Compare commits

...

23 Commits

Author SHA1 Message Date
9b8550c387 Refactor plugin to Mindnet Causal Assistant with enhanced vocabulary management and linting features. Updated manifest and package files to reflect new plugin details and dependencies. Added commands for reloading vocabulary and validating notes.
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
2026-01-15 11:34:29 +01:00
Lishid
dc2fa22c4d
Fix typo in ESLint plugin link 2025-12-30 13:09:48 -05:00
Johannes Theiner
2323eddbb1
Merge pull request #164 from joethei/master
Include eslint config
2025-12-18 11:53:40 +01:00
Johannes Theiner
911b773800 Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	README.md
#	src/main.ts
2025-11-14 15:24:54 +01:00
Johannes Theiner
123fc4a870 update eslint plugin version
remove builtin-modules external dependency
2025-11-14 14:28:33 +01:00
Johannes Theiner
0eca98adda update package.json 2025-11-10 22:27:46 +01:00
Johannes Theiner
92cba25647 update sample plugin with eslint instructions 2025-11-10 12:26:07 +01:00
Johannes Theiner
9984b040b6 remove testing code 2025-10-17 12:36:26 +02:00
Johannes Theiner
e2a64e0534
Merge pull request #151 from adamu/main
Prefix unused variables with _
2025-09-17 18:12:44 +02:00
Johannes Theiner
8933f6ce64
Merge pull request #142 from johannrichard/declutter-version-bump
build: only write new minAppVersion requirements to `versions.json`
2025-09-17 17:54:33 +02:00
Adam Millerchip
f16c1401b3 Prefix unused variables with _ 2025-08-28 14:38:40 +09:00
Johannes Theiner
fa2fac56be Remove node 18 from action 2025-07-04 13:29:58 +02:00
Johannes Theiner
e0485eb3ba add lock file to repo 2025-07-04 13:28:27 +02:00
Johannes Theiner
dbc9096f81 Add lint action 2025-07-04 13:27:53 +02:00
Johannes Theiner
90290a8af5 Update to use released eslint plugin 2025-07-03 12:00:06 +02:00
Johannes Theiner
68ab547f2c Update to use released eslint plugin 2025-06-02 12:10:29 +02:00
Johann Richard
3fe07677b5
build: only write new minAppVersion requirements to versions.json
Only add a new version requirements if `minAppVersion` is not already in `versions.json`. Should declutter `versions.json`.
2025-04-28 08:38:38 +02:00
Johannes Theiner
ba04056938
Merge remote-tracking branch 'origin/master' 2025-01-02 16:48:30 +01:00
Johannes Theiner
55f5080882
Add linting of dependencies, throwing errors when including using telemetry libraries 2025-01-02 16:48:03 +01:00
Johannes Theiner
20002ffc65
Merge branch 'obsidianmd:master' into master 2024-11-20 17:02:47 +01:00
Johannes Theiner
fa15a2558e
Merge remote-tracking branch 'origin/master'
# Conflicts:
#	tsconfig.json
2024-07-31 23:00:52 +02:00
Johannes Theiner
15e343739e
Add stylelint and configure eslint to add custom plugin.
- Split up main.ts into multiple files.
2024-07-31 22:59:59 +02:00
Johannes Theiner
b9a0e401e0
configure eslint 2024-02-29 13:18:14 +01:00
40 changed files with 8233 additions and 213 deletions

View File

@ -1,3 +0,0 @@
node_modules/
main.js

View File

@ -1,23 +0,0 @@
{
"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"
}
}

28
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Node.js build
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
- run: npm run lint

View File

@ -7,7 +7,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- 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 output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
@ -55,15 +55,11 @@ Quick starting guide for new plugin devs:
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Improve code quality with eslint (optional)
## 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.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint ./src/`
- 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
@ -91,4 +87,4 @@ If you have multiple URLs, you can also do:
## API Documentation
See https://github.com/obsidianmd/obsidian-api
See https://docs.obsidian.md

