4 Commits
v0.1 ... master

Author SHA1 Message Date
b53d22be59 release 0.2.1
Some checks failed
ob2qualkard CI / build (20.x) (push) Has been cancelled
ob2qualkard CI / build (22.x) (push) Has been cancelled
ob2qualkard CI / build (24.x) (push) Has been cancelled
Release ob2qualkard / build (push) Has been cancelled
2026-08-05 21:50:16 -03:00
ff0e612c43 mirror to github
Some checks failed
ob2qualkard CI / build (20.x) (push) Has been cancelled
ob2qualkard CI / build (22.x) (push) Has been cancelled
ob2qualkard CI / build (24.x) (push) Has been cancelled
2026-08-05 21:16:36 -03:00
10fd1fd9cc new files v0.2
Some checks failed
ob2qualkard CI / build (20.x) (push) Has been cancelled
ob2qualkard CI / build (22.x) (push) Has been cancelled
ob2qualkard CI / build (24.x) (push) Has been cancelled
Release ob2qualkard / build (push) Has been cancelled
2026-08-05 21:12:33 -03:00
7085576313 ob2qualkard v0.2 test alpha 2026-08-05 21:11:46 -03:00
20 changed files with 3945 additions and 213 deletions

View File

@@ -19,7 +19,7 @@
# * rcb@beco.cc *
# **************************************************************************
name: Zettelclean CI
name: ob2qualkard CI
on:
push:

View File

@@ -19,7 +19,7 @@
# * rcb@beco.cc *
# **************************************************************************
name: Release Zettelclean
name: Release ob2qualkard
on:
push:
@@ -71,6 +71,6 @@ jobs:
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="Zettelclean $tag" \
--title="ob2qualkard $tag" \
--draft \
main.js manifest.json ${{ steps.styles.outputs.exists == 'true' && 'styles.css' || '' }}

10
.gitignore vendored
View File

@@ -29,8 +29,8 @@
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
# Build output (main.js builds into build/; ignore both). Ships via releases.
build/
main.js
# Exclude sourcemaps
@@ -41,4 +41,8 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
ZETTELCLEAN*
# no need
Makefile
GITHUB*

125
README.md
View File

