Skip to content

Commit d6b07c9

Browse files
committed
Initial release v0.1.0
UniRate Currency: inline live and historical exchange rates for Obsidian. - Inline syntax: `usd:eur`, `usd:eur:100`, `100 usd:eur`, with `:`, `>`, or `→` - Commands: Insert exchange rate, Convert selection, Clear cache - Settings: API key, default base currency, rate/money decimals, cache TTLs - 35 jest tests (parser, cache, format, client) - Built with esbuild against the official sample-plugin scaffold
1 parent 558c316 commit d6b07c9

26 files changed

Lines changed: 5705 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
node: ["18", "20", "22"]
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: ${{ matrix.node }}
19+
- run: npm ci
20+
- run: npm test
21+
- run: npm run build

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "[0-9]+.[0-9]+.[0-9]+"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: "20"
20+
21+
- run: npm ci
22+
- run: npm test
23+
- run: npm run build
24+
25+
- name: Create release
26+
env:
27+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28+
TAG: ${{ github.ref_name }}
29+
run: |
30+
gh release create "$TAG" \
31+
--title="$TAG" \
32+
--notes="Release $TAG. See README for install/upgrade." \
33+
main.js manifest.json styles.css

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules/
2+
main.js
3+
*.log
4+
.DS_Store
5+
.idea/
6+
.vscode/
7+
coverage/

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2026 UniRate API
3+
Copyright (c) 2026 UniRate-API
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# UniRate Currency for Obsidian
2+
3+
Inline live and historical currency exchange rates in your notes.
4+
5+
Type `` `usd:eur` `` and the plugin renders the current USD→EUR rate where the inline code used to be. Type `` `usd:eur:100` `` and it renders the converted amount. 170+ currencies plus the major cryptocurrencies, with daily history back to 1999 (Pro plan).
6+
7+
Powered by [UniRateAPI](https://unirateapi.com).
8+
9+
## Why
10+
11+
Existing Obsidian finance plugins are either crypto-only (`obsidian-crypto-lookup`) or general-purpose calculators that happen to handle currency (`obsidian-numerals`). Neither gives you inline, in-line live rates in normal prose. A [2022 forum thread](https://forum.obsidian.md/) asking for one is still open.
12+
13+
## Install
14+
15+
Until the plugin is approved on the community plugins list, install manually:
16+
17+
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/UniRate-API/obsidian-currency/releases).
18+
2. Drop them into `<your vault>/.obsidian/plugins/unirate-currency/`.
19+
3. Reload Obsidian and enable **UniRate Currency** under Settings → Community plugins.
20+
4. Open the **UniRate Currency** tab and paste your API key (get a [free key](https://unirateapi.com/register)).
21+
22+
## Syntax
23+
24+
All forms go inside a single inline code span (`` `…` ``):
25+
26+
| You write | Renders as |
27+
|---|---|
28+
| `` `usd:eur` `` | `0.9234 EUR/USD` |
29+
| `` `usd:eur:100` `` | `92.34 EUR` |
30+
| `` `100 usd:eur` `` | `92.34 EUR` |
31+
| `` `gbp>jpy` `` | `198.42 JPY/GBP` |
32+
| `` `usd→eur` `` | `0.9234 EUR/USD` |
33+
34+
- Currency codes can be 3-5 characters, case-insensitive (`USDT`, `BTC`, `XAU`).
35+
- Decimal places, thousand-grouping, and the appended currency code are all configurable.
36+
- Rates are cached in memory for 60 minutes by default to avoid hammering the API.
37+
38+
## Commands
39+
40+
- **Insert exchange rate** — opens a modal to pick from/to/amount/date and inserts the result at the cursor.
41+
- **Convert selection** — select something like `100 USD to EUR` (or `100 usd:eur`) and run the command to replace the selection with the converted value.
42+
- **Clear rate cache** — drop the in-memory cache; the next render will re-fetch.
43+
44+
## Pricing
45+
46+
UniRateAPI's free tier covers live rates and supports 170+ currencies. Historical rates and commodities (`XAU`, `XAG`, `XPT`, `XPD`) require a [Pro plan](https://unirateapi.com/pricing).
47+
48+
## Disclosure
49+
50+
This plugin is built and maintained by UniRateAPI. If you'd rather use a different rate source, the inline syntax is generic enough that another plugin could implement it the same way — open an issue if you'd find that useful.
51+
52+
## Development
53+
54+
```bash
55+
npm install
56+
npm run dev # esbuild watch
57+
npm run build # tsc + esbuild production
58+
npm test # jest unit tests
59+
```
60+
61+
The plugin uses Obsidian's `requestUrl` API so it works on both desktop and mobile without CORS workarounds.
62+
63+
## License
64+
65+
MIT. See `LICENSE`.

__tests__/__mocks__/obsidian.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
export const requestUrl = jest.fn(async (_opts: unknown) => ({
2+
status: 200,
3+
headers: {},
4+
arrayBuffer: new ArrayBuffer(0),
5+
text: "{}",
6+
json: {},
7+
}));
8+
9+
export class Notice {
10+
constructor(_message: string) {}
11+
}
12+
13+
export class Modal {
14+
app: unknown;
15+
contentEl = { empty: () => undefined, createEl: () => undefined };
16+
constructor(app: unknown) {
17+
this.app = app;
18+
}
19+
open(): void {}
20+
close(): void {}
21+
}
22+
23+
export class Plugin {
24+
app: unknown;
25+
manifest: unknown;
26+
constructor(app?: unknown, manifest?: unknown) {
27+
this.app = app;
28+
this.manifest = manifest;
29+
}
30+
}
31+
32+
export class PluginSettingTab {
33+
app: unknown;
34+
plugin: unknown;
35+
containerEl: { empty: () => void } = { empty: () => undefined };
36+
constructor(app: unknown, plugin: unknown) {
37+
this.app = app;
38+
this.plugin = plugin;
39+
}
40+
display(): void {}
41+
}
42+
43+
export class Setting {
44+
constructor(_containerEl: unknown) {}
45+
setName(): this {
46+
return this;
47+
}
48+
setDesc(): this {
49+
return this;
50+
}
51+
addText(): this {
52+
return this;
53+
}
54+
addToggle(): this {
55+
return this;
56+
}
57+
addSlider(): this {
58+
return this;
59+
}
60+
addButton(): this {
61+
return this;
62+
}
63+
}
64+
65+
export type App = unknown;
66+
export type Editor = unknown;
67+
export type MarkdownView = unknown;
68+
export type MarkdownPostProcessorContext = unknown;

__tests__/cache.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { TTLCache } from "../src/cache";
2+
3+
describe("TTLCache", () => {
4+
test("returns set value before TTL expires", () => {
5+
let now = 1_000_000;
6+
const cache = new TTLCache<number>(() => now);
7+
cache.set("k", 42, 60_000);
8+
now += 30_000;
9+
expect(cache.get("k")).toBe(42);
10+
});
11+
12+
test("evicts after TTL expires", () => {
13+
let now = 1_000_000;
14+
const cache = new TTLCache<number>(() => now);
15+
cache.set("k", 42, 60_000);
16+
now += 60_001;
17+
expect(cache.get("k")).toBeUndefined();
18+
expect(cache.size()).toBe(0);
19+
});
20+
21+
test("returns undefined for unknown key", () => {
22+
const cache = new TTLCache<number>();
23+
expect(cache.get("missing")).toBeUndefined();
24+
});
25+
26+
test("clear empties the store", () => {
27+
const cache = new TTLCache<number>();
28+
cache.set("a", 1, 1000);
29+
cache.set("b", 2, 1000);
30+
expect(cache.size()).toBe(2);
31+
cache.clear();
32+
expect(cache.size()).toBe(0);
33+
expect(cache.get("a")).toBeUndefined();
34+
});
35+
36+
test("overwrites existing key with fresh TTL", () => {
37+
let now = 1_000_000;
38+
const cache = new TTLCache<number>(() => now);
39+
cache.set("k", 1, 1000);
40+
now += 500;
41+
cache.set("k", 2, 1000);
42+
now += 800;
43+
expect(cache.get("k")).toBe(2);
44+
});
45+
});

__tests__/client.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { requestUrl } from "obsidian";
2+
import { UniRateClient, UniRateClientError } from "../src/client";
3+
4+
const mockedRequestUrl = requestUrl as unknown as jest.Mock;
5+
6+
function makeResponse(body: unknown, status = 200) {
7+
return {
8+
status,
9+
headers: {},
10+
arrayBuffer: new ArrayBuffer(0),
11+
text: JSON.stringify(body),
12+
json: body,
13+
};
14+
}
15+
16+
describe("UniRateClient", () => {
17+
beforeEach(() => {
18+
mockedRequestUrl.mockReset();
19+
});
20+
21+
test("getRate hits /api/rates and returns the target rate", async () => {
22+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({ rates: { EUR: 0.92 } }));
23+
const c = new UniRateClient("k");
24+
expect(await c.getRate("USD", "EUR")).toBe(0.92);
25+
const call = mockedRequestUrl.mock.calls[0][0];
26+
expect(call.url).toContain("/api/rates");
27+
expect(call.url).toContain("from=USD");
28+
expect(call.url).toContain("to=EUR");
29+
expect(call.url).toContain("api_key=k");
30+
});
31+
32+
test("getRate routes to historical endpoint when date is given", async () => {
33+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({ rates: { EUR: 0.91 } }));
34+
const c = new UniRateClient("k");
35+
expect(await c.getRate("USD", "EUR", "2024-01-15")).toBe(0.91);
36+
expect(mockedRequestUrl.mock.calls[0][0].url).toContain("/api/historical/rates");
37+
expect(mockedRequestUrl.mock.calls[0][0].url).toContain("date=2024-01-15");
38+
});
39+
40+
test("convert multiplies amount by rate", async () => {
41+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({ rates: { EUR: 0.5 } }));
42+
const c = new UniRateClient("k");
43+
const r = await c.convert("USD", "EUR", 100);
44+
expect(r.rate).toBe(0.5);
45+
expect(r.result).toBe(50);
46+
});
47+
48+
test("throws AuthenticationError-style on 401", async () => {
49+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({}, 401));
50+
const c = new UniRateClient("bad");
51+
await expect(c.getRate("USD", "EUR")).rejects.toMatchObject({
52+
name: "UniRateClientError",
53+
status: 401,
54+
});
55+
});
56+
57+
test("throws on 403 with Pro-plan message", async () => {
58+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({}, 403));
59+
const c = new UniRateClient("k");
60+
await expect(c.getRate("USD", "XAU", "2024-01-15")).rejects.toMatchObject({
61+
status: 403,
62+
message: expect.stringContaining("Pro"),
63+
});
64+
});
65+
66+
test("throws on 429", async () => {
67+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({}, 429));
68+
const c = new UniRateClient("k");
69+
await expect(c.getRate("USD", "EUR")).rejects.toMatchObject({ status: 429 });
70+
});
71+
72+
test("throws on 404", async () => {
73+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({}, 404));
74+
const c = new UniRateClient("k");
75+
await expect(c.getRate("USD", "ZZZ")).rejects.toMatchObject({ status: 404 });
76+
});
77+
78+
test("throws when API key is missing", async () => {
79+
const c = new UniRateClient("");
80+
await expect(c.getRate("USD", "EUR")).rejects.toBeInstanceOf(UniRateClientError);
81+
expect(mockedRequestUrl).not.toHaveBeenCalled();
82+
});
83+
84+
test("listCurrencies sorts and returns the list", async () => {
85+
mockedRequestUrl.mockResolvedValueOnce(
86+
makeResponse({ currencies: ["EUR", "USD", "GBP"] }),
87+
);
88+
const c = new UniRateClient("k");
89+
expect(await c.listCurrencies()).toEqual(["EUR", "GBP", "USD"]);
90+
});
91+
92+
test("throws when /api/rates response is missing target", async () => {
93+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({ rates: { JPY: 150 } }));
94+
const c = new UniRateClient("k");
95+
await expect(c.getRate("USD", "EUR")).rejects.toBeInstanceOf(UniRateClientError);
96+
});
97+
98+
test("sends Accept: application/json header (workaround for /api/currencies HTML 404)", async () => {
99+
mockedRequestUrl.mockResolvedValueOnce(makeResponse({ currencies: ["USD"] }));
100+
const c = new UniRateClient("k");
101+
await c.listCurrencies();
102+
const call = mockedRequestUrl.mock.calls[0][0];
103+
expect(call.headers).toMatchObject({ Accept: "application/json" });
104+
});
105+
});

__tests__/format.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { formatMoney, formatRate } from "../src/format";
2+
3+
describe("formatRate", () => {
4+
test("rounds to requested decimals", () => {
5+
expect(formatRate(0.923456, 4)).toBe("0.9235");
6+
expect(formatRate(0.923456, 2)).toBe("0.92");
7+
});
8+
9+
test("pads zeroes for short values", () => {
10+
expect(formatRate(1, 4)).toBe("1.0000");
11+
});
12+
13+
test("does not use thousand separators", () => {
14+
expect(formatRate(12345.678, 2)).toBe("12345.68");
15+
});
16+
17+
test("clamps absurd decimal counts", () => {
18+
expect(formatRate(1, 99)).toBe("1.0000000000");
19+
expect(formatRate(1, -5)).toBe("1");
20+
});
21+
22+
test("handles NaN/Infinity", () => {
23+
expect(formatRate(NaN, 2)).toBe("NaN");
24+
expect(formatRate(Infinity, 2)).toBe("Infinity");
25+
});
26+
});
27+
28+
describe("formatMoney", () => {
29+
test("appends currency code", () => {
30+
expect(formatMoney(92.34, "EUR", 2)).toBe("92.34 EUR");
31+
});
32+
33+
test("uppercases nothing, trusts caller", () => {
34+
expect(formatMoney(100, "USD", 0)).toBe("100 USD");
35+
});
36+
});

0 commit comments

Comments
 (0)