View File

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from 'node:module';
const banner =
`/*
@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
@ -31,7 +31,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtinModules],
format: "cjs",
target: "es2018",
logLevel: "info",

34
eslint.config.mts Normal file
View File

@ -0,0 +1,34 @@
import tseslint from 'typescript-eslint';
import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals";
import { globalIgnores } from "eslint/config";
export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
projectService: {
allowDefaultProject: [
'eslint.config.js',
'manifest.json'
]
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json']
},
},
},
...obsidianmd.configs.recommended,
globalIgnores([
"node_modules",
"dist",
"esbuild.config.mjs",
"eslint.config.js",
"version-bump.mjs",
"versions.json",
"main.js",
]),
);

134
main.ts
View File

@ -1,134 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// 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-sample-modal-simple',
name: 'Open sample 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: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
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-sample-modal-complex',
name: 'Open sample 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;
}
}
});
// 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(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// 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));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #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();
}));
}
}

View File

@ -1,11 +1,11 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"id": "mindnet-causal-assistant",
"name": "Mindnet Causal Assistant",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"description": "Linting, chain explorer and guided authoring for Mindnet causal graphs.",
"author": "Lars Stommer",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": false
"isDesktopOnly": true
}

6747
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,22 +3,31 @@
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"type": "module",
"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"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"deploy:local": "powershell -ExecutionPolicy Bypass -File scripts/deploy-local.ps1",
"build:deploy": "npm run build && npm run deploy:local",
"lint": "eslint .",
"test": "vitest run"
},
"keywords": [],
"author": "",
"license": "MIT",
"license": "0-BSD",
"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",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1",
"jiti": "2.6.1",
"vitest": "^1.6.0"
},
"dependencies": {
"obsidian": "latest"
}
}

16
scripts/deploy-local.ps1 Normal file
View File

@ -0,0 +1,16 @@
param(
[string]$VaultPath = "\\nashome\mindnet\vault\mindnet_dev",
[string]$PluginId = "mindnet-causal-assistant"
)
$pluginDir = Join-Path $VaultPath ".obsidian\plugins\$PluginId"
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
Copy-Item -Path ".\manifest.json" -Destination $pluginDir -Force
Copy-Item -Path ".\main.js" -Destination $pluginDir -Force
if (Test-Path ".\styles.css") {
Copy-Item -Path ".\styles.css" -Destination $pluginDir -Force
}
Write-Host "Deployed to $pluginDir"

25
src/__mocks__/obsidian.ts Normal file
View File

@ -0,0 +1,25 @@
// Mock for obsidian API in tests
export class Notice {
constructor(public message: string) {}
}
export interface App {
vault: Vault;
workspace: Workspace;
}
export interface Vault {
read(file: TFile): Promise<string>;
modify(file: TFile, content: string): Promise<void>;
getMarkdownFiles(): TFile[];
}
export interface Workspace {
getActiveFile(): TFile | null;
}
export interface TFile {
path: string;
name: string;
extension: string;
}

0
src/graph/GraphIndex.ts Normal file
View File

0
src/graph/traverse.ts Normal file
View File

229
src/lint/LintEngine.ts Normal file
View File

@ -0,0 +1,229 @@
import type { App, TFile } from "obsidian";
import type { ParsedEdge } from "../parser/types";
import type { Vocabulary } from "../vocab/Vocabulary";
import type { Finding, QuickFix } from "./types";
import { parseEdgesFromCallouts } from "../parser/parseEdgesFromCallouts";
const EDGE_HEADER_RE = /^\s*(>+)\s*\[!edge\]\s*(.+?)\s*$/i;
/**
* Pure function to lint parsed edges against vocabulary and file existence.
* This can be tested independently.
*/
export function lintEdges(
parsedEdges: ParsedEdge[],
vocabulary: Vocabulary,
existingFilesSet: Set<string>
): Finding[] {
const findings: Finding[] = [];
for (const edge of parsedEdges) {
const normalized = vocabulary.normalize(edge.rawType);
// Check for unknown edge type
if (normalized.canonical === null) {
findings.push({
ruleId: "unknown_edge_type",
severity: "ERROR",
message: `Unknown edge type: "${edge.rawType}"`,
filePath: "", // Will be set by caller
lineStart: edge.lineStart,
lineEnd: edge.lineEnd,
evidence: edge.rawType,
});
continue;
}
// Check for alias not normalized
const rawLower = edge.rawType.trim().toLowerCase();
const canonicalLower = normalized.canonical.toLowerCase();
if (rawLower !== canonicalLower) {
findings.push({
ruleId: "alias_not_normalized",
severity: "WARN",
message: `Edge type "${edge.rawType}" should be normalized to "${normalized.canonical}"`,
filePath: "", // Will be set by caller
lineStart: edge.lineStart,
lineEnd: edge.lineStart,
evidence: edge.rawType,
quickFixes: [], // Will be populated by caller with file context
});
}
// Check for missing target notes
for (const target of edge.targets) {
if (!target) continue;
// Check if file exists (exact match to markdown file name)
// Target might be "Note Name" or "Note Name#heading" - we check the base name
const parts = target.split("#");
const firstPart = parts[0];
if (!firstPart) continue;
const baseName = firstPart.trim();
if (!baseName) continue;
const markdownFileName = baseName.endsWith(".md") ? baseName : `${baseName}.md`;
if (!existingFilesSet.has(markdownFileName) && !existingFilesSet.has(baseName)) {
findings.push({
ruleId: "missing_target_note",
severity: "WARN",
message: `Target note "${target}" not found in vault`,
filePath: "", // Will be set by caller
lineStart: edge.lineStart,
lineEnd: edge.lineEnd,
evidence: target,
});
}
}
}
return findings;
}
/**
* LintEngine for validating markdown notes containing edge definitions.
*/
export class LintEngine {
/**
* Lint the currently active markdown note.
*/
static async lintCurrentNote(
app: App,
vocabulary: Vocabulary
): Promise<Finding[]> {
const activeFile = app.workspace.getActiveFile();
if (!activeFile) {
throw new Error("No active file");
}
if (activeFile.extension !== "md") {
throw new Error("Active file is not a markdown file");
}
// Read file content
const content = await app.vault.read(activeFile);
// Parse edges
const parsedEdges = parseEdgesFromCallouts(content);
// Build set of existing markdown files in vault
const existingFilesSet = new Set<string>();
const markdownFiles = app.vault.getMarkdownFiles();
for (const file of markdownFiles) {
existingFilesSet.add(file.name);
// Also add without .md extension for matching
if (file.name.endsWith(".md")) {
const baseName = file.name.slice(0, -3);
existingFilesSet.add(baseName);
}
}
// Run pure linting logic
const findings = lintEdges(parsedEdges, vocabulary, existingFilesSet);
// Set filePath and add quickfixes
const filePath = activeFile.path;
const lines = content.split(/\r?\n/);
for (const finding of findings) {
finding.filePath = filePath;
// Add quickfix for alias_not_normalized
if (finding.ruleId === "alias_not_normalized" && finding.lineStart !== undefined) {
const lineIndex = finding.lineStart;
const line = lines[lineIndex];
if (line) {
const normalized = vocabulary.normalize(finding.evidence || "");
if (normalized.canonical) {
finding.quickFixes = [
createNormalizeQuickFix(
app,
activeFile,
content,
lineIndex,
finding.evidence || "",
normalized.canonical
),
];
}
}
}
}
return findings;
}
}
/**
* Create a quickfix that normalizes an edge type in the file.
*/
function createNormalizeQuickFix(
app: App,
file: TFile,
currentContent: string,
lineIndex: number,
rawType: string,
canonical: string
): QuickFix {
return {
id: "normalize_edge_type",
title: `Normalize to "${canonical}"`,
apply: async () => {
const { Notice } = await import("obsidian");
const lines = currentContent.split(/\r?\n/);
const line = lines[lineIndex];
if (!line) {
new Notice("Line not found");
return;
}
// Match the edge header pattern
const match = line.match(EDGE_HEADER_RE);
if (!match || !match[2]) {
new Notice("Edge header pattern not found on line");
return;
}
// Find the position of the raw type in the line
// match[2] is the captured type, but we need to find where it appears in the original line
const edgeMarker = "[!edge]";
const edgeIndex = line.indexOf(edgeMarker);
if (edgeIndex === -1) {
new Notice("Edge marker not found on line");
return;
}
// Find the type after [!edge]
const afterEdge = line.substring(edgeIndex + edgeMarker.length);
const typeMatch = afterEdge.match(/^\s+(\S+)/);
if (!typeMatch || typeMatch[1] !== rawType.trim()) {
new Notice("Type token not found at expected position");
return;
}
// Replace the raw type with canonical
const beforeType = line.substring(0, edgeIndex + edgeMarker.length + typeMatch[0].indexOf(typeMatch[1]));
const afterType = line.substring(beforeType.length + typeMatch[1].length);
const newLine = beforeType + canonical + afterType;
// Safety check: verify the new line still matches the pattern
const verifyMatch = newLine.match(EDGE_HEADER_RE);
if (!verifyMatch) {
new Notice("Quickfix would produce invalid line - skipping");
return;
}
// Update the line
lines[lineIndex] = newLine;
const newContent = lines.join("\n");
// Write back to file
await app.vault.modify(file, newContent);
},
};
}

0
src/lint/index.ts Normal file
View File

0
src/lint/rules/index.ts Normal file
View File

View File

View File

View File

18
src/lint/types.ts Normal file
View File

@ -0,0 +1,18 @@
export type Severity = "ERROR" | "WARN" | "INFO";
export interface QuickFix {
id: string;
title: string;
apply: () => Promise<void>;
}
export interface Finding {
ruleId: string;
severity: Severity;
message: string;
filePath: string;
lineStart?: number;
lineEnd?: number;
evidence?: string;
quickFixes?: QuickFix[];
}

176
src/main.ts Normal file
View File

@ -0,0 +1,176 @@
import { Notice, Plugin, TFile } from "obsidian";
import { DEFAULT_SETTINGS, type MindnetSettings, normalizeVaultPath } from "./settings";
import { VocabularyLoader } from "./vocab/VocabularyLoader";
import { parseEdgeVocabulary } from "./vocab/parseEdgeVocabulary";
import { Vocabulary } from "./vocab/Vocabulary";
import { LintEngine } from "./lint/LintEngine";
import { MindnetSettingTab } from "./ui/MindnetSettingTab";
export default class MindnetCausalAssistantPlugin extends Plugin {
settings: MindnetSettings;
private vocabulary: Vocabulary | null = null;
private reloadDebounceTimer: number | null = null;
async onload(): Promise<void> {
await this.loadSettings();
// Add settings tab
this.addSettingTab(new MindnetSettingTab(this.app, this));
// Register live reload for edge vocabulary file
this.registerEvent(
this.app.vault.on("modify", async (file: TFile) => {
const normalizedFilePath = normalizeVaultPath(file.path);
const normalizedVocabPath = normalizeVaultPath(this.settings.edgeVocabularyPath);
// Check if modified file matches vocabulary path (exact match or ends with)
if (normalizedFilePath === normalizedVocabPath ||
normalizedFilePath === `/${normalizedVocabPath}` ||
normalizedFilePath.endsWith(`/${normalizedVocabPath}`)) {
// Debounce reload to avoid multiple rapid reloads
if (this.reloadDebounceTimer !== null) {
window.clearTimeout(this.reloadDebounceTimer);
}
this.reloadDebounceTimer = window.setTimeout(async () => {
await this.reloadVocabulary();
this.reloadDebounceTimer = null;
}, 200);
}
})
);
this.addCommand({
id: "mindnet-reload-edge-vocabulary",
name: "Mindnet: Reload edge vocabulary",
callback: async () => {
await this.reloadVocabulary();
},
});
this.addCommand({
id: "mindnet-validate-current-note",
name: "Mindnet: Validate current note",
callback: async () => {
try {
const vocabulary = await this.ensureVocabularyLoaded();
if (!vocabulary) {
return;
}
const findings = await LintEngine.lintCurrentNote(this.app, vocabulary);
// Count findings by severity
const errorCount = findings.filter(f => f.severity === "ERROR").length;
const warnCount = findings.filter(f => f.severity === "WARN").length;
const infoCount = findings.filter(f => f.severity === "INFO").length;
// Show summary notice
new Notice(`Lint: ${errorCount} errors, ${warnCount} warnings, ${infoCount} info`);
// Log findings to console
console.log("=== Lint Findings ===");
for (const finding of findings) {
const quickfixInfo = finding.quickFixes && finding.quickFixes.length > 0
? ` [QuickFix: ${finding.quickFixes.map(qf => qf.title).join(", ")}]`
: "";
console.log(`[${finding.severity}] ${finding.ruleId}: ${finding.message} (${finding.filePath}:${finding.lineStart}${quickfixInfo})`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to validate note: ${msg}`);
console.error(e);
}
},
});
}
onunload(): void {
// nothing yet
}
private async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
/**
* Ensure vocabulary is loaded. Auto-loads if not present.
* Returns Vocabulary instance or null on failure.
*/
private async ensureVocabularyLoaded(): Promise<Vocabulary | null> {
if (this.vocabulary) {
return this.vocabulary;
}
try {
const text = await VocabularyLoader.loadText(
this.app,
this.settings.edgeVocabularyPath
);
const parsed = parseEdgeVocabulary(text);
this.vocabulary = new Vocabulary(parsed);
const stats = this.vocabulary.getStats();
console.log("Vocabulary auto-loaded", stats);
return this.vocabulary;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("Vocabulary file not found")) {
new Notice("edge_vocabulary.md not found. Check the path in plugin settings.");
} else {
new Notice(`Failed to load vocabulary: ${msg}. Check plugin settings.`);
}
console.error("Failed to load vocabulary:", e);
return null;
}
}
/**
* Reload vocabulary from file. Used by manual command and live reload.
*/
private async reloadVocabulary(): Promise<void> {
try {
const text = await VocabularyLoader.loadText(
this.app,
this.settings.edgeVocabularyPath
);
const parsed = parseEdgeVocabulary(text);
this.vocabulary = new Vocabulary(parsed);
const stats = this.vocabulary.getStats();
console.log("Vocabulary loaded", stats);
new Notice(`Edge vocabulary reloaded: ${stats.canonicalCount} canonical, ${stats.aliasCount} aliases`);
// Log normalization examples
if (stats.canonicalCount > 0) {
const firstCanonical = Array.from(parsed.byCanonical.keys())[0];
if (firstCanonical) {
const canonicalNorm = this.vocabulary.normalize(firstCanonical);
console.log(`Normalization example (canonical): "${firstCanonical}" -> canonical: ${canonicalNorm.canonical}, inverse: ${canonicalNorm.inverse}`);
}
if (stats.aliasCount > 0) {
const firstAlias = Array.from(parsed.aliasToCanonical.keys())[0];
if (firstAlias) {
const aliasNorm = this.vocabulary.normalize(firstAlias);
console.log(`Normalization example (alias): "${firstAlias}" -> canonical: ${aliasNorm.canonical}, inverse: ${aliasNorm.inverse}`);
}
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("Vocabulary file not found")) {
new Notice("edge_vocabulary.md not found. Configure path in plugin settings.");
} else {
new Notice(`Failed to reload vocabulary: ${msg}`);
}
console.error(e);
}
}
}