@@ -1,92 +1,75 @@
# Obsidian Sample Plugin
# Obsidian to QualKard (ob2qualkard)
This is a sample plugin for Obsidian (https://obsidian.md).
Push a flashcard from an Obsidian note to a [QualKard](https://code.beco.cc/beco/kanboard)
spaced-repetition board on Kanboard. One command, one card, one direction: your notes
stay the source of truth; nothing is ever synced back.
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
## How a card is written
This sample plugin demonstrates some of the basic functionality the plugin API can do.
A card lives inside a fenced code block with the info string `qualcard`. The **first
blank line** splits it: the first paragraph becomes the card's **front** (the Kanboard
task title / prompt), and everything after the blank line becomes the **back** (the task
description / answer).
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open modal (simple)" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and outputs a Notice on click.
- Registers a global interval which logs 'setInterval' to the console.
````markdown
```qualcard
When is judicial review available?
## First time developing plugins?
Only on a concrete case or controversy brought by a party with standing.
```
````
Quick starting guide for new plugin devs:
- The front is a single line (wrapped lines are joined with spaces), because Kanboard
task titles are single-line.
- The back keeps its Markdown, including paragraph breaks.
- A note may contain **any number** of `qualcard` blocks; the surrounding prose is never
sent.
- A block with **no blank line** has no answer and is skipped (a flashcard needs both
sides).
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `src/main.ts` to `main.js`.
- Make changes to `src/main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Pushing
## Releasing new releases
Run the command **"Push this note to QualKard"** (command palette or the editor
right-click menu):
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
- **One** `qualcard` block in the note -> it is pushed, wherever the cursor is.
- **Several** blocks -> the block **under the cursor** is pushed. If the cursor is not
inside any block, nothing is pushed and you are asked to place it.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
Pushing is deliberate and creates a new card each time (no de-duplication). On the
QualKard board the new task is automatically adopted as a fresh "new" card in the Drafts
column.
## Adding your plugin to the community plugin list
## Settings
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
| Setting | Meaning |
|---|---|
| Kanboard URL | Base URL of your Kanboard, e.g. `https://organon.beco.cc` |
| API username | Kanboard user whose personal API token is used (e.g. `drbeco`) |
| API token | That user's personal API token |
| Study board | Display name of the QualKard-enabled board to push to |
## How to use
The target board must already be set up as a QualKard study board; otherwise the task is
created but never treated as a card.
- Clone this repo.
- Make sure your NodeJS is at least v18 (`node --version`).
- `npm i` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Network and privacy
## Manually installing the plugin
This plugin makes network requests **only** when you run the push command, and sends
**only** the front/back text of the single block you push -- never any other note
content. The API token is stored in this vault's `.obsidian` data (`data.json`) in plain
text; if you sync or back up your vault, keep that in mind and do not publish it.
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Build and install
## Improve code quality with eslint
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- This project already has eslint preconfigured, you can invoke a check by running`npm run lint`
- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines.
- A GitHub action is preconfigured to automatically lint every commit on all branches.
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
pnpm install
make install VAULT=/abs/path/to/your/vault
```
If you have multiple URLs, you can also do:
Then fully restart Obsidian (the reload button does not reliably reload a changed plugin)
and enable **Obsidian to QualKard**. You can also install it with
[BRAT](https://github.com/TfTHacker/obsidian42-brat) from the GitHub mirror.
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## License
## API Documentation
See https://docs.obsidian.md
GPL-3.0-or-later. Copyright (C) 2026 Ruben Carlo Benante <rcb@beco.cc>.

View File

@@ -1 +1 @@
ob2qualkard v0.1
ob2qualkard 0.2.1

View File

@@ -24,7 +24,7 @@ import process from 'process';
import { builtinModules } from 'node:module';
const banner = `/*
* zettelclean - keep a Zettelkasten filename's slug in sync with the note's H1.
* ob2qualkard - push Obsidian notes as flashcards to a QualKard (Kanboard) board.
* Copyright (C) 2026 Ruben Carlo Benante (Dr. Beco) <rcb@beco.cc>
* Licensed under the GNU General Public License v3.0 or later.
* See https://www.gnu.org/licenses/ and the LICENSE file.
@@ -63,7 +63,7 @@ const context = await esbuild.context({
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
outfile: 'build/main.js',
minify: prod,
});

View File

@@ -30,7 +30,7 @@ export default defineConfig(
'esbuild.config.mjs',
'version-bump.mjs',
'versions.json',
'main.js',
'build/**',
'package.json',
'package-lock.json',
'tsconfig.json',
@@ -51,4 +51,13 @@ export default defineConfig(
},
},
...obsidianmd.configs.recommended,
{
rules: {
// QualKard and Kanboard are product names, not sentence-case slips.
'obsidianmd/ui/sentence-case': [
'warn',
{ brands: ['QualKard', 'Kanboard'] },
],
},
},
);

View File

@@ -1,11 +1,10 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"version": "1.0.0",
"id": "ob2qualkard",
"name": "Obsidian to QualKard",
"version": "0.2.1",
"minAppVersion": "1.0.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"description": "Push a note's flashcard blocks to a QualKard spaced-repetition board on Kanboard.",
"author": "Ruben Carlo Benante",
"authorUrl": "https://code.beco.cc/beco/ob2qualkard",
"isDesktopOnly": false
}

View File

@@ -1,17 +1,21 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"name": "ob2qualkard",
"version": "0.2.1",
"description": "Push a note's flashcard blocks to a QualKard spaced-repetition board on Kanboard.",
"author": "Ruben Carlo Benante <rcb@beco.cc>",
"main": "main.js",
"type": "module",
"packageManager": "pnpm@11.17.0",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "node --test \"src/**/*.test.ts\"",
"test:watch": "node --test --watch \"src/**/*.test.ts\"",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": [],
"license": "0-BSD",
"license": "GPL-3.0-or-later",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^22.15.17",

3153
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false

127
src/card.test.ts Normal file
View File

@@ -0,0 +1,127 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseQualcardBlocks, selectBlockToPush } from './card.ts';
test('parseQualcardBlocks: splits front/back on the first blank line', () => {
const md = [
'# Note title',
'',
'```qualcard',
'When is judicial review available?',
'',
'Only on a concrete case or controversy',
'brought by a party with standing.',
'```',
'',
'more prose that is never sent',
].join('\n');
const blocks = parseQualcardBlocks(md);
assert.equal(blocks.length, 1);
assert.equal(blocks[0].front, 'When is judicial review available?');
assert.equal(
blocks[0].back,
'Only on a concrete case or controversy\nbrought by a party with standing.',
);
assert.equal(blocks[0].hasAnswer, true);
});
test('parseQualcardBlocks: multi-line front collapses to a single-line title', () => {
const md = ['```qualcard', 'A very', 'long prompt', '', 'the answer', '```'].join(
'\n',
);
const blocks = parseQualcardBlocks(md);
assert.equal(blocks.length, 1);
assert.equal(blocks[0].front, 'A very long prompt');
assert.equal(blocks[0].back, 'the answer');
});
test('parseQualcardBlocks: several blocks become several cards', () => {
const md = [
'```qualcard',
'Front A',
'',
'Back A',
'```',
'',
'```qualcard',
'Front B',
'',
'Back B',
'```',
].join('\n');
const blocks = parseQualcardBlocks(md);
assert.equal(blocks.length, 2);
assert.equal(blocks[0].front, 'Front A');
assert.equal(blocks[1].front, 'Front B');
assert.equal(blocks[0].startLine, 0);
assert.equal(blocks[0].endLine, 4);
assert.equal(blocks[1].startLine, 6);
assert.equal(blocks[1].endLine, 10);
});
test('parseQualcardBlocks: a block with no blank line has no answer', () => {
const md = ['```qualcard', 'Just a prompt, no answer', '```'].join('\n');
const blocks = parseQualcardBlocks(md);
assert.equal(blocks.length, 1);
assert.equal(blocks[0].front, 'Just a prompt, no answer');
assert.equal(blocks[0].back, '');
assert.equal(blocks[0].hasAnswer, false);
});
test('parseQualcardBlocks: an unclosed fence is ignored', () => {
const md = ['```qualcard', 'dangling front', '', 'dangling back'].join('\n');
assert.equal(parseQualcardBlocks(md).length, 0);
});
test('selectBlockToPush: none / single / cursor / ambiguous', () => {
const md = [
'```qualcard',
'Front A',
'',
'Back A',
'```',
'',
'```qualcard',
'Front B',
'',
'Back B',
'```',
].join('\n');
const two = parseQualcardBlocks(md);
assert.equal(selectBlockToPush([], 0).reason, 'none');
const single = parseQualcardBlocks(['```qualcard', 'F', '', 'B', '```'].join('\n'));
const s = selectBlockToPush(single, 99);
assert.equal(s.reason, 'single');
assert.equal(s.block?.front, 'F');
const inB = selectBlockToPush(two, 7);
assert.equal(inB.reason, 'cursor');
assert.equal(inB.block?.front, 'Front B');
const outside = selectBlockToPush(two, 5);
assert.equal(outside.reason, 'ambiguous');
assert.equal(outside.block, null);
});

137
src/card.ts Normal file
View File

@@ -0,0 +1,137 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
// Pure block-parsing logic for ob2qualkard. No Obsidian imports, so it is
// unit-testable in isolation. A card lives only inside a ```qualcard fenced
// block; the first blank line splits the front (prompt -> Kanboard task title)
// from the back (answer -> task description).
export interface CardBlock {
// The prompt: first paragraph of the block, collapsed to a single line.
front: string;
// The answer: everything after the first blank line, trimmed.
back: string;
// True when a blank-line divider exists and the text after it is non-empty.
hasAnswer: boolean;
// 0-based line indices of the opening and closing fence lines.
startLine: number;
endLine: number;
}
// Opening fence: up to 3 spaces, 3+ backticks, the info string "qualcard".
// Case-insensitive so ```Qualcard also works; documented as lowercase.
const OPEN_RE = /^ {0,3}(`{3,})qualcard\s*$/i;
// Extract every ```qualcard block from a note's Markdown text.
export function parseQualcardBlocks(markdown: string): CardBlock[] {
const lines = markdown.split(/\r?\n/);
const blocks: CardBlock[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line === undefined) break;
const open = line.match(OPEN_RE);
if (!open) {
i++;
continue;
}
const fenceLen = open[1]?.length ?? 3;
// Closing fence: backticks only, at least as many as the opener.
const closeRe = new RegExp('^ {0,3}`{' + fenceLen + ',}\\s*$');
const content: string[] = [];
let j = i + 1;
let closed = false;
while (j < lines.length) {
const cur = lines[j];
if (cur === undefined) break;
if (closeRe.test(cur)) {
closed = true;
break;
}
content.push(cur);
j++;
}
if (closed) {
blocks.push(buildBlock(content, i, j));
i = j + 1;
} else {
// Unclosed fence: not a valid block; resume after the opener line.
i++;
}
}
return blocks;
}
function buildBlock(
content: string[],
startLine: number,
endLine: number,
): CardBlock {
// Skip leading blank lines, then find the first blank line: the divider.
let start = 0;
while (start < content.length && content[start]?.trim() === '') start++;
let divider = -1;
for (let k = start; k < content.length; k++) {
if (content[k]?.trim() === '') {
divider = k;
break;
}
}
const frontLines = content.slice(
start,
divider === -1 ? content.length : divider,
);
const front = collapse(frontLines.join(' '));
const back = divider === -1 ? '' : content.slice(divider + 1).join('\n').trim();
return { front, back, hasAnswer: back.length > 0, startLine, endLine };
}
// Join wrapped lines into a single-line title, squeezing runs of whitespace.
function collapse(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
export type SelectReason = 'none' | 'single' | 'cursor' | 'ambiguous';
export interface Selection {
block: CardBlock | null;
reason: SelectReason;
}
// Pick which block the "push this note" command acts on:
// 0 blocks -> none
// 1 block -> that block (cursor position ignored)
// >1 blocks -> the block containing the cursor, else ambiguous (push nothing).
export function selectBlockToPush(
blocks: CardBlock[],
cursorLine: number,
): Selection {
if (blocks.length === 0) return { block: null, reason: 'none' };
if (blocks.length === 1) {
return { block: blocks[0] ?? null, reason: 'single' };
}
const hit = blocks.find(
(b) => cursorLine >= b.startLine && cursorLine <= b.endLine,
);
return hit
? { block: hit, reason: 'cursor' }
: { block: null, reason: 'ambiguous' };
}

86
src/kanboard.ts Normal file
View File

@@ -0,0 +1,86 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
// The only module that touches the network. Uses Obsidian's requestUrl (not
// fetch) so the HTTP Basic auth header and cross-origin call work. The pure
// request/response helpers live in rpc.ts and are unit-tested there.
import { requestUrl } from 'obsidian';
import { buildJsonRpcRequest, parseRpcResult, type RpcAuth } from './rpc';
// Perform a JSON-RPC call and return its `result`, throwing a readable Error
// on transport failures or Kanboard-reported errors.
async function call(
auth: RpcAuth,
method: string,
params: Record<string, unknown>,
): Promise<unknown> {
const req = buildJsonRpcRequest(auth, method, params);
const resp = await requestUrl({
url: req.url,
method: 'POST',
headers: req.headers,
body: req.body,
throw: false,
});
if (resp.status === 401) {
throw new Error('Authentication failed (check username and API token)');
}
let data: unknown;
try {
data = resp.json;
} catch {
throw new Error(`Unexpected response from Kanboard (HTTP ${resp.status})`);
}
return parseRpcResult(data);
}
// Resolve a board (Kanboard project) display name to its numeric id, or null.
export async function getProjectIdByName(
auth: RpcAuth,
name: string,
): Promise<number | null> {
const result = await call(auth, 'getProjectByName', { name });
if (!result || typeof result !== 'object') return null;
const id = (result as { id?: unknown }).id;
return id ? Number(id) : null;
}
// Create a Kanboard task (which QualKard adopts as a new "new" card). Returns
// the new task id. project_id + title are the only required fields; QualKard's
// server-side hook fills in the spaced-repetition state on creation.
export async function createTask(
auth: RpcAuth,
projectId: number,
title: string,
description: string,
): Promise<number> {
const result = await call(auth, 'createTask', {
title,
project_id: projectId,
description,
});
// createTask returns the new task id, or false on a validation failure.
if (!result || typeof result === 'boolean') {
throw new Error('Kanboard rejected the task (no id returned)');
}
return Number(result);
}

View File

@@ -1,114 +1,132 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
import { Editor, Notice, Plugin } from 'obsidian';
import { parseQualcardBlocks, selectBlockToPush } from './card';
import { getProjectIdByName, createTask } from './kanboard';
import {
Editor,
MarkdownView,
MarkdownFileInfo,
Modal,
Notice,
Plugin,
} from 'obsidian';
import {
Ob2qualkardSettings,
DEFAULT_SETTINGS,
MyPluginSettings,
SampleSettingTab,
Ob2qualkardSettingTab,
} from './settings';
// Remember to rename these classes and interfaces!
export default class MyPlugin extends Plugin {
settings!: MyPluginSettings;
export default class Ob2qualkardPlugin extends Plugin {
settings!: Ob2qualkardSettings;
// Board name -> project id, resolved once per session (cleared on save).
private boardCache = new Map<string, number>();
async onload() {
await this.loadSettings();
this.addSettingTab(new Ob2qualkardSettingTab(this.app, this));
// This creates an icon in the left ribbon.
this.addRibbonIcon('dice', 'Sample', (_evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status bar text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-modal-simple',
name: 'Open modal (simple)',
callback: () => {
new SampleModal(this.app).open();
},
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'replace-selected',
name: 'Replace selected content',
editorCallback: (
editor: Editor,
_ctx: MarkdownView | MarkdownFileInfo,
) => {
editor.replaceSelection('Sample editor command');
},
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-modal-complex',
name: 'Open modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
return false;
},
id: 'push-note-to-qualkard',
name: 'Push this note to QualKard',
editorCallback: (editor) => void this.pushNote(editor),
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(activeDocument, 'click', (_evt: MouseEvent) => {
new Notice('Click');
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(
window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000),
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor) => {
menu.addItem((item) =>
item
.setTitle('Push this note to QualKard')
.setIcon('upload')
.onClick(() => void this.pushNote(editor)),
);
}),
);
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
(await this.loadData()) as Partial<MyPluginSettings>,
(await this.loadData()) as Partial<Ob2qualkardSettings>,
);
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
onOpen() {
const { contentEl } = this;
contentEl.setText('Woah!');
// The board name may have changed; re-resolve its id on the next push.
this.boardCache.clear();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
// Parse the note, pick the card to push (sole block, or the one under the
// cursor), validate it, then create the Kanboard task. One-way, no dedup.
private async pushNote(editor: Editor) {
const sel = selectBlockToPush(
parseQualcardBlocks(editor.getValue()),
editor.getCursor().line,
);
if (sel.reason === 'none') {
new Notice('No qualcard block in this note');
return;
}
if (sel.reason === 'ambiguous') {
new Notice(
'Several qualcard blocks; place the cursor inside the one to push',
);
return;
}
const block = sel.block;
if (!block || !block.front) {
new Notice('This qualcard block has no prompt (front)');
return;
}
if (!block.hasAnswer) {
new Notice('This qualcard block has no answer, so it was skipped');
return;
}
if (!this.settings.apiToken || !this.settings.board) {
new Notice(
'Set the API token and study board in settings first',
);
return;
}
try {
const projectId = await this.resolveBoard();
if (projectId === null) {
new Notice(`Board "${this.settings.board}" not found`);
return;
}
const taskId = await createTask(
this.settings,
projectId,
block.front,
block.back,
);
new Notice(`Card created (task #${taskId})`);
} catch (e) {
const msg = e instanceof Error ? e.message : 'push failed';
console.error('ob2qualkard: push failed', e);
new Notice(msg);
}
}
private async resolveBoard(): Promise<number | null> {
const name = this.settings.board;
const cached = this.boardCache.get(name);
if (cached !== undefined) return cached;
const id = await getProjectIdByName(this.settings, name);
if (id !== null) this.boardCache.set(name, id);
return id;
}
}

54
src/rpc.test.ts Normal file
View File

@@ -0,0 +1,54 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildJsonRpcRequest, parseRpcResult } from './rpc.ts';
test('buildJsonRpcRequest: endpoint, basic auth, and json-rpc body', () => {
const req = buildJsonRpcRequest(
{ baseUrl: 'https://organon.beco.cc', username: 'drbeco', apiToken: 'tok' },
'createTask',
{ title: 'T', project_id: 7, description: 'D' },
);
assert.equal(req.url, 'https://organon.beco.cc/jsonrpc.php');
assert.equal(req.headers['Content-Type'], 'application/json');
assert.equal(req.headers.Authorization, 'Basic ' + btoa('drbeco:tok'));
const body = JSON.parse(req.body);
assert.equal(body.jsonrpc, '2.0');
assert.equal(body.method, 'createTask');
assert.deepEqual(body.params, { title: 'T', project_id: 7, description: 'D' });
});
test('buildJsonRpcRequest: strips trailing slashes from the base URL', () => {
const req = buildJsonRpcRequest(
{ baseUrl: 'https://x.example///', username: 'u', apiToken: 't' },
'getMe',
{},
);
assert.equal(req.url, 'https://x.example/jsonrpc.php');
});
test('parseRpcResult: returns result, or throws error.message', () => {
assert.equal(parseRpcResult({ result: 42 }), 42);
assert.equal(parseRpcResult({ result: false }), false);
assert.throws(() => parseRpcResult({ error: { message: 'boom' } }), /boom/);
});

68
src/rpc.ts Normal file
View File

@@ -0,0 +1,68 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
// Pure Kanboard JSON-RPC helpers. No Obsidian imports, so both the request
// builder and the response parser are unit-testable without the network.
export interface RpcAuth {
baseUrl: string;
username: string;
apiToken: string;
}
export interface JsonRpcRequest {
url: string;
headers: Record<string, string>;
body: string;
}
// Build the authenticated JSON-RPC request for a Kanboard method. Kanboard
// authenticates with HTTP Basic "user:token"; the endpoint is /jsonrpc.php.
export function buildJsonRpcRequest(
auth: RpcAuth,
method: string,
params: Record<string, unknown>,
): JsonRpcRequest {
const base = auth.baseUrl.replace(/\/+$/, '');
const token = toBase64(`${auth.username}:${auth.apiToken}`);
return {
url: `${base}/jsonrpc.php`,
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${token}`,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
};
}
// btoa exists both in Obsidian (browser runtime) and in Node's test runner.
function toBase64(s: string): string {
return btoa(s);
}
// Return the JSON-RPC `result`, or throw the `error.message` as an Error.
export function parseRpcResult(data: unknown): unknown {
const d = data as { result?: unknown; error?: { message?: string } } | null;
if (d && d.error) {
throw new Error(d.error.message ?? 'Kanboard RPC error');
}
return d ? d.result : undefined;
}

View File

@@ -1,38 +1,132 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import MyPlugin from './main';
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
export interface MyPluginSettings {
mySetting: string;
import { App, PluginSettingTab, Setting, SettingDefinitionItem } from 'obsidian';
import Ob2qualkardPlugin from './main';
export interface Ob2qualkardSettings {
baseUrl: string;
username: string;
apiToken: string;
board: string;
}
export const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default',
export const DEFAULT_SETTINGS: Ob2qualkardSettings = {
baseUrl: 'https://organon.beco.cc',
username: 'drbeco',
apiToken: '',
board: '',
};
export class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
interface FieldSpec {
key: keyof Ob2qualkardSettings;
name: string;
desc: string;
placeholder: string;
password?: boolean;
}
constructor(app: App, plugin: MyPlugin) {
// A single source of truth for the four text fields, shared by the modern
// declarative API and the imperative fallback below.
const FIELDS: FieldSpec[] = [
{
key: 'baseUrl',
name: 'Kanboard URL',
desc: 'Base URL of your Kanboard instance (without the /jsonrpc.php path).',
placeholder: 'https://organon.beco.cc',
},
{
key: 'username',
name: 'API username',
desc: 'Kanboard user whose personal API token is used.',
placeholder: 'drbeco',
},
{
key: 'apiToken',
name: 'API token',
desc: 'Personal API token for that user. Stored in plain text in this vault (data.json) - do not commit or publicly sync it.',
placeholder: 'paste token',
password: true,
},
{
key: 'board',
name: 'Study board',
desc: 'Display name of the QualKard-enabled board that cards are pushed to.',
placeholder: 'Direito',
},
];
export class Ob2qualkardSettingTab extends PluginSettingTab {
plugin: Ob2qualkardPlugin;
constructor(app: App, plugin: Ob2qualkardPlugin) {
super(app, plugin);
this.plugin = plugin;
}
// Modern declarative settings (Obsidian 1.13+): rendered and indexed for
// settings search. Ignored on older versions, which use display() below.
getSettingDefinitions(): SettingDefinitionItem[] {
return FIELDS.map(
(f): SettingDefinitionItem => ({
name: f.name,
desc: f.desc,
control: {
type: 'text',
key: f.key,
defaultValue: DEFAULT_SETTINGS[f.key],
placeholder: f.placeholder,
},
}),
);
}
getControlValue(key: string): unknown {
return this.plugin.settings[key as keyof Ob2qualkardSettings];
}
async setControlValue(key: string, value: unknown): Promise<void> {
const text = typeof value === 'string' ? value : '';
this.plugin.settings[key as keyof Ob2qualkardSettings] = text;
await this.plugin.saveSettings();
}
// Imperative fallback for Obsidian < 1.13 (e.g. 1.12.x). Not called on
// 1.13+ once getSettingDefinitions() returns a non-empty array.
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Settings #1')
.setDesc("It's a secret")
.addText((text) =>
text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}),
);
this.containerEl.empty();
for (const f of FIELDS) {
new Setting(this.containerEl)
.setName(f.name)
.setDesc(f.desc)
.addText((text) => {
text
.setPlaceholder(f.placeholder)
.setValue(this.plugin.settings[f.key])
.onChange(async (value) => {
this.plugin.settings[f.key] = value;
await this.plugin.saveSettings();
});
if (f.password) text.inputEl.type = 'password';
});
}
}
}

View File

@@ -1,8 +0,0 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/

View File

@@ -1,3 +1,5 @@
{
"1.0.0": "1.0.0"
"0.1.0": "1.0.0",
"0.2.0": "1.0.0",
"0.2.1": "1.0.0"
}