ob2qualkard v0.2 test alpha

This commit is contained in:
2026-08-05 21:11:46 -03:00
parent 43a66d604c
commit 7085576313
13 changed files with 308 additions and 209 deletions

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;
}
}

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';
});
}
}
}