2
src/parser/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./types";
export * from "./parseEdgesFromCallouts";

View File

@ -0,0 +1,78 @@
import type { ParsedEdge } from "./types";
const EDGE_HEADER_RE = /^\s*(>+)\s*\[!edge\]\s*(.+?)\s*$/i;
const TARGET_LINK_RE = /\[\[([^\]]+?)\]\]/g;
/**
* Extract edges from any callout nesting:
* - Edge starts with: > [!edge] <type> (any number of '>' allowed)
* - Collect targets from subsequent lines while quoteLevel >= edgeLevel
* - Stop when:
* a) next [!edge] header appears, OR
* b) quoteLevel drops below edgeLevel (block ends), ignoring blank lines
*/
export function parseEdgesFromCallouts(markdown: string): ParsedEdge[] {
const lines = markdown.split(/\r?\n/);
const edges: ParsedEdge[] = [];
let current: ParsedEdge | null = null;
let currentEdgeLevel = 0;
const getQuoteLevel = (line: string): number => {
const m = line.match(/^\s*(>+)/);
return m && m[1] ? m[1].length : 0;
};
const flush = (endLine: number) => {
if (!current) return;
current.lineEnd = endLine;
if (current.targets.length > 0) edges.push(current);
current = null;
currentEdgeLevel = 0;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
// Start of a new edge block
const edgeMatch = line.match(EDGE_HEADER_RE);
if (edgeMatch && edgeMatch[1] && edgeMatch[2]) {
flush(i - 1);
currentEdgeLevel = edgeMatch[1].length;
current = {
rawType: edgeMatch[2].trim(),
targets: [],
lineStart: i,
lineEnd: i,
};
continue;
}
if (!current) continue;
const trimmed = line.trim();
const ql = getQuoteLevel(line);
// End of the current edge block if quote level drops below the edge header level
// (ignore blank lines)
if (trimmed !== "" && ql < currentEdgeLevel) {
flush(i - 1);
continue;
}
// Collect targets (multiple per line allowed)
TARGET_LINK_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TARGET_LINK_RE.exec(line)) !== null) {
if (m[1]) {
const t = m[1].trim();
if (t) current.targets.push(t);
}
}
}
flush(lines.length - 1);
return edges;
}

View File

15
src/parser/types.ts Normal file
View File

@ -0,0 +1,15 @@
export interface NodeMeta {
id?: string;
title?: string;
type?: string;
status?: string;
tags?: string[];
date?: string;
}
export interface ParsedEdge {
rawType: string;
targets: string[]; // link texts without [[ ]]
lineStart: number; // 0-based
lineEnd: number; // 0-based
}

21
src/settings.ts Normal file
View File

@ -0,0 +1,21 @@
export interface MindnetSettings {
edgeVocabularyPath: string; // vault-relativ
graphSchemaPath: string; // vault-relativ (später)
maxHops: number;
strictMode: boolean;
}
export const DEFAULT_SETTINGS: MindnetSettings = {
edgeVocabularyPath: "_system/dictionary/edge_vocabulary.md",
graphSchemaPath: "_system/dictionary/graph_schema.md",
maxHops: 3,
strictMode: false,
};
/**
* Optional helper: normalize to Obsidian vault paths (forward slashes).
*/
export function normalizeVaultPath(p: string): string {
return (p || "").trim().replace(/\\/g, "/");
}

View File

@ -0,0 +1,207 @@
import { describe, it, expect } from "vitest";
import type { ParsedEdge } from "../../parser/types";
import { lintEdges } from "../../lint/LintEngine";
import { Vocabulary } from "../../vocab/Vocabulary";
import { parseEdgeVocabulary } from "../../vocab/parseEdgeVocabulary";
describe("lintEdges", () => {
// Create a minimal vocabulary for testing
const vocabMd = `
| System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung |
| :--- | :--- | :--- | :--- |
| \`caused_by\` | \`resulted_in\` | \`wegen\`, \`ausgelöst_durch\` | Test |
| \`impacts\` | \`impacted_by\` | *(Kein Alias)* | Test |
`;
const vocabulary = new Vocabulary(parseEdgeVocabulary(vocabMd));
it("reports unknown edge type as ERROR", () => {
const edges: ParsedEdge[] = [
{
rawType: "unknown_type",
targets: [],
lineStart: 0,
lineEnd: 0,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(1);
const finding = findings[0];
if (!finding) throw new Error("Expected finding");
expect(finding.ruleId).toBe("unknown_edge_type");
expect(finding.severity).toBe("ERROR");
expect(finding.message).toContain("unknown_type");
});
it("reports alias not normalized as WARN", () => {
const edges: ParsedEdge[] = [
{
rawType: "wegen",
targets: [],
lineStart: 5,
lineEnd: 5,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(1);
const finding = findings[0];
if (!finding) throw new Error("Expected finding");
expect(finding.ruleId).toBe("alias_not_normalized");
expect(finding.severity).toBe("WARN");
expect(finding.message).toContain("wegen");
expect(finding.message).toContain("caused_by");
expect(finding.lineStart).toBe(5);
});
it("does not report normalized canonical types", () => {
const edges: ParsedEdge[] = [
{
rawType: "caused_by",
targets: [],
lineStart: 0,
lineEnd: 0,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(0);
});
it("reports missing target notes as WARN", () => {
const edges: ParsedEdge[] = [
{
rawType: "caused_by",
targets: ["MissingNote", "ExistingNote"],
lineStart: 0,
lineEnd: 2,
},
];
const existingFiles = new Set<string>(["ExistingNote.md", "ExistingNote"]);
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(1);
const finding = findings[0];
if (!finding) throw new Error("Expected finding");
expect(finding.ruleId).toBe("missing_target_note");
expect(finding.severity).toBe("WARN");
expect(finding.message).toContain("MissingNote");
expect(finding.evidence).toBe("MissingNote");
});
it("handles target notes with headings", () => {
const edges: ParsedEdge[] = [
{
rawType: "caused_by",
targets: ["Note#heading"],
lineStart: 0,
lineEnd: 0,
},
];
const existingFiles = new Set<string>(["Note.md"]);
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(0);
});
it("handles target notes without .md extension", () => {
const edges: ParsedEdge[] = [
{
rawType: "caused_by",
targets: ["NoteName"],
lineStart: 0,
lineEnd: 0,
},
];
const existingFiles = new Set<string>(["NoteName.md"]);
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(0);
});
it("handles multiple issues in one edge", () => {
const edges: ParsedEdge[] = [
{
rawType: "wegen", // alias not normalized
targets: ["MissingNote"], // missing target
lineStart: 10,
lineEnd: 12,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(2);
const finding0 = findings[0];
const finding1 = findings[1];
if (!finding0 || !finding1) throw new Error("Expected findings");
expect(finding0.ruleId).toBe("alias_not_normalized");
expect(finding1.ruleId).toBe("missing_target_note");
});
it("handles case-insensitive alias normalization", () => {
const edges: ParsedEdge[] = [
{
rawType: "WEGEN", // uppercase alias
targets: [],
lineStart: 0,
lineEnd: 0,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(1);
const finding = findings[0];
if (!finding) throw new Error("Expected finding");
expect(finding.ruleId).toBe("alias_not_normalized");
expect(finding.message).toContain("WEGEN");
expect(finding.message).toContain("caused_by");
});
it("handles empty targets array", () => {
const edges: ParsedEdge[] = [
{
rawType: "caused_by",
targets: [],
lineStart: 0,
lineEnd: 0,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
expect(findings.length).toBe(0);
});
it("preserves line numbers in findings", () => {
const edges: ParsedEdge[] = [
{
rawType: "unknown",
targets: [],
lineStart: 42,
lineEnd: 45,
},
];
const existingFiles = new Set<string>();
const findings = lintEdges(edges, vocabulary, existingFiles);
const finding = findings[0];
if (!finding) throw new Error("Expected finding");
expect(finding.lineStart).toBe(42);
expect(finding.lineEnd).toBe(45);
});
});

View File

@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { parseEdgesFromCallouts } from "../../parser/parseEdgesFromCallouts";
describe("parseEdgesFromCallouts", () => {
it("parses edge blocks embedded inside other callouts", () => {
const md = `
> [!abstract]- 🧩 Some Block
>> Some text
>> [!edge] caused_by
>> [[A]]
>> [[B]]
>> More text
Outside.
`.trim();
const edges = parseEdgesFromCallouts(md);
expect(edges.length).toBe(1);
const edge = edges[0];
if (!edge) throw new Error("Expected edge to exist");
expect(edge.rawType).toBe("caused_by");
expect(edge.targets).toEqual(["A", "B"]);
});
it("stops when quote level drops below edge level", () => {
const md = `
> [!abstract]
>> [!edge] resulted_in
>> [[X]]
Normal text ends quote.
`.trim();
const edges = parseEdgesFromCallouts(md);
expect(edges.length).toBe(1);
const edge = edges[0];
if (!edge) throw new Error("Expected edge to exist");
expect(edge.rawType).toBe("resulted_in");
expect(edge.targets).toEqual(["X"]);
});
it("starts a new edge when another edge header appears", () => {
const md = `
> [!edge] caused_by
> [[A]]
> [!edge] resulted_in
> [[B]]
`.trim();
const edges = parseEdgesFromCallouts(md);
expect(edges.length).toBe(2);
const edge0 = edges[0];
const edge1 = edges[1];
if (!edge0 || !edge1) throw new Error("Expected edges to exist");
expect(edge0.rawType).toBe("caused_by");
expect(edge0.targets).toEqual(["A"]);
expect(edge1.rawType).toBe("resulted_in");
expect(edge1.targets).toEqual(["B"]);
});
it("allows multiple links in one line", () => {
const md = `
>> [!edge] impacts
>> [[Y]] [[Z]]
`.trim();
const edges = parseEdgesFromCallouts(md);
expect(edges.length).toBe(1);
const edge = edges[0];
if (!edge) throw new Error("Expected edge to exist");
expect(edge.rawType).toBe("impacts");
expect(edge.targets).toEqual(["Y", "Z"]);
});
});

View File

@ -0,0 +1,169 @@
import { describe, it, expect } from "vitest";
import { parseEdgeVocabulary } from "../../vocab/parseEdgeVocabulary";
import { Vocabulary } from "../../vocab/Vocabulary";
describe("parseEdgeVocabulary", () => {
const fixtureMd = `
# Edge Vocabulary
## Causal Relations
| System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung |
| :--- | :--- | :--- | :--- |
| **\`caused_by\`** | \`resulted_in\` | \`ausgelöst_durch\`, \`wegen\`, \`verursacht_durch\` | Describes causation |
| \`impacts\` | \`impacted_by\` | *(Kein Alias)* | Impact relationship |
| \`influences\` | \`influenced_by\` | \`beeinflusst\`, \`wirkt_auf\` | Influence relation |
## Other Relations
| System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung |
| :--- | :--- | :--- | :--- |
| \`relates_to\` | \`related_from\` | \`verwandt_mit\`, \`bezogen_auf\` | General relation |
`;
it("parses canonical types correctly", () => {
const vocab = parseEdgeVocabulary(fixtureMd);
expect(vocab.byCanonical.has("caused_by")).toBe(true);
expect(vocab.byCanonical.has("impacts")).toBe(true);
expect(vocab.byCanonical.has("influences")).toBe(true);
expect(vocab.byCanonical.has("relates_to")).toBe(true);
});
it("parses inverse types correctly", () => {
const vocab = parseEdgeVocabulary(fixtureMd);
const causedBy = vocab.byCanonical.get("caused_by");
if (!causedBy) throw new Error("Expected caused_by entry");
expect(causedBy.inverse).toBe("resulted_in");
const impacts = vocab.byCanonical.get("impacts");
if (!impacts) throw new Error("Expected impacts entry");
expect(impacts.inverse).toBe("impacted_by");
});
it("handles (Kein Alias) correctly", () => {
const vocab = parseEdgeVocabulary(fixtureMd);
const impacts = vocab.byCanonical.get("impacts");
if (!impacts) throw new Error("Expected impacts entry");
expect(impacts.aliases).toEqual([]);
});
it("parses multiple aliases correctly", () => {
const vocab = parseEdgeVocabulary(fixtureMd);
const causedBy = vocab.byCanonical.get("caused_by");
if (!causedBy) throw new Error("Expected caused_by entry");
expect(causedBy.aliases).toEqual(["ausgelöst_durch", "wegen", "verursacht_durch"]);
const influences = vocab.byCanonical.get("influences");
if (!influences) throw new Error("Expected influences entry");
expect(influences.aliases).toEqual(["beeinflusst", "wirkt_auf"]);
});
it("builds alias-to-canonical mapping", () => {
const vocab = parseEdgeVocabulary(fixtureMd);
expect(vocab.aliasToCanonical.get("ausgelöst_durch")).toBe("caused_by");
expect(vocab.aliasToCanonical.get("wegen")).toBe("caused_by");
expect(vocab.aliasToCanonical.get("verursacht_durch")).toBe("caused_by");
expect(vocab.aliasToCanonical.get("beeinflusst")).toBe("influences");
expect(vocab.aliasToCanonical.get("wirkt_auf")).toBe("influences");
});
it("parses multiple tables correctly", () => {
const vocab = parseEdgeVocabulary(fixtureMd);
expect(vocab.byCanonical.size).toBe(4);
expect(vocab.byCanonical.has("caused_by")).toBe(true);
expect(vocab.byCanonical.has("impacts")).toBe(true);
expect(vocab.byCanonical.has("influences")).toBe(true);
expect(vocab.byCanonical.has("relates_to")).toBe(true);
});
});
describe("Vocabulary wrapper", () => {
const fixtureMd = `
| System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung |
| :--- | :--- | :--- | :--- |
| **\`caused_by\`** | \`resulted_in\` | \`ausgelöst_durch\`, \`wegen\` | Test |
| \`impacts\` | \`impacted_by\` | *(Kein Alias)* | Test |
`;
it("getCanonical returns canonical for canonical input", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
expect(vocab.getCanonical("caused_by")).toBe("caused_by");
expect(vocab.getCanonical("impacts")).toBe("impacts");
});
it("getCanonical returns canonical for alias input (case-insensitive)", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
expect(vocab.getCanonical("wegen")).toBe("caused_by");
expect(vocab.getCanonical("WEGEN")).toBe("caused_by");
expect(vocab.getCanonical("ausgelöst_durch")).toBe("caused_by");
expect(vocab.getCanonical("AUSGELÖST_DURCH")).toBe("caused_by");
});
it("getCanonical returns null for unknown input", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
expect(vocab.getCanonical("unknown")).toBe(null);
expect(vocab.getCanonical("")).toBe(null);
});
it("getInverse returns inverse for canonical", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
expect(vocab.getInverse("caused_by")).toBe("resulted_in");
expect(vocab.getInverse("impacts")).toBe("impacted_by");
});
it("getInverse returns null for unknown canonical", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
expect(vocab.getInverse("unknown")).toBe(null);
});
it("normalize returns correct result for canonical", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
const result = vocab.normalize("caused_by");
expect(result.raw).toBe("caused_by");
expect(result.canonical).toBe("caused_by");
expect(result.inverse).toBe("resulted_in");
});
it("normalize returns correct result for alias (case-insensitive)", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
const result = vocab.normalize("wegen");
expect(result.raw).toBe("wegen");
expect(result.canonical).toBe("caused_by");
expect(result.inverse).toBe("resulted_in");
const resultUpper = vocab.normalize("WEGEN");
expect(resultUpper.raw).toBe("WEGEN");
expect(resultUpper.canonical).toBe("caused_by");
expect(resultUpper.inverse).toBe("resulted_in");
});
it("normalize returns null canonical for unknown input", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
const result = vocab.normalize("unknown");
expect(result.raw).toBe("unknown");
expect(result.canonical).toBe(null);
expect(result.inverse).toBe(null);
});
it("getStats returns correct counts", () => {
const vocab = new Vocabulary(parseEdgeVocabulary(fixtureMd));
const stats = vocab.getStats();
expect(stats.canonicalCount).toBe(2);
expect(stats.aliasCount).toBe(2); // ausgelöst_durch, wegen
});
});

View File

@ -0,0 +1,99 @@
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import type MindnetCausalAssistantPlugin from "../main";
import { VocabularyLoader } from "../vocab/VocabularyLoader";
export class MindnetSettingTab extends PluginSettingTab {
plugin: MindnetCausalAssistantPlugin;
constructor(app: App, plugin: MindnetCausalAssistantPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Mindnet Settings" });
// Edge vocabulary path
new Setting(containerEl)
.setName("Edge vocabulary path")
.setDesc("Vault-relative path to the edge vocabulary markdown file")
.addText((text) =>
text
.setPlaceholder("_system/dictionary/edge_vocabulary.md")
.setValue(this.plugin.settings.edgeVocabularyPath)
.onChange(async (value) => {
this.plugin.settings.edgeVocabularyPath = value;
await this.plugin.saveSettings();
})
)
.addButton((button) =>
button
.setButtonText("Validate")
.setCta()
.onClick(async () => {
try {
const text = await VocabularyLoader.loadText(
this.app,
this.plugin.settings.edgeVocabularyPath
);
new Notice(
`Edge vocabulary file found (${text.length} characters)`
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(
`Failed to load edge vocabulary: ${msg}`
);
}
})
);
// Graph schema path
new Setting(containerEl)
.setName("Graph schema path")
.setDesc("Vault-relative path to the graph schema markdown file")
.addText((text) =>
text
.setPlaceholder("_system/dictionary/graph_schema.md")
.setValue(this.plugin.settings.graphSchemaPath)
.onChange(async (value) => {
this.plugin.settings.graphSchemaPath = value;
await this.plugin.saveSettings();
})
);
// Strict mode toggle
new Setting(containerEl)
.setName("Strict mode")
.setDesc("Enable strict validation mode")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.strictMode)
.onChange(async (value) => {
this.plugin.settings.strictMode = value;
await this.plugin.saveSettings();
})
);
// Max hops number input
new Setting(containerEl)
.setName("Max hops")
.setDesc("Maximum number of hops for graph traversal")
.addText((text) =>
text
.setPlaceholder("3")
.setValue(String(this.plugin.settings.maxHops))
.onChange(async (value) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.maxHops = numValue;
await this.plugin.saveSettings();
}
})
);
}
}

0
src/ui/MindnetView.ts Normal file
View File

64
src/vocab/Vocabulary.ts Normal file
View File

@ -0,0 +1,64 @@
import type { EdgeVocabulary, NormalizeResult } from "./types";
/**
* Wrapper class for edge vocabulary that provides convenient lookup methods.
*/
export class Vocabulary {
private vocab: EdgeVocabulary;
constructor(vocab: EdgeVocabulary) {
this.vocab = vocab;
}
/**
* Get canonical edge type from raw input (case-insensitive).
* Returns null if not found.
*/
getCanonical(raw: string): string | null {
const lowerRaw = raw.toLowerCase();
// Check if it's already a canonical
for (const [canonical] of this.vocab.byCanonical) {
if (canonical.toLowerCase() === lowerRaw) {
return canonical;
}
}
// Check aliases
const canonical = this.vocab.aliasToCanonical.get(lowerRaw);
return canonical ?? null;
}
/**
* Get inverse edge type for a canonical type.
* Returns null if not found or no inverse defined.
*/
getInverse(canonical: string): string | null {
const entry = this.vocab.byCanonical.get(canonical);
return entry?.inverse ?? null;
}
/**
* Normalize a raw edge type string to canonical and inverse.
*/
normalize(raw: string): NormalizeResult {
const canonical = this.getCanonical(raw);
const inverse = canonical ? this.getInverse(canonical) : null;
return {
raw,
canonical,
inverse,
};
}
/**
* Get statistics about the vocabulary.
*/
getStats(): { canonicalCount: number; aliasCount: number } {
return {
canonicalCount: this.vocab.byCanonical.size,
aliasCount: this.vocab.aliasToCanonical.size,
};
}
}

View File

@ -0,0 +1,27 @@
import type { App, TFile } from "obsidian";
import { normalizeVaultPath } from "../settings";
export class VocabularyLoader {
/**
* Loads a text file from the currently opened Obsidian vault.
* @param app Obsidian App
* @param vaultRelativePath e.g. "_system/dictionary/edge_vocabulary.md"
*/
static async loadText(app: App, vaultRelativePath: string): Promise<string> {
const p = normalizeVaultPath(vaultRelativePath);
const abstract = app.vault.getAbstractFileByPath(p);
if (!abstract) {
throw new Error(`Vocabulary file not found in vault: "${p}"`);
}
// Guard: Only files can be read
const file = abstract as TFile;
// TFile has 'extension' and 'path' properties; if it isn't a file this will usually fail at runtime.
if (!(file && typeof file.path === "string")) {
throw new Error(`Path is not a file: "${p}"`);
}
return await app.vault.read(file);
}
}

0
src/vocab/index.ts Normal file
View File

View File

@ -0,0 +1,105 @@
import type { EdgeVocabulary } from "./types";
const BACKTICK_RE = /`([^`]+)`/g;
/**
* Parses markdown tables containing edge vocabulary definitions.
*
* Expected format:
* | System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung ... |
* | **`caused_by`** | `resulted_in` | `ausgelöst_durch`, `wegen`, ... | ... |
*
* Rules:
* - Extract all backticked tokens from each row
* - First token = canonical (may be wrapped in ** **)
* - Second token = inverse (optional)
* - Remaining tokens = aliases (skip if cell contains "(Kein Alias)")
* - Skip rows with less than 1 token (with warning counter)
* - Store canonical as-is, but use lowercase for lookup keys
*/
export function parseEdgeVocabulary(md: string): EdgeVocabulary {
const lines = md.split(/\r?\n/);
const byCanonical = new Map<string, { canonical: string; inverse?: string; aliases: string[] }>();
const aliasToCanonical = new Map<string, string>();
let skippedRows = 0;
for (const line of lines) {
// Skip header separator rows (e.g., "| :--- | :--- |")
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) {
continue;
}
// Only process table rows
if (!line.trim().startsWith("|")) {
continue;
}
// Extract all backticked tokens
const tokens: string[] = [];
let match: RegExpExecArray | null;
BACKTICK_RE.lastIndex = 0;
while ((match = BACKTICK_RE.exec(line)) !== null) {
if (match[1]) {
const token = match[1].trim();
if (token) {
tokens.push(token);
}
}
}
// Skip rows with no tokens or only one token (need at least canonical)
if (tokens.length < 1) {
skippedRows++;
continue;
}
// Check if aliases cell contains "(Kein Alias)"
const hasNoAliases = /\(Kein Alias\)/i.test(line);
const canonical = tokens[0];
if (!canonical) {
skippedRows++;
continue;
}
const inverse = tokens.length >= 2 && tokens[1] ? tokens[1] : undefined;
// Extract aliases: all tokens after the first two, but only if not "(Kein Alias)"
const aliases: string[] = [];
if (!hasNoAliases && tokens.length > 2) {
for (let i = 2; i < tokens.length; i++) {
const alias = tokens[i];
if (alias) {
aliases.push(alias);
}
}
}
// Store canonical entry
byCanonical.set(canonical, {
canonical,
inverse,
aliases,
});
// Build alias-to-canonical mapping (case-insensitive keys)
for (const alias of aliases) {
if (alias) {
const lowerAlias = alias.toLowerCase();
if (!aliasToCanonical.has(lowerAlias)) {
aliasToCanonical.set(lowerAlias, canonical);
}
}
}
}
if (skippedRows > 0) {
console.warn(`parseEdgeVocabulary: Skipped ${skippedRows} rows with insufficient tokens`);
}
return {
byCanonical,
aliasToCanonical,
};
}

20
src/vocab/types.ts Normal file
View File

@ -0,0 +1,20 @@
export type CanonicalEdgeType = string;
export interface EdgeTypeEntry {
canonical: CanonicalEdgeType;
inverse?: CanonicalEdgeType;
aliases: string[];
}
export interface EdgeVocabulary {
// key: canonical edge type
byCanonical: Map<CanonicalEdgeType, EdgeTypeEntry>;
// key: alias (lowercased) -> canonical
aliasToCanonical: Map<string, CanonicalEdgeType>;
}
export interface NormalizeResult {
raw: string;
canonical: CanonicalEdgeType | null;
inverse: CanonicalEdgeType | null;
}

View File

@ -1,24 +1,30 @@
{
"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"
]
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"importHelpers": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"strictNullChecks": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
"useUnknownInCatchVariables": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"src/**/*.ts"
]
}

View File

@ -3,12 +3,15 @@ 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 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"));
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

13
vitest.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
globals: true,
},
resolve: {
alias: {
obsidian: new URL("./src/__mocks__/obsidian.ts", import.meta.url).pathname,
},
},
});