mirror of
https://github.com/qf3l3k/obsidian-data-fetcher.git
synced 2026-07-22 05:43:10 +00:00
Initial commit
This commit is contained in:
commit
f031902d33
19 changed files with 3459 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
Normal file
23
.eslintrc
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
}
|
||||
}
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Copyright (C) 2020-2025 by Dynalist Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
116
README.md
Normal file
116
README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Obsidian Data Fetcher
|
||||
|
||||
A plugin for [Obsidian](https://obsidian.md) that allows users to fetch data from multiple sources (REST APIs, GraphQL, gRPC, RPC) and insert the results into notes.
|
||||
|
||||
## Features
|
||||
|
||||
- Support for multiple data sources:
|
||||
- REST APIs
|
||||
- GraphQL endpoints
|
||||
- gRPC services (via REST proxies)
|
||||
- RPC services
|
||||
- Two modes of operation:
|
||||
- Directly define queries within notes
|
||||
- Predefine endpoints in settings and reference them in notes
|
||||
- Automatic caching of query results to reduce redundant requests
|
||||
- Cache expiration settings
|
||||
- Manual refresh capabilities
|
||||
- Customizable headers for authentication
|
||||
|
||||
## Installation
|
||||
|
||||
### From Obsidian Community Plugins
|
||||
|
||||
1. Open Obsidian Settings
|
||||
2. Go to Community Plugins
|
||||
3. Search for "Data Fetcher"
|
||||
4. Click Install, then Enable
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from the [releases page](https://github.com/qf3l3k/obsidian-api-fetcher/releases)
|
||||
2. Extract the zip file into your Obsidian vault's `.obsidian/plugins` folder
|
||||
3. Enable the plugin in Obsidian settings
|
||||
|
||||
## Usage
|
||||
|
||||
### Method 1: Direct Query Definition
|
||||
|
||||
Create a code block with the language set to `data-query` and define your query in JSON format:
|
||||
|
||||
```
|
||||
```data-query
|
||||
{
|
||||
"type": "rest",
|
||||
"url": "https://api.example.com/data",
|
||||
"method": "GET",
|
||||
"headers": {
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Method 2: Using Aliases
|
||||
|
||||
1. First, define an endpoint alias in the plugin settings
|
||||
2. Then reference it in your notes:
|
||||
|
||||
```
|
||||
```data-query
|
||||
@my-api-alias
|
||||
body: {"id": 123}
|
||||
```
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Go to Settings > Data Fetcher to configure:
|
||||
|
||||
- Cache duration
|
||||
- Pre-defined endpoint aliases
|
||||
- Default headers
|
||||
|
||||
## Examples
|
||||
|
||||
### REST API Example
|
||||
|
||||
```
|
||||
```data-query
|
||||
{
|
||||
"type": "rest",
|
||||
"url": "https://jsonplaceholder.typicode.com/posts/1",
|
||||
"method": "GET"
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### GraphQL Example
|
||||
|
||||
```
|
||||
```data-query
|
||||
{
|
||||
"type": "graphql",
|
||||
"url": "https://api.spacex.land/graphql",
|
||||
"query": "{ launchesPast(limit: 5) { mission_name launch_date_local } }",
|
||||
"variables": {}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Using Aliases
|
||||
|
||||
```
|
||||
```data-query
|
||||
@github-api
|
||||
query: query { viewer { repositories(first: 5) { nodes { name } } } }
|
||||
```
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have feature requests, please file them on the [GitHub issues page](https://github.com/yourusername/obsidian-api-fetcher/issues).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
385
main.ts
Normal file
385
main.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { DataFetcherSettings, DEFAULT_SETTINGS } from './src/settings';
|
||||
import { parseDataQuery, executeQuery, QueryParams, QueryResult } from './src/queryEngine';
|
||||
import { CacheManager } from './src/cacheManager';
|
||||
|
||||
export default class DataFetcherPlugin extends Plugin {
|
||||
settings: DataFetcherSettings;
|
||||
cacheManager: CacheManager;
|
||||
|
||||
async onload() {
|
||||
console.log('Loading Data Fetcher plugin');
|
||||
|
||||
await this.loadSettings();
|
||||
this.cacheManager = new CacheManager(this.app, this);
|
||||
|
||||
// Register the data fetcher processor for codeblocks
|
||||
this.registerMarkdownCodeBlockProcessor('data-query', async (source, el, ctx) => {
|
||||
try {
|
||||
const query = parseDataQuery(source, this.settings);
|
||||
const cachedResult = await this.cacheManager.getFromCache(query);
|
||||
|
||||
if (cachedResult) {
|
||||
this.renderResult(cachedResult, el, query); // Pass the query here
|
||||
} else {
|
||||
el.createEl('div', { text: 'Fetching data...', cls: 'data-fetcher-loading' });
|
||||
const result = await executeQuery(query);
|
||||
await this.cacheManager.saveToCache(query, result);
|
||||
el.empty();
|
||||
this.renderResult(result, el, query); // Pass the query here
|
||||
}
|
||||
} catch (error) {
|
||||
el.createEl('div', { text: `Error: ${error.message}`, cls: 'data-fetcher-error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add the command to manually refresh data
|
||||
this.addCommand({
|
||||
id: 'refresh-data-query',
|
||||
name: 'Refresh Data Query',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
new Notice('Refreshing data queries in the current note...');
|
||||
this.app.workspace.trigger('data-fetcher:refresh-query');
|
||||
}
|
||||
});
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new DataFetcherSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
renderResult(result: QueryResult, container: HTMLElement, query?: QueryParams) {
|
||||
const resultContainer = container.createEl('div', { cls: 'data-fetcher-result' });
|
||||
|
||||
// Create header with timestamp and refresh button
|
||||
const header = resultContainer.createEl('div', { cls: 'data-fetcher-header' });
|
||||
header.createEl('span', {
|
||||
text: `Last updated: ${new Date(result.timestamp).toLocaleString()}`,
|
||||
cls: 'data-fetcher-timestamp'
|
||||
});
|
||||
|
||||
const refreshBtn = header.createEl('button', {
|
||||
text: 'Refresh',
|
||||
cls: 'data-fetcher-refresh'
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
// Clear the container and show loading
|
||||
container.empty();
|
||||
container.createEl('div', { text: 'Refreshing data...', cls: 'data-fetcher-loading' });
|
||||
|
||||
// Re-execute the query using the stored query data
|
||||
const storedQuery = (refreshBtn as any).query;
|
||||
if (!storedQuery) {
|
||||
throw new Error('Query data not found');
|
||||
}
|
||||
|
||||
const result = await executeQuery(storedQuery);
|
||||
|
||||
// Update the cache
|
||||
await this.cacheManager.saveToCache(storedQuery, result);
|
||||
|
||||
// Re-render the result
|
||||
container.empty();
|
||||
this.renderResult(result, container, storedQuery);
|
||||
|
||||
new Notice('Data refreshed successfully');
|
||||
} catch (error) {
|
||||
container.empty();
|
||||
container.createEl('div', { text: `Error: ${error.message}`, cls: 'data-fetcher-error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Store the query with the button for later use
|
||||
if (query) {
|
||||
(refreshBtn as any).query = query;
|
||||
}
|
||||
|
||||
// Create the content container
|
||||
const content = resultContainer.createEl('div', { cls: 'data-fetcher-content' });
|
||||
|
||||
// Render the data based on type
|
||||
if (typeof result.data === 'object') {
|
||||
const pre = content.createEl('pre');
|
||||
pre.createEl('code', { text: JSON.stringify(result.data, null, 2) });
|
||||
} else {
|
||||
content.setText(String(result.data));
|
||||
}
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log('Unloading Data Fetcher plugin');
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class DataFetcherSettingTab extends PluginSettingTab {
|
||||
plugin: DataFetcherPlugin;
|
||||
|
||||
constructor(app: App, plugin: DataFetcherPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
// Cache Management section
|
||||
containerEl.createEl('h3', {text: 'Cache Management'});
|
||||
|
||||
const cacheInfoEl = containerEl.createEl('div', {
|
||||
cls: 'data-fetcher-cache-info'
|
||||
});
|
||||
|
||||
// Get and display cache info
|
||||
this.updateCacheInfo(cacheInfoEl);
|
||||
|
||||
// Clear cache button
|
||||
new Setting(containerEl)
|
||||
.setName('Clear Cache')
|
||||
.setDesc('Remove all cached API responses')
|
||||
.addButton(button => button
|
||||
.setButtonText('Clear Cache')
|
||||
.setClass('data-fetcher-clear-cache')
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await this.plugin.cacheManager.clearAllCache();
|
||||
new Notice('Cache cleared successfully');
|
||||
this.updateCacheInfo(cacheInfoEl);
|
||||
} catch (error) {
|
||||
new Notice('Failed to clear cache: ' + error.message);
|
||||
}
|
||||
}));
|
||||
|
||||
containerEl.createEl('h3', {text: 'Data Fetcher Settings'});
|
||||
|
||||
// General settings
|
||||
new Setting(containerEl)
|
||||
.setName('Cache Duration')
|
||||
.setDesc('How long to cache results (in minutes)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('60')
|
||||
.setValue(this.plugin.settings.cacheDuration.toString())
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.cacheDuration = parseInt(value) || 60;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Endpoint aliases section
|
||||
containerEl.createEl('h3', {text: 'Endpoint Aliases'});
|
||||
|
||||
const endpointList = containerEl.createEl('div', {cls: 'endpoint-list'});
|
||||
|
||||
// Create UI for each existing endpoint
|
||||
this.plugin.settings.endpoints.forEach((endpoint, index) => {
|
||||
this.createEndpointSetting(endpointList, endpoint, index);
|
||||
});
|
||||
|
||||
// Add new endpoint button
|
||||
new Setting(containerEl)
|
||||
.addButton(button => button
|
||||
.setButtonText('Add Endpoint')
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.endpoints.push({
|
||||
alias: '',
|
||||
url: '',
|
||||
method: 'GET',
|
||||
type: 'rest',
|
||||
headers: {}
|
||||
});
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
}
|
||||
|
||||
createEndpointSetting(container: HTMLElement, endpoint: any, index: number) {
|
||||
const endpointEl = container.createEl('div', {cls: 'endpoint-item'});
|
||||
|
||||
new Setting(endpointEl)
|
||||
.setName('Alias')
|
||||
.addText(text => text
|
||||
.setPlaceholder('api-name')
|
||||
.setValue(endpoint.alias)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.endpoints[index].alias = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(endpointEl)
|
||||
.setName('Type')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('rest', 'REST')
|
||||
.addOption('graphql', 'GraphQL')
|
||||
.addOption('grpc', 'gRPC')
|
||||
.addOption('rpc', 'RPC')
|
||||
.setValue(endpoint.type)
|
||||
.onChange(async (value) => {
|
||||
const validTypes = ["rest", "graphql", "grpc", "rpc"];
|
||||
if (validTypes.includes(value)) {
|
||||
this.plugin.settings.endpoints[index].type = value as "rest" | "graphql" | "grpc" | "rpc";
|
||||
} else {
|
||||
// Default to REST if invalid type
|
||||
this.plugin.settings.endpoints[index].type = "rest";
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
new Setting(endpointEl)
|
||||
.setName('URL')
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://api.example.com')
|
||||
.setValue(endpoint.url)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.endpoints[index].url = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Add different fields based on endpoint type
|
||||
if (endpoint.type === 'rest' || endpoint.type === 'rpc') {
|
||||
new Setting(endpointEl)
|
||||
.setName('Method')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('GET', 'GET')
|
||||
.addOption('POST', 'POST')
|
||||
.addOption('PUT', 'PUT')
|
||||
.addOption('DELETE', 'DELETE')
|
||||
.setValue(endpoint.method)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.endpoints[index].method = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
|
||||
// Add headers section
|
||||
new Setting(endpointEl)
|
||||
.setName('Headers')
|
||||
.setDesc('Add headers for this endpoint')
|
||||
.addButton(button => button
|
||||
.setButtonText('Manage Headers')
|
||||
.onClick(() => {
|
||||
new HeadersModal(this.app, endpoint.headers, async (headers) => {
|
||||
this.plugin.settings.endpoints[index].headers = headers;
|
||||
await this.plugin.saveSettings();
|
||||
}).open();
|
||||
}));
|
||||
|
||||
// Delete button
|
||||
new Setting(endpointEl)
|
||||
.addButton(button => button
|
||||
.setButtonText('Delete')
|
||||
.setClass('data-fetcher-delete-btn')
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.endpoints.splice(index, 1);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
}
|
||||
|
||||
async updateCacheInfo(containerEl: HTMLElement) {
|
||||
const cacheInfo = await this.plugin.cacheManager.getCacheInfo();
|
||||
containerEl.empty();
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' bytes';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
containerEl.createEl('div', {
|
||||
text: `Cache contains ${cacheInfo.count} items (${formatSize(cacheInfo.size)})`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class HeadersModal extends Modal {
|
||||
headers: Record<string, string>;
|
||||
onSubmit: (headers: Record<string, string>) => void;
|
||||
|
||||
constructor(app: App, headers: Record<string, string>, onSubmit: (headers: Record<string, string>) => void) {
|
||||
super(app);
|
||||
this.headers = {...headers};
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const {contentEl} = this;
|
||||
|
||||
contentEl.createEl('h2', {text: 'Manage Headers'});
|
||||
|
||||
// Display existing headers
|
||||
Object.entries(this.headers).forEach(([key, value]) => {
|
||||
this.createHeaderRow(contentEl, key, value);
|
||||
});
|
||||
|
||||
// Add new header button
|
||||
const addBtn = contentEl.createEl('button', {text: 'Add Header'});
|
||||
addBtn.addEventListener('click', () => {
|
||||
this.createHeaderRow(contentEl, '', '');
|
||||
});
|
||||
|
||||
// Save button
|
||||
const saveBtn = contentEl.createEl('button', {text: 'Save', cls: 'mod-cta'});
|
||||
saveBtn.addEventListener('click', () => {
|
||||
this.onSubmit(this.headers);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
createHeaderRow(container: HTMLElement, key: string, value: string) {
|
||||
const row = container.createEl('div', {cls: 'header-row'});
|
||||
|
||||
const keyInput = row.createEl('input', {
|
||||
attr: {
|
||||
type: 'text',
|
||||
placeholder: 'Header name',
|
||||
value: key
|
||||
}
|
||||
});
|
||||
|
||||
const valueInput = row.createEl('input', {
|
||||
attr: {
|
||||
type: 'text',
|
||||
placeholder: 'Value',
|
||||
value: value
|
||||
}
|
||||
});
|
||||
|
||||
const deleteBtn = row.createEl('button', {text: 'X'});
|
||||
|
||||
// Event listeners
|
||||
const oldKey = key;
|
||||
|
||||
keyInput.addEventListener('change', () => {
|
||||
if (oldKey) {
|
||||
delete this.headers[oldKey];
|
||||
}
|
||||
this.headers[keyInput.value] = valueInput.value;
|
||||
});
|
||||
|
||||
valueInput.addEventListener('change', () => {
|
||||
this.headers[keyInput.value] = valueInput.value;
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
if (keyInput.value) {
|
||||
delete this.headers[keyInput.value];
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "obsidian-api-fetcher",
|
||||
"name": "Data Fetcher",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Fetch data from multiple sources (REST APIs, RPC, gRPC, GraphQL) and insert results into notes",
|
||||
"author": "qf3l3k",
|
||||
"authorUrl": "https://github.com/qf3l3k/obsidian-api-fetcher",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
2227
package-lock.json
generated
Normal file
2227
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "obsidian-api-fetcher",
|
||||
"version": "1.0.0",
|
||||
"description": "Fetch data from multiple sources (REST APIs, RPC, gRPC, GraphQL) and insert results into notes",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": ["obsidian", "plugin", "data", "api", "fetch"],
|
||||
"author": "qf3l3k",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
143
src/cacheManager.ts
Normal file
143
src/cacheManager.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { App } from 'obsidian';
|
||||
import { QueryParams, QueryResult } from './queryEngine';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export class CacheManager {
|
||||
private app: App;
|
||||
private plugin: any;
|
||||
private cache: Record<string, QueryResult> = {};
|
||||
|
||||
constructor(app: App, plugin: any) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.loadCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache from plugin data
|
||||
*/
|
||||
private async loadCache(): Promise<void> {
|
||||
try {
|
||||
const data = await this.plugin.loadData();
|
||||
this.cache = data?.cache || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to load cache:', error);
|
||||
this.cache = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to plugin data
|
||||
*/
|
||||
private async saveCache(): Promise<void> {
|
||||
try {
|
||||
const data = await this.plugin.loadData() || {};
|
||||
data.cache = this.cache;
|
||||
await this.plugin.saveData(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key for a query
|
||||
*/
|
||||
private generateCacheKey(params: QueryParams): string {
|
||||
// Create a unique key based on query parameters
|
||||
const stringToHash = JSON.stringify({
|
||||
url: params.url,
|
||||
type: params.type,
|
||||
method: params.method,
|
||||
body: params.body,
|
||||
query: params.query,
|
||||
variables: params.variables
|
||||
});
|
||||
|
||||
return crypto.createHash('md5').update(stringToHash).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached result for a query
|
||||
*/
|
||||
async getFromCache(params: QueryParams): Promise<QueryResult | null> {
|
||||
try {
|
||||
const cacheKey = this.generateCacheKey(params);
|
||||
const cachedItem = this.cache[cacheKey];
|
||||
|
||||
if (!cachedItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if cache is expired
|
||||
const now = Date.now();
|
||||
const cacheAge = now - cachedItem.timestamp;
|
||||
const cacheDurationMs = this.plugin.settings.cacheDuration * 60 * 1000;
|
||||
|
||||
if (cacheAge > cacheDurationMs) {
|
||||
delete this.cache[cacheKey];
|
||||
await this.saveCache();
|
||||
return null; // Cache is expired
|
||||
}
|
||||
|
||||
return cachedItem;
|
||||
} catch (error) {
|
||||
console.error('Error reading from cache:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save result to cache
|
||||
*/
|
||||
async saveToCache(params: QueryParams, result: QueryResult): Promise<void> {
|
||||
try {
|
||||
const cacheKey = this.generateCacheKey(params);
|
||||
this.cache[cacheKey] = result;
|
||||
await this.saveCache();
|
||||
} catch (error) {
|
||||
console.error('Error saving to cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific cache entry
|
||||
*/
|
||||
async clearCacheEntry(params: QueryParams): Promise<void> {
|
||||
try {
|
||||
const cacheKey = this.generateCacheKey(params);
|
||||
delete this.cache[cacheKey];
|
||||
await this.saveCache();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache entry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
this.cache = {};
|
||||
await this.saveCache();
|
||||
} catch (error) {
|
||||
console.error('Error clearing all cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache size information
|
||||
* Returns the number of items and total size in bytes
|
||||
*/
|
||||
async getCacheInfo(): Promise<{count: number, size: number}> {
|
||||
try {
|
||||
const count = Object.keys(this.cache).length;
|
||||
const cacheString = JSON.stringify(this.cache);
|
||||
const size = new TextEncoder().encode(cacheString).length;
|
||||
|
||||
return { count, size };
|
||||
} catch (error) {
|
||||
console.error('Error getting cache info:', error);
|
||||
return { count: 0, size: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
279
src/queryEngine.ts
Normal file
279
src/queryEngine.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { DataFetcherSettings } from './settings';
|
||||
import { requestUrl, RequestUrlParam } from 'obsidian';
|
||||
|
||||
export interface QueryParams {
|
||||
endpoint: string;
|
||||
type: 'rest' | 'graphql' | 'grpc' | 'rpc';
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
query?: string;
|
||||
variables?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the data query from the codeblock
|
||||
*/
|
||||
export function parseDataQuery(source: string, settings: DataFetcherSettings): QueryParams {
|
||||
try {
|
||||
// Check if using reference or direct definition
|
||||
if (source.trim().startsWith('@')) {
|
||||
// Using a reference to predefined endpoint
|
||||
const lines = source.trim().split('\n');
|
||||
const aliasLine = lines[0].trim();
|
||||
const alias = aliasLine.substring(1); // Remove the @ prefix
|
||||
|
||||
// Find the endpoint by alias
|
||||
const endpoint = settings.endpoints.find(e => e.alias === alias);
|
||||
if (!endpoint) {
|
||||
throw new Error(`Endpoint alias "${alias}" not found in settings`);
|
||||
}
|
||||
|
||||
// Parse additional parameters if any
|
||||
const queryParams: QueryParams = {
|
||||
endpoint: alias,
|
||||
type: endpoint.type,
|
||||
url: endpoint.url,
|
||||
method: endpoint.method,
|
||||
headers: { ...endpoint.headers }
|
||||
};
|
||||
|
||||
// Process remaining lines as additional parameters
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!line) continue;
|
||||
|
||||
// Check if it's a parameter assignment
|
||||
if (line.includes(':')) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
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 (key.trim() === 'body') {
|
||||
try {
|
||||
queryParams.body = JSON.parse(value);
|
||||
} catch {
|
||||
queryParams.body = value;
|
||||
}
|
||||
} else if (key.trim() === 'query') {
|
||||
queryParams.query = value;
|
||||
} else if (key.trim() === 'variables') {
|
||||
try {
|
||||
queryParams.variables = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error('Variables must be valid JSON');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
} else {
|
||||
// Direct definition
|
||||
try {
|
||||
const queryObj = JSON.parse(source);
|
||||
|
||||
// Validate required fields
|
||||
if (!queryObj.type) {
|
||||
throw new Error('Query type is required');
|
||||
}
|
||||
|
||||
if (!queryObj.url) {
|
||||
throw new Error('URL is required');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: 'direct',
|
||||
...queryObj
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid query format: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse query: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the query based on the parsed parameters
|
||||
*/
|
||||
export async function executeQuery(params: QueryParams): Promise<QueryResult> {
|
||||
try {
|
||||
let data: any;
|
||||
|
||||
switch (params.type) {
|
||||
case 'rest':
|
||||
data = await executeRestQuery(params);
|
||||
break;
|
||||
case 'graphql':
|
||||
data = await executeGraphQLQuery(params);
|
||||
break;
|
||||
case 'grpc':
|
||||
data = await executeGrpcQuery(params);
|
||||
break;
|
||||
case 'rpc':
|
||||
data = await executeRpcQuery(params);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported query type: ${params.type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Query execution error:', error);
|
||||
return {
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute REST API query
|
||||
*/
|
||||
async function executeRestQuery(params: QueryParams): Promise<any> {
|
||||
if (!params.url) {
|
||||
throw new Error('URL is required for REST queries');
|
||||
}
|
||||
|
||||
const requestParams: RequestUrlParam = {
|
||||
url: params.url,
|
||||
method: params.method || 'GET',
|
||||
headers: params.headers || {},
|
||||
};
|
||||
|
||||
if (params.body) {
|
||||
if (typeof params.body === 'object') {
|
||||
requestParams.body = JSON.stringify(params.body);
|
||||
if (!requestParams.headers) {
|
||||
requestParams.headers = {};
|
||||
}
|
||||
|
||||
if (!requestParams.headers['Content-Type']) {
|
||||
requestParams.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
} else {
|
||||
requestParams.body = params.body;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await requestUrl(requestParams);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute GraphQL query
|
||||
*/
|
||||
async function executeGraphQLQuery(params: QueryParams): Promise<any> {
|
||||
if (!params.url) {
|
||||
throw new Error('URL is required for GraphQL queries');
|
||||
}
|
||||
|
||||
if (!params.query) {
|
||||
throw new Error('Query is required for GraphQL queries');
|
||||
}
|
||||
|
||||
const requestParams: RequestUrlParam = {
|
||||
url: params.url,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.headers || {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
variables: params.variables || {}
|
||||
})
|
||||
};
|
||||
|
||||
const response = await requestUrl(requestParams);
|
||||
return response.json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute gRPC query
|
||||
* Note: This is a simplified implementation as Obsidian doesn't have direct gRPC support
|
||||
* This will use a REST proxy approach for gRPC
|
||||
*/
|
||||
async function executeGrpcQuery(params: QueryParams): Promise<any> {
|
||||
if (!params.url) {
|
||||
throw new Error('URL is required for gRPC queries');
|
||||
}
|
||||
|
||||
// For gRPC, we're assuming a REST proxy is set up
|
||||
// We'll send a POST request with the appropriate parameters
|
||||
const requestParams: RequestUrlParam = {
|
||||
url: params.url,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.headers || {})
|
||||
},
|
||||
body: JSON.stringify(params.body || {})
|
||||
};
|
||||
|
||||
const response = await requestUrl(requestParams);
|
||||
return response.json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute RPC query
|
||||
*/
|
||||
async function executeRpcQuery(params: QueryParams): Promise<any> {
|
||||
if (!params.url) {
|
||||
throw new Error('URL is required for RPC queries');
|
||||
}
|
||||
|
||||
const requestParams: RequestUrlParam = {
|
||||
url: params.url,
|
||||
method: params.method || 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.headers || {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: params.query || 'method',
|
||||
params: params.body || {},
|
||||
id: 1
|
||||
})
|
||||
};
|
||||
|
||||
const response = await requestUrl(requestParams);
|
||||
return response.json;
|
||||
}
|
||||
19
src/settings.ts
Normal file
19
src/settings.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export interface EndpointConfig {
|
||||
alias: string;
|
||||
url: string;
|
||||
method: string;
|
||||
type: 'rest' | 'graphql' | 'grpc' | 'rpc';
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface DataFetcherSettings {
|
||||
cacheDuration: number; // in minutes
|
||||
endpoints: EndpointConfig[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: DataFetcherSettings = {
|
||||
cacheDuration: 60,
|
||||
endpoints: []
|
||||
}
|
||||
102
styles.css
Normal file
102
styles.css
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/* Data Fetcher Plugin Styles */
|
||||
|
||||
.data-fetcher-result {
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-fetcher-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--background-secondary);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.data-fetcher-timestamp {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-fetcher-refresh {
|
||||
font-size: 0.8em;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-fetcher-refresh:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.data-fetcher-content {
|
||||
padding: 12px;
|
||||
background-color: var(--background-primary);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-fetcher-content pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.data-fetcher-error {
|
||||
padding: 10px;
|
||||
color: var(--text-error);
|
||||
background-color: var(--background-modifier-error);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.data-fetcher-loading {
|
||||
padding: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Settings styles */
|
||||
.endpoint-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 5px;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-row input {
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.data-fetcher-delete-btn {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.data-fetcher-clear-cache {
|
||||
background-color: var(--text-error) !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
}
|
||||
|
||||
.data-fetcher-clear-cache:hover {
|
||||
background-color: var(--text-error-hover) !important;
|
||||
}
|
||||
|
||||
.data-fetcher-cache-info {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue