diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3efcf08..50f3ec2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ # * rcb@beco.cc * # ************************************************************************** -name: Zettelclean CI +name: ob2qualkard CI on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97854d4..54dbc43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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' || '' }} diff --git a/.gitignore b/.gitignore index 3916386..c49d3af 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,3 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store -ZETTELCLEAN* diff --git a/README.md b/README.md index 7cfe621..5a90571 100644 --- a/README.md +++ b/README.md @@ -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 . diff --git a/VERSION b/VERSION index b6c1867..0be21c5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -ob2qualkard v0.1 +ob2qualkard v0.2 diff --git a/esbuild.config.mjs b/esbuild.config.mjs index c9d06db..a755c6c 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -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) * Licensed under the GNU General Public License v3.0 or later. * See https://www.gnu.org/licenses/ and the LICENSE file. diff --git a/eslint.config.mts b/eslint.config.mts index c1d7d2e..2c88f04 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -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'] }, + ], + }, + }, ); diff --git a/manifest.json b/manifest.json index 7eb5987..0b9566c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,10 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", - "version": "1.0.0", + "id": "ob2qualkard", + "name": "Obsidian to QualKard", + "version": "0.2.0", "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 } diff --git a/package.json b/package.json index 39a8b52..966bfc3 100644 --- a/package.json +++ b/package.json @@ -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.0", + "description": "Push a note's flashcard blocks to a QualKard spaced-repetition board on Kanboard.", + "author": "Ruben Carlo Benante ", "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", diff --git a/src/main.ts b/src/main.ts index db213e8..6933615 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); 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, + (await this.loadData()) as Partial, ); } 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 { + 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; } } diff --git a/src/settings.ts b/src/settings.ts index 9eae902..6a80c6c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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 { + 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'; + }); + } } } diff --git a/styles.css b/styles.css deleted file mode 100644 index 71cc60f..0000000 --- a/styles.css +++ /dev/null @@ -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. - -*/ diff --git a/versions.json b/versions.json index 0ff7302..79c5364 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,4 @@ { - "1.0.0": "1.0.0" + "0.1.0": "1.0.0", + "0.2.0": "1.0.0" }