diff --git a/extension-api/pom.xml b/extension-api/pom.xml index 6673f0c..d115b76 100644 --- a/extension-api/pom.xml +++ b/extension-api/pom.xml @@ -38,6 +38,13 @@ net.revelc.code.formatter formatter-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + false + + diff --git a/golemcore/obsidian/plugin.yaml b/golemcore/obsidian/plugin.yaml new file mode 100644 index 0000000..6e0774c --- /dev/null +++ b/golemcore/obsidian/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/obsidian +provider: golemcore +name: obsidian +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap +description: Obsidian vault plugin backed by obsidian-local-rest-api. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/obsidian/pom.xml b/golemcore/obsidian/pom.xml new file mode 100644 index 0000000..9e9d960 --- /dev/null +++ b/golemcore/obsidian/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-obsidian-plugin + golemcore/obsidian + Obsidian vault plugin for GolemCore + + + golemcore + obsidian + ../../misc/formatter_eclipse.xml + + + + + me.golemcore.plugins + golemcore-plugin-extension-api + provided + + + me.golemcore.plugins + golemcore-plugin-runtime-api + provided + + + org.projectlombok + lombok + provided + + + org.springframework + spring-context + + + com.fasterxml.jackson.core + jackson-databind + + + com.squareup.okhttp3 + okhttp-jvm + ${okhttp.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + src/main/resources + + + ${project.basedir} + + plugin.yaml + + META-INF/golemcore + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + maven-shade-plugin + + + maven-antrun-plugin + + + copy-plugin-artifact + package + + + + + + + + run + + + + + + + diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java new file mode 100644 index 0000000..d0f502e --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class ObsidianPluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/obsidian") + .provider("golemcore") + .name("obsidian") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return ObsidianPluginConfiguration.class; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java new file mode 100644 index 0000000..2fc65fe --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfig.java @@ -0,0 +1,92 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ObsidianPluginConfig { + + private static final String DEFAULT_BASE_URL = "http://127.0.0.1:27123"; + private static final int DEFAULT_TIMEOUT_MS = 30_000; + private static final int DEFAULT_SEARCH_CONTEXT_LENGTH = 100; + private static final int DEFAULT_MAX_READ_CHARS = 12_000; + + @Builder.Default + private Boolean enabled = false; + + @Builder.Default + private String baseUrl = DEFAULT_BASE_URL; + private String apiKey; + + @Builder.Default + private Integer timeoutMs = DEFAULT_TIMEOUT_MS; + + @Builder.Default + private Boolean allowInsecureTls = false; + + @Builder.Default + private Integer defaultSearchContextLength = DEFAULT_SEARCH_CONTEXT_LENGTH; + + @Builder.Default + private Integer maxReadChars = DEFAULT_MAX_READ_CHARS; + + @Builder.Default + private Boolean allowWrite = false; + + @Builder.Default + private Boolean allowDelete = false; + + @Builder.Default + private Boolean allowMove = false; + + @Builder.Default + private Boolean allowRename = false; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = DEFAULT_BASE_URL; + } else { + baseUrl = trimTrailingSlash(baseUrl.trim()); + } + if (timeoutMs == null || timeoutMs <= 0) { + timeoutMs = DEFAULT_TIMEOUT_MS; + } + if (allowInsecureTls == null) { + allowInsecureTls = false; + } + if (defaultSearchContextLength == null || defaultSearchContextLength <= 0) { + defaultSearchContextLength = DEFAULT_SEARCH_CONTEXT_LENGTH; + } + if (maxReadChars == null || maxReadChars <= 0) { + maxReadChars = DEFAULT_MAX_READ_CHARS; + } + if (allowWrite == null) { + allowWrite = false; + } + if (allowDelete == null) { + allowDelete = false; + } + if (allowMove == null) { + allowMove = false; + } + if (allowRename == null) { + allowRename = false; + } + } + + private String trimTrailingSlash(String value) { + String trimmed = value; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isBlank() ? DEFAULT_BASE_URL : trimmed; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java new file mode 100644 index 0000000..ea39d0c --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfigService.java @@ -0,0 +1,33 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ObsidianPluginConfigService { + + static final String PLUGIN_ID = "golemcore/obsidian"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ObsidianPluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + ObsidianPluginConfig config = raw.isEmpty() + ? ObsidianPluginConfig.builder().build() + : objectMapper.convertValue(raw, ObsidianPluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(ObsidianPluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java new file mode 100644 index 0000000..75bf378 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginConfiguration.java @@ -0,0 +1,9 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = ObsidianPluginConfiguration.class) +public class ObsidianPluginConfiguration { +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java new file mode 100644 index 0000000..0e403e1 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributor.java @@ -0,0 +1,247 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import lombok.RequiredArgsConstructor; +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsAction; +import me.golemcore.plugin.api.extension.spi.PluginSettingsCatalogItem; +import me.golemcore.plugin.api.extension.spi.PluginSettingsContributor; +import me.golemcore.plugin.api.extension.spi.PluginSettingsField; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiException; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianTransportException; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class ObsidianPluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; + + private final ObsidianPluginConfigService configService; + private final ObsidianApiClient apiClient; + + @Override + public String getPluginId() { + return ObsidianPluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(ObsidianPluginConfigService.PLUGIN_ID) + .pluginName("obsidian") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("Obsidian") + .description("Obsidian vault connection, context limits, and file operation policy.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(37) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + ObsidianPluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("baseUrl", config.getBaseUrl()); + values.put("apiKey", ""); + values.put("timeoutMs", config.getTimeoutMs()); + values.put("allowInsecureTls", Boolean.TRUE.equals(config.getAllowInsecureTls())); + values.put("defaultSearchContextLength", config.getDefaultSearchContextLength()); + values.put("maxReadChars", config.getMaxReadChars()); + values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite())); + values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete())); + values.put("allowMove", Boolean.TRUE.equals(config.getAllowMove())); + values.put("allowRename", Boolean.TRUE.equals(config.getAllowRename())); + + return PluginSettingsSection.builder() + .title("Obsidian") + .description("Configure the Obsidian vault connection and conservative file operation policy.") + .fields(List.of( + PluginSettingsField.builder() + .key("enabled") + .type("boolean") + .label("Enable Obsidian") + .description("Allow tools to use the Obsidian vault integration.") + .build(), + PluginSettingsField.builder() + .key("baseUrl") + .type("text") + .label("Base URL") + .description("Obsidian Local REST API endpoint.") + .placeholder("http://127.0.0.1:27123") + .build(), + PluginSettingsField.builder() + .key("apiKey") + .type("secret") + .label("API Key") + .description("Leave blank to keep the current secret.") + .placeholder("Enter API key") + .build(), + PluginSettingsField.builder() + .key("timeoutMs") + .type("number") + .label("Request Timeout (ms)") + .description("Timeout for vault API requests.") + .min(1000.0) + .max(300000.0) + .step(1000.0) + .build(), + PluginSettingsField.builder() + .key("allowInsecureTls") + .type("boolean") + .label("Allow Insecure TLS") + .description("Allow self-signed TLS certificates when connecting to Obsidian.") + .build(), + PluginSettingsField.builder() + .key("defaultSearchContextLength") + .type("number") + .label("Default Search Context Length") + .description("Context length used when searching vault content.") + .min(1.0) + .step(1.0) + .build(), + PluginSettingsField.builder() + .key("maxReadChars") + .type("number") + .label("Max Read Chars") + .description("Maximum number of characters returned when reading a note.") + .min(1.0) + .step(1.0) + .build(), + PluginSettingsField.builder() + .key("allowWrite") + .type("boolean") + .label("Allow Write") + .description("Permit tools to create or edit notes.") + .build(), + PluginSettingsField.builder() + .key("allowDelete") + .type("boolean") + .label("Allow Delete") + .description("Permit tools to delete notes.") + .build(), + PluginSettingsField.builder() + .key("allowMove") + .type("boolean") + .label("Allow Move") + .description("Permit tools to move notes or files.") + .build(), + PluginSettingsField.builder() + .key("allowRename") + .type("boolean") + .label("Allow Rename") + .description("Permit tools to rename notes or files.") + .build())) + .values(values) + .actions(List.of(PluginSettingsAction.builder() + .actionId(ACTION_TEST_CONNECTION) + .label("Test Connection") + .variant("secondary") + .build())) + .build(); + } + + @Override + public PluginSettingsSection saveSection(String sectionKey, Map values) { + requireSection(sectionKey); + ObsidianPluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setBaseUrl(readString(values, "baseUrl", config.getBaseUrl())); + String apiKey = readString(values, "apiKey", null); + if (apiKey != null && !apiKey.isBlank()) { + config.setApiKey(apiKey); + } + config.setTimeoutMs(readInteger(values, "timeoutMs", config.getTimeoutMs())); + config.setAllowInsecureTls(readBoolean(values, "allowInsecureTls", false)); + config.setDefaultSearchContextLength(readInteger(values, "defaultSearchContextLength", + config.getDefaultSearchContextLength())); + config.setMaxReadChars(readInteger(values, "maxReadChars", config.getMaxReadChars())); + config.setAllowWrite(readBoolean(values, "allowWrite", false)); + config.setAllowDelete(readBoolean(values, "allowDelete", false)); + config.setAllowMove(readBoolean(values, "allowMove", false)); + config.setAllowRename(readBoolean(values, "allowRename", false)); + configService.save(config); + return getSection(sectionKey); + } + + @Override + public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { + requireSection(sectionKey); + if (!ACTION_TEST_CONNECTION.equals(actionId)) { + throw new IllegalArgumentException("Unknown Obsidian action: " + actionId); + } + return testConnection(); + } + + private PluginActionResult testConnection() { + ObsidianPluginConfig config = configService.getConfig(); + if (!hasText(config.getApiKey())) { + return PluginActionResult.builder() + .status("error") + .message("Obsidian API key is not configured.") + .build(); + } + try { + List entries = apiClient.listDirectory(""); + return PluginActionResult.builder() + .status("ok") + .message("Connected to Obsidian. Vault root returned " + entries.size() + " item(s).") + .build(); + } catch (IllegalArgumentException | IllegalStateException | ObsidianApiException + | ObsidianTransportException ex) { + return PluginActionResult.builder() + .status("error") + .message("Connection failed: " + ex.getMessage()) + .build(); + } + } + + private void requireSection(String sectionKey) { + if (!SECTION_KEY.equals(sectionKey)) { + throw new IllegalArgumentException("Unknown Obsidian settings section: " + sectionKey); + } + } + + private boolean readBoolean(Map values, String key, boolean defaultValue) { + Object value = values.get(key); + return value instanceof Boolean bool ? bool : defaultValue; + } + + private int readInteger(Map values, String key, int defaultValue) { + Object value = values.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + private String readString(Map values, String key, String defaultValue) { + Object value = values.get(key); + if (value instanceof String text) { + return text; + } + return defaultValue; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java new file mode 100644 index 0000000..448ddf5 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultService.java @@ -0,0 +1,233 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiException; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianPathValidator; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianTransportException; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class ObsidianVaultService { + + private final ObsidianApiClient apiClient; + private final ObsidianPluginConfigService configService; + private final ObsidianPathValidator pathValidator = new ObsidianPathValidator(); + + public ObsidianVaultService(ObsidianApiClient apiClient, ObsidianPluginConfigService configService) { + this.apiClient = apiClient; + this.configService = configService; + } + + public ToolResult listDirectory(String path) { + try { + String normalizedPath = pathValidator.normalizeDirectoryPath(path); + List entries = apiClient.listDirectory(normalizedPath); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("entries", entries); + data.put("files", entries); + return ToolResult.success( + "Listed " + entries.size() + " item(s) in " + displayPath(normalizedPath), + data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readNote(String path) { + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String content = apiClient.readNote(normalizedPath); + int originalLength = content.length(); + int maxReadChars = configService.getConfig().getMaxReadChars(); + boolean truncated = originalLength > maxReadChars; + String visibleContent = truncated ? content.substring(0, maxReadChars) : content; + + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("content", visibleContent); + data.put("truncated", truncated); + if (truncated) { + data.put("originalLength", originalLength); + } + return ToolResult.success(visibleContent, data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult searchNotes(String query, Integer contextLength) { + try { + if (query == null || query.isBlank()) { + throw new IllegalArgumentException("Search query is required"); + } + int requestedContextLength = contextLength != null ? contextLength : 0; + List results = apiClient.simpleSearch(query.trim(), requestedContextLength); + Map data = new LinkedHashMap<>(); + data.put("query", query.trim()); + data.put("count", results.size()); + data.put("results", results); + return ToolResult.success("Found " + results.size() + " note(s) for query: " + query.trim(), data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createNote(String path, String content) { + return writeNote(path, content, "Created", false); + } + + public ToolResult updateNote(String path, String content) { + return writeNote(path, content, "Updated", true); + } + + public ToolResult deleteNote(String path) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian delete is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + requireExistingNote(normalizedPath); + apiClient.deleteNote(normalizedPath); + return ToolResult.success("Deleted note " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult moveNote(String path, String targetPath) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowMove())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian move is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String normalizedTargetPath = pathValidator.normalizeNotePath(targetPath); + return relocateNote(normalizedPath, normalizedTargetPath, "Moved"); + } catch (IllegalArgumentException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult renameNote(String path, String newName) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowRename())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian rename is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String targetPath = pathValidator.resolveSiblingNotePath(normalizedPath, newName); + return relocateNote(normalizedPath, targetPath, "Renamed"); + } catch (IllegalArgumentException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult writeNote(String path, String content, String verb, boolean requireExisting) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Obsidian write is disabled in plugin settings"); + } + + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + requireContent(content); + if (requireExisting) { + requireExistingNote(normalizedPath); + } else { + ensureNoteAbsent(normalizedPath); + } + apiClient.writeNote(normalizedPath, content); + return ToolResult.success(verb + " note " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ToolResult relocateNote(String sourcePath, String targetPath, String verb) { + try { + if (sourcePath.equals(targetPath)) { + throw new IllegalArgumentException("Source and target paths must differ"); + } + String content = requireExistingNote(sourcePath); + ensureNoteAbsent(targetPath); + apiClient.writeNote(targetPath, content); + try { + apiClient.deleteNote(sourcePath); + } catch (ObsidianApiException | ObsidianTransportException ex) { + Map data = new LinkedHashMap<>(); + data.put("path", sourcePath); + data.put("target_path", targetPath); + return ToolResult.builder() + .success(false) + .failureKind(ToolFailureKind.EXECUTION_FAILED) + .error("Obsidian " + verb.toLowerCase(Locale.ROOT) + " partially failed after writing " + + targetPath + "; both source and target may now exist: " + ex.getMessage()) + .data(data) + .build(); + } + + Map data = new LinkedHashMap<>(); + data.put("path", sourcePath); + data.put("target_path", targetPath); + return ToolResult.success(verb + " note from " + sourcePath + " to " + targetPath, data); + } catch (IllegalArgumentException | ObsidianApiException | ObsidianTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private String requireExistingNote(String path) { + try { + return apiClient.readNote(path); + } catch (ObsidianApiException ex) { + if (ex.getStatusCode() == 404) { + throw new IllegalArgumentException("Note does not exist: " + path); + } + throw ex; + } + } + + private void ensureNoteAbsent(String path) { + if (noteExists(path)) { + throw new IllegalArgumentException("Target note already exists: " + path); + } + } + + private boolean noteExists(String path) { + try { + apiClient.readNote(path); + return true; + } catch (ObsidianApiException ex) { + if (ex.getStatusCode() == 404) { + return false; + } + throw ex; + } + } + + private void requireContent(String content) { + if (content == null) { + throw new IllegalArgumentException("Content is required"); + } + } + + private ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); + } + + private String displayPath(String normalizedPath) { + return normalizedPath.isBlank() ? "/" : normalizedPath; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java new file mode 100644 index 0000000..44e7e68 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProvider.java @@ -0,0 +1,149 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugin.api.extension.spi.ToolProvider; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Component +public class ObsidianVaultToolProvider implements ToolProvider { + + private static final String TYPE = "type"; + private static final String TYPE_OBJECT = "object"; + private static final String TYPE_STRING = "string"; + private static final String TYPE_INTEGER = "integer"; + private static final String PROPERTIES = "properties"; + private static final String REQUIRED = "required"; + private static final String PARAM_OPERATION = "operation"; + private static final String PARAM_PATH = "path"; + private static final String PARAM_QUERY = "query"; + private static final String PARAM_CONTEXT_LENGTH = "context_length"; + private static final String PARAM_CONTENT = "content"; + private static final String PARAM_TARGET_PATH = "target_path"; + private static final String PARAM_NEW_NAME = "new_name"; + + private final ObsidianVaultService service; + + public ObsidianVaultToolProvider(ObsidianVaultService service) { + this.service = service; + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("obsidian_vault") + .description("Use an Obsidian vault through obsidian-local-rest-api.") + .inputSchema(Map.of( + TYPE, TYPE_OBJECT, + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of( + TYPE, TYPE_STRING, + "enum", List.of( + "list_directory", + "read_note", + "search_notes", + "create_note", + "update_note", + "delete_note", + "move_note", + "rename_note")), + PARAM_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Vault-relative note or directory path."), + PARAM_QUERY, Map.of( + TYPE, TYPE_STRING, + "description", "Search query for search_notes."), + PARAM_CONTEXT_LENGTH, Map.of( + TYPE, TYPE_INTEGER, + "description", "Optional context length for search_notes."), + PARAM_CONTENT, Map.of( + TYPE, TYPE_STRING, + "description", "Markdown content for create_note or update_note."), + PARAM_TARGET_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Target vault-relative path for move_note."), + PARAM_NEW_NAME, Map.of( + TYPE, TYPE_STRING, + "description", "New file name for rename_note.")), + REQUIRED, List.of(PARAM_OPERATION), + "allOf", List.of( + requiredWhen("read_note", List.of(PARAM_PATH)), + requiredWhen("search_notes", List.of(PARAM_QUERY)), + requiredWhen("create_note", List.of(PARAM_PATH, PARAM_CONTENT)), + requiredWhen("update_note", List.of(PARAM_PATH, PARAM_CONTENT)), + requiredWhen("delete_note", List.of(PARAM_PATH)), + requiredWhen("move_note", List.of(PARAM_PATH, PARAM_TARGET_PATH)), + requiredWhen("rename_note", List.of(PARAM_PATH, PARAM_NEW_NAME))))) + .build(); + } + + @Override + public CompletableFuture execute(Map parameters) { + return CompletableFuture.supplyAsync(() -> executeOperation(parameters)); + } + + private ToolResult executeOperation(Map parameters) { + String operation = readString(parameters.get(PARAM_OPERATION)); + if (operation == null || operation.isBlank()) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "operation is required"); + } + + return switch (operation) { + case "list_directory" -> service.listDirectory(readString(parameters.get(PARAM_PATH))); + case "read_note" -> service.readNote(readString(parameters.get(PARAM_PATH))); + case "search_notes" -> executeSearchNotes(parameters); + case "create_note" -> service.createNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_CONTENT))); + case "update_note" -> service.updateNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_CONTENT))); + case "delete_note" -> service.deleteNote(readString(parameters.get(PARAM_PATH))); + case "move_note" -> service.moveNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_TARGET_PATH))); + case "rename_note" -> service.renameNote( + readString(parameters.get(PARAM_PATH)), + readString(parameters.get(PARAM_NEW_NAME))); + default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Unsupported obsidian_vault operation: " + operation); + }; + } + + private ToolResult executeSearchNotes(Map parameters) { + Object rawContextLength = parameters.get(PARAM_CONTEXT_LENGTH); + Integer contextLength = readInteger(rawContextLength); + if (rawContextLength != null && contextLength == null) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "context_length must be an integer"); + } + return service.searchNotes(readString(parameters.get(PARAM_QUERY)), contextLength); + } + + private Map requiredWhen(String operation, List requiredFields) { + return Map.of( + "if", Map.of( + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of("const", operation))), + "then", Map.of( + REQUIRED, requiredFields)); + } + + private String readString(Object value) { + if (value instanceof String stringValue) { + return stringValue; + } + return null; + } + + private Integer readInteger(Object value) { + if (value instanceof Integer integerValue) { + return integerValue; + } + return null; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java new file mode 100644 index 0000000..a337390 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/model/ObsidianSearchResult.java @@ -0,0 +1,72 @@ +package me.golemcore.plugins.golemcore.obsidian.model; + +import java.util.List; + +public final class ObsidianSearchResult { + + private final String filename; + private final double score; + private final List matches; + + public ObsidianSearchResult(String filename, double score, List matches) { + this.filename = filename; + this.score = score; + this.matches = matches == null ? List.of() : List.copyOf(matches); + } + + public String getFilename() { + return filename; + } + + public double getScore() { + return score; + } + + public List getMatches() { + return matches; + } + + public static final class Match { + + private final String context; + private final MatchSpan span; + + public Match(String context, MatchSpan span) { + this.context = context; + this.span = span; + } + + public String getContext() { + return context; + } + + public MatchSpan getMatch() { + return span; + } + } + + public static final class MatchSpan { + + private final Integer start; + private final Integer end; + private final String source; + + public MatchSpan(Integer start, Integer end, String source) { + this.start = start; + this.end = end; + this.source = source; + } + + public Integer getStart() { + return start; + } + + public Integer getEnd() { + return end; + } + + public String getSource() { + return source; + } + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java new file mode 100644 index 0000000..507e201 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClient.java @@ -0,0 +1,382 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfig; +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfigService; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Component; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Component +public class ObsidianApiClient { + + private static final MediaType TEXT_MARKDOWN = MediaType.get("text/markdown; charset=utf-8"); + private static final MediaType APPLICATION_JSON = MediaType.get("application/json; charset=utf-8"); + private static final String VAULT_SEGMENT = "vault"; + private static final String SEARCH_SEGMENT = "search"; + private static final String SIMPLE_SEGMENT = "simple"; + + private final ObsidianPluginConfigService configService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ObsidianApiClient(ObsidianPluginConfigService configService) { + this.configService = configService; + } + + public List listDirectory(String path) { + Request request = new Request.Builder() + .url(buildVaultUrl(path, true)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .get() + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + if (!hasText(responseBody)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode filesNode = root.path("files"); + if (!filesNode.isArray()) { + throw new ObsidianApiException(response.code(), + "Invalid vault directory response: expected {files: [...]}"); + } + List files = new ArrayList<>(filesNode.size()); + for (JsonNode fileNode : filesNode) { + files.add(fileNode.asText("")); + } + return List.copyOf(files); + } catch (JsonProcessingException ex) { + throw new ObsidianApiException(response.code(), + "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response")); + } + } + } + + public String readNote(String path) { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Accept", "application/vnd.olrapi.note+json") + .header("Authorization", authorizationHeader()) + .get() + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + if (!hasText(responseBody)) { + throw new ObsidianApiException(response.code(), + "Invalid note response: expected JSON with content"); + } + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode contentNode = root.get("content"); + if (contentNode == null || contentNode.isNull() || !contentNode.isTextual()) { + throw new ObsidianApiException(response.code(), + "Invalid note response: expected textual content"); + } + return contentNode.asText(""); + } catch (JsonProcessingException ex) { + throw new ObsidianApiException(response.code(), + "Invalid note response: expected JSON with content"); + } + } + } + + public void writeNote(String path, String content) { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .header("Content-Type", TEXT_MARKDOWN.toString()) + .put(RequestBody.create(content != null ? content : "", TEXT_MARKDOWN)) + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + } + } + + public void deleteNote(String path) { + Request request = new Request.Builder() + .url(buildVaultUrl(path, false)) + .header("Authorization", authorizationHeader()) + .delete() + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + } + } + + public List simpleSearch(String query, int contextLength) { + if (!hasText(query)) { + throw new IllegalArgumentException("Search query is required"); + } + + int effectiveContextLength = contextLength > 0 ? contextLength : getConfig().getDefaultSearchContextLength(); + Request request = new Request.Builder() + .url(buildSearchUrl(query, effectiveContextLength)) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader()) + .post(RequestBody.create("", APPLICATION_JSON)) + .build(); + + try (Response response = openResponse(request)) { + String responseBody = readResponseBody(response); + ensureSuccessful(response, responseBody); + try { + if (!hasText(responseBody)) { + throw new ObsidianApiException(response.code(), + "Invalid search response: expected top-level array"); + } + JsonNode root = objectMapper.readTree(responseBody); + if (!root.isArray()) { + throw new ObsidianApiException(response.code(), + "Invalid search response: expected top-level array"); + } + return parseSearchResults(root, response.code()); + } catch (JsonProcessingException ex) { + throw new ObsidianApiException(response.code(), + "Invalid JSON response: " + firstText(ex.getOriginalMessage(), "Unexpected response")); + } + } + } + + protected Response executeRequest(Request request) throws IOException { + return buildHttpClient(getConfig()).newCall(request).execute(); + } + + private Response openResponse(Request request) { + try { + return executeRequest(request); + } catch (IOException ex) { + throw new ObsidianTransportException(transportMessage(ex), ex); + } + } + + private String readResponseBody(Response response) { + try (ResponseBody body = response.body()) { + return body != null ? body.string() : ""; + } catch (IOException ex) { + throw new ObsidianTransportException(transportMessage(ex), ex); + } + } + + private ObsidianPluginConfig getConfig() { + return configService.getConfig(); + } + + private HttpUrl buildVaultUrl(String relativePath, boolean directory) { + ObsidianPluginConfig config = getConfig(); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + HttpUrl.Builder builder = Objects.requireNonNull(baseUrl).newBuilder().addPathSegment(VAULT_SEGMENT); + for (String segment : splitPath(relativePath)) { + builder.addPathSegment(segment); + } + if (directory) { + builder.addPathSegment(""); + } + return builder.build(); + } + + private HttpUrl buildSearchUrl(String query, int contextLength) { + ObsidianPluginConfig config = getConfig(); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + return Objects.requireNonNull(baseUrl) + .newBuilder() + .addPathSegment(SEARCH_SEGMENT) + .addPathSegment(SIMPLE_SEGMENT) + .addPathSegment("") + .addQueryParameter("query", query) + .addQueryParameter("contextLength", String.valueOf(contextLength)) + .build(); + } + + private HttpUrl parseBaseUrl(String baseUrl) { + HttpUrl parsed = HttpUrl.parse(baseUrl); + if (parsed == null) { + throw new IllegalStateException("Invalid Obsidian base URL: " + baseUrl); + } + return parsed; + } + + private List splitPath(String relativePath) { + if (!hasText(relativePath)) { + return List.of(); + } + String normalized = relativePath.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (!hasText(normalized)) { + return List.of(); + } + String[] segments = normalized.split("/"); + List values = new ArrayList<>(segments.length); + for (String segment : segments) { + if (hasText(segment)) { + values.add(segment); + } + } + return values; + } + + private OkHttpClient buildHttpClient(ObsidianPluginConfig config) { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .callTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())) + .connectTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())) + .readTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())) + .writeTimeout(java.time.Duration.ofMillis(config.getTimeoutMs())); + HttpUrl baseUrl = parseBaseUrl(config.getBaseUrl()); + if (Boolean.TRUE.equals(config.getAllowInsecureTls()) && "https".equalsIgnoreCase(baseUrl.scheme())) { + try { + TrustManager[] trustManagers = new TrustManager[] { new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagers, new SecureRandom()); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + HostnameVerifier hostnameVerifier = (hostname, session) -> true; + builder.sslSocketFactory(sslSocketFactory, trustManager); + builder.hostnameVerifier(hostnameVerifier); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Unable to configure insecure TLS for Obsidian", ex); + } + } + return builder.build(); + } + + private void ensureSuccessful(Response response, String responseBody) { + if (response.isSuccessful()) { + return; + } + throw new ObsidianApiException(response.code(), extractErrorMessage(responseBody, response.code())); + } + + private String extractErrorMessage(String responseBody, int statusCode) { + if (!hasText(responseBody)) { + return "HTTP " + statusCode; + } + try { + JsonNode root = objectMapper.readTree(responseBody); + String message = firstText(root.path("message").asText(null), + root.path("error").asText(null), + root.path("status").asText(null)); + if (hasText(message)) { + return message; + } + } catch (JsonProcessingException ignored) { + // Fall through to the raw response body. + } + return responseBody.trim(); + } + + private List parseSearchResults(JsonNode arrayNode, int statusCode) { + List results = new ArrayList<>(arrayNode.size()); + for (JsonNode resultNode : arrayNode) { + JsonNode filenameNode = resultNode.get("filename"); + if (filenameNode == null || !filenameNode.isTextual() || !hasText(filenameNode.asText())) { + throw new ObsidianApiException(statusCode, "Invalid search result: missing filename"); + } + double score = resultNode.path("score").isNumber() ? resultNode.path("score").asDouble() : 0.0d; + JsonNode matchesNode = resultNode.get("matches"); + if (matchesNode == null || !matchesNode.isArray()) { + throw new ObsidianApiException(statusCode, "Invalid search result: expected matches array"); + } + results.add(new ObsidianSearchResult(filenameNode.asText(), score, parseMatches(matchesNode, statusCode))); + } + return List.copyOf(results); + } + + private List parseMatches(JsonNode matchesNode, int statusCode) { + List matches = new ArrayList<>(matchesNode.size()); + for (JsonNode matchNode : matchesNode) { + JsonNode contextNode = matchNode.get("context"); + if (contextNode == null || !contextNode.isTextual()) { + throw new ObsidianApiException(statusCode, "Invalid search match: missing context"); + } + JsonNode spanNode = matchNode.get("match"); + if (spanNode == null || !spanNode.isObject()) { + throw new ObsidianApiException(statusCode, "Invalid search match: expected match object"); + } + JsonNode startNode = spanNode.get("start"); + JsonNode endNode = spanNode.get("end"); + JsonNode sourceNode = spanNode.get("source"); + if (startNode == null || !startNode.isNumber() + || endNode == null || !endNode.isNumber()) { + throw new ObsidianApiException(statusCode, "Invalid search match: incomplete span"); + } + String source = sourceNode != null && sourceNode.isTextual() && hasText(sourceNode.asText()) + ? sourceNode.asText() + : null; + matches.add(new ObsidianSearchResult.Match( + contextNode.asText(), + new ObsidianSearchResult.MatchSpan(startNode.asInt(), endNode.asInt(), source))); + } + return List.copyOf(matches); + } + + private String authorizationHeader() { + return "Bearer " + getConfig().getApiKey(); + } + + private String transportMessage(IOException ex) { + String message = ex.getMessage(); + return hasText(message) ? "Obsidian transport failed: " + message : "Obsidian transport failed"; + } + + private String firstText(String... values) { + for (String value : values) { + if (hasText(value)) { + return value; + } + } + return ""; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java new file mode 100644 index 0000000..e8bbb85 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiException.java @@ -0,0 +1,17 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +public class ObsidianApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public ObsidianApiException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java new file mode 100644 index 0000000..0dbcb9f --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianPathValidator.java @@ -0,0 +1,80 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; + +public final class ObsidianPathValidator { + + public String normalizeDirectoryPath(String path) { + String normalized = normalizeRelativePath(path, true); + if (!normalized.isBlank() && normalized.toLowerCase(Locale.ROOT).endsWith(".md")) { + throw new IllegalArgumentException("directory paths must point to directories, not notes"); + } + return normalized; + } + + public String normalizeNotePath(String path) { + String normalized = normalizeRelativePath(path, false); + if (!normalized.toLowerCase(Locale.ROOT).endsWith(".md")) { + throw new IllegalArgumentException("Obsidian note paths must end with .md"); + } + return normalized; + } + + public String normalizeNewName(String newName) { + if (newName == null || newName.isBlank()) { + throw new IllegalArgumentException("newName is required"); + } + String normalized = newName.trim(); + if (normalized.contains("/") || normalized.contains("\\")) { + throw new IllegalArgumentException("newName must not contain path separators"); + } + if (!normalized.toLowerCase(Locale.ROOT).endsWith(".md")) { + throw new IllegalArgumentException("newName must end with .md"); + } + return normalized; + } + + public String resolveSiblingNotePath(String sourcePath, String newName) { + String normalizedSourcePath = normalizeNotePath(sourcePath); + String normalizedNewName = normalizeNewName(newName); + int separatorIndex = normalizedSourcePath.lastIndexOf('/'); + if (separatorIndex < 0) { + return normalizedNewName; + } + return normalizedSourcePath.substring(0, separatorIndex + 1) + normalizedNewName; + } + + private String normalizeRelativePath(String path, boolean allowBlank) { + if (path == null || path.isBlank()) { + if (allowBlank) { + return ""; + } + throw new IllegalArgumentException("Path is required"); + } + + String candidate = path.trim().replace('\\', '/'); + Deque segments = new ArrayDeque<>(); + for (String rawSegment : candidate.split("/")) { + String segment = rawSegment.trim(); + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path must stay within the vault root"); + } + segments.removeLast(); + continue; + } + segments.addLast(segment); + } + + String normalized = String.join("/", segments); + if (!allowBlank && normalized.isBlank()) { + throw new IllegalArgumentException("Path is required"); + } + return normalized; + } +} diff --git a/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java new file mode 100644 index 0000000..f405826 --- /dev/null +++ b/golemcore/obsidian/src/main/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianTransportException.java @@ -0,0 +1,10 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +public class ObsidianTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ObsidianTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap b/golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap new file mode 100644 index 0000000..d96479c --- /dev/null +++ b/golemcore/obsidian/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap @@ -0,0 +1 @@ +me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java new file mode 100644 index 0000000..f631b40 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +class ObsidianPluginBootstrapTest { + + @Test + void shouldDescribeObsidianPlugin() { + ObsidianPluginBootstrap bootstrap = new ObsidianPluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/obsidian", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("obsidian", descriptor.getName()); + assertEquals(ObsidianPluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java new file mode 100644 index 0000000..05e2a03 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianPluginSettingsContributorTest.java @@ -0,0 +1,145 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianTransportException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ObsidianPluginSettingsContributorTest { + + private ObsidianPluginConfigService configService; + private ObsidianApiClient apiClient; + private ObsidianPluginSettingsContributor contributor; + private ObsidianPluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(ObsidianPluginConfigService.class); + apiClient = mock(ObsidianApiClient.class); + contributor = new ObsidianPluginSettingsContributor(configService, apiClient); + config = ObsidianPluginConfig.builder().build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldExposeSectionWithBlankSecretAndSafeDefaultsFromDefaultConfig() { + PluginSettingsSection section = contributor.getSection("main"); + + assertEquals(false, section.getValues().get("enabled")); + assertEquals("http://127.0.0.1:27123", section.getValues().get("baseUrl")); + assertEquals("", section.getValues().get("apiKey")); + assertEquals(30_000, section.getValues().get("timeoutMs")); + assertEquals(false, section.getValues().get("allowInsecureTls")); + assertEquals(100, section.getValues().get("defaultSearchContextLength")); + assertEquals(12_000, section.getValues().get("maxReadChars")); + assertFalse((Boolean) section.getValues().get("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertFalse((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowRename")); + assertEquals(1, section.getActions().size()); + assertEquals("test-connection", section.getActions().getFirst().getActionId()); + assertEquals("Test Connection", section.getActions().getFirst().getLabel()); + } + + @Test + void shouldRoundTripSavedPolicyFlagsThroughGetSection() { + ObsidianPluginConfig initialConfig = ObsidianPluginConfig.builder() + .apiKey("existing-secret") + .build(); + initialConfig.normalize(); + ObsidianPluginConfig persistedConfig = ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("existing-secret") + .timeoutMs(45_000) + .allowInsecureTls(true) + .defaultSearchContextLength(120) + .maxReadChars(8_000) + .allowWrite(true) + .allowDelete(false) + .allowMove(true) + .allowRename(false) + .build(); + persistedConfig.normalize(); + when(configService.getConfig()).thenReturn(initialConfig, persistedConfig); + + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("baseUrl", "https://127.0.0.1:27124"); + values.put("apiKey", ""); + values.put("timeoutMs", 45_000); + values.put("allowInsecureTls", true); + values.put("defaultSearchContextLength", 120); + values.put("maxReadChars", 8_000); + values.put("allowWrite", true); + values.put("allowDelete", false); + values.put("allowMove", true); + values.put("allowRename", false); + + PluginSettingsSection section = contributor.saveSection("main", values); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ObsidianPluginConfig.class); + verify(configService).save(captor.capture()); + ObsidianPluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getApiKey()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertTrue(saved.getAllowMove()); + assertFalse(saved.getAllowRename()); + + assertEquals("", section.getValues().get("apiKey")); + assertTrue((Boolean) section.getValues().get("allowWrite")); + assertFalse((Boolean) section.getValues().get("allowDelete")); + assertTrue((Boolean) section.getValues().get("allowMove")); + assertFalse((Boolean) section.getValues().get("allowRename")); + } + + @Test + void shouldReturnOkWhenConnectionTestSucceeds() { + config.setApiKey("secret"); + when(apiClient.listDirectory("")).thenReturn(List.of("Inbox.md", "Projects/")); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertEquals("Connected to Obsidian. Vault root returned 2 item(s).", result.getMessage()); + verify(apiClient).listDirectory(""); + } + + @Test + void shouldReturnErrorWhenApiKeyIsMissing() { + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Obsidian API key is not configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionTestFails() { + config.setApiKey("secret"); + when(apiClient.listDirectory("")).thenThrow(new ObsidianTransportException( + "Obsidian transport failed: timeout", + new IOException("timeout"))); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: Obsidian transport failed: timeout", result.getMessage()); + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java new file mode 100644 index 0000000..158f2cc --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultServiceTest.java @@ -0,0 +1,382 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiClient; +import me.golemcore.plugins.golemcore.obsidian.support.ObsidianApiException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ObsidianVaultServiceTest { + + private ObsidianPluginConfigService configService; + private StubObsidianApiClient apiClient; + private ObsidianVaultService service; + + @BeforeEach + void setUp() { + configService = mock(ObsidianPluginConfigService.class); + when(configService.getConfig()).thenReturn(config(true, true, true, true, 12_000)); + apiClient = new StubObsidianApiClient(configService); + service = new ObsidianVaultService(apiClient, configService); + } + + @Test + void shouldListVaultRootDirectory() { + apiClient.listDirectoryResults.put("", List.of("Inbox.md", "Projects/")); + + ToolResult result = service.listDirectory(""); + + assertTrue(result.isSuccess()); + assertEquals(List.of(""), apiClient.listDirectoryCalls); + assertEquals("Listed 2 item(s) in /", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("", data.get("path")); + assertEquals(List.of("Inbox.md", "Projects/"), data.get("entries")); + } + + @Test + void shouldNormalizeNotePathsBeforeUpdating() { + apiClient.noteContents.put("Inbox.md", "# Existing"); + + ToolResult result = service.updateNote("./Projects/../Inbox.md", "# Inbox"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("Inbox.md"), apiClient.readPaths); + assertEquals(List.of("Inbox.md"), apiClient.writePaths); + assertEquals(List.of("# Inbox"), apiClient.writeContents); + } + + @Test + void shouldRejectBlankNotePathBeforeCallingApi() { + ToolResult result = service.readNote(" "); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("Path is required")); + assertTrue(apiClient.readPaths.isEmpty()); + } + + @Test + void shouldRejectNotePathsWithoutMarkdownExtensionBeforeCallingApi() { + ToolResult result = service.createNote("Projects/Todo.txt", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains(".md")); + assertTrue(apiClient.writePaths.isEmpty()); + } + + @Test + void shouldRejectDirectoryListingPathsThatPointToNotes() { + ToolResult result = service.listDirectory("Inbox.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("directory")); + assertTrue(apiClient.listDirectoryCalls.isEmpty()); + } + + @Test + void shouldDenyCreateWhenWriteIsDisabled() { + when(configService.getConfig()).thenReturn(config(false, true, true, true, 12_000)); + + ToolResult result = service.createNote("Projects/Todo.md", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("write is disabled")); + assertTrue(apiClient.writePaths.isEmpty()); + } + + @Test + void shouldRejectCreateWhenDestinationAlreadyExists() { + apiClient.noteContents.put("Projects/Todo.md", "# Existing"); + + ToolResult result = service.createNote("Projects/Todo.md", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("already exists")); + assertTrue(apiClient.writePaths.isEmpty()); + } + + @Test + void shouldDenyDeleteWhenDeleteIsDisabled() { + when(configService.getConfig()).thenReturn(config(true, false, true, true, 12_000)); + + ToolResult result = service.deleteNote("Projects/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("delete is disabled")); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldRejectDeleteWhenSourceDoesNotExist() { + ToolResult result = service.deleteNote("Projects/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("does not exist")); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldDenyMoveWhenMoveIsDisabled() { + when(configService.getConfig()).thenReturn(config(true, true, false, true, 12_000)); + + ToolResult result = service.moveNote("Projects/Todo.md", "Archive/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("move is disabled")); + assertTrue(apiClient.readPaths.isEmpty()); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldDenyRenameWhenRenameIsDisabled() { + when(configService.getConfig()).thenReturn(config(true, true, true, false, 12_000)); + + ToolResult result = service.renameNote("Projects/Todo.md", "Done.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("rename is disabled")); + assertTrue(apiClient.readPaths.isEmpty()); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldRejectUpdateWhenSourceDoesNotExist() { + ToolResult result = service.updateNote("Projects/Todo.md", "# Todo"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("does not exist")); + assertTrue(apiClient.writePaths.isEmpty()); + } + + @Test + void shouldRejectRenameWhenNewNameContainsPathSeparators() { + ToolResult result = service.renameNote("Projects/Todo.md", "Archive/Done.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("newName")); + assertTrue(apiClient.readPaths.isEmpty()); + } + + @Test + void shouldTruncateReadNoteContentAndExposeStructuredFlag() { + when(configService.getConfig()).thenReturn(config(true, true, true, true, 5)); + apiClient.noteContents.put("Inbox.md", "123456789"); + + ToolResult result = service.readNote("Inbox.md"); + + assertTrue(result.isSuccess()); + assertEquals("12345", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("Inbox.md", data.get("path")); + assertEquals("12345", data.get("content")); + assertEquals(true, data.get("truncated")); + assertEquals(9, data.get("originalLength")); + } + + @Test + void shouldSearchNotesUsingRequestedContextLength() { + apiClient.searchResults = List.of(new ObsidianSearchResult( + "Inbox.md", + 0.91d, + List.of(new ObsidianSearchResult.Match( + "Daily review notes", + new ObsidianSearchResult.MatchSpan(6, 12, "content"))))); + + ToolResult result = service.searchNotes("daily review", 42); + + assertTrue(result.isSuccess()); + assertEquals(List.of("daily review"), apiClient.searchQueries); + assertEquals(List.of(42), apiClient.searchContextLengths); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals(1, data.get("count")); + assertEquals(apiClient.searchResults, data.get("results")); + } + + @Test + void shouldDeleteValidatedNote() { + apiClient.noteContents.put("Todo.md", "# Todo"); + + ToolResult result = service.deleteNote("./Projects/../Todo.md"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("Todo.md"), apiClient.readPaths); + assertEquals(List.of("Todo.md"), apiClient.deletePaths); + } + + @Test + void shouldRejectMoveWhenTargetAlreadyExists() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + apiClient.noteContents.put("Archive/Todo.md", "# Existing"); + + ToolResult result = service.moveNote("Projects/Todo.md", "Archive/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("already exists")); + assertEquals(List.of("Projects/Todo.md", "Archive/Todo.md"), apiClient.readPaths); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldRejectMoveWhenSourceAndTargetNormalizeToSamePath() { + apiClient.noteContents.put("Inbox.md", "# Inbox"); + + ToolResult result = service.moveNote("./Inbox.md", "Inbox.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("must differ")); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + @Test + void shouldSurfacePartialFailureWhenMoveCannotDeleteSource() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + apiClient.deleteFailures.put("Projects/Todo.md", new ObsidianApiException(500, "delete failed")); + + ToolResult result = service.moveNote("Projects/Todo.md", "Archive/Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("both source and target may now exist")); + assertEquals(List.of("Projects/Todo.md", "Archive/Todo.md"), apiClient.readPaths); + assertEquals(List.of("Archive/Todo.md"), apiClient.writePaths); + assertEquals(List.of("Projects/Todo.md"), apiClient.deletePaths); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("Projects/Todo.md", data.get("path")); + assertEquals("Archive/Todo.md", data.get("target_path")); + } + + @Test + void shouldRenameByWritingSiblingTargetAndDeletingSource() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + + ToolResult result = service.renameNote("Projects/Todo.md", "Done.md"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("Projects/Todo.md", "Projects/Done.md"), apiClient.readPaths); + assertEquals(List.of("Projects/Done.md"), apiClient.writePaths); + assertEquals(List.of("Projects/Todo.md"), apiClient.deletePaths); + } + + @Test + void shouldRejectRenameWhenNewNameKeepsSamePath() { + apiClient.noteContents.put("Projects/Todo.md", "# Todo"); + + ToolResult result = service.renameNote("Projects/Todo.md", "Todo.md"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("must differ")); + assertTrue(apiClient.writePaths.isEmpty()); + assertTrue(apiClient.deletePaths.isEmpty()); + } + + private ObsidianPluginConfig config( + boolean allowWrite, + boolean allowDelete, + boolean allowMove, + boolean allowRename, + int maxReadChars) { + return ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("api-key") + .timeoutMs(30_000) + .allowInsecureTls(false) + .defaultSearchContextLength(100) + .maxReadChars(maxReadChars) + .allowWrite(allowWrite) + .allowDelete(allowDelete) + .allowMove(allowMove) + .allowRename(allowRename) + .build(); + } + + private static final class StubObsidianApiClient extends ObsidianApiClient { + + private final Map> listDirectoryResults = new HashMap<>(); + private final Map noteContents = new HashMap<>(); + private final Map deleteFailures = new HashMap<>(); + private final List listDirectoryCalls = new ArrayList<>(); + private final List readPaths = new ArrayList<>(); + private final List writePaths = new ArrayList<>(); + private final List writeContents = new ArrayList<>(); + private final List deletePaths = new ArrayList<>(); + private final List searchQueries = new ArrayList<>(); + private final List searchContextLengths = new ArrayList<>(); + private List searchResults = List.of(); + + private StubObsidianApiClient(ObsidianPluginConfigService configService) { + super(configService); + } + + @Override + public List listDirectory(String path) { + listDirectoryCalls.add(path); + return listDirectoryResults.getOrDefault(path, List.of()); + } + + @Override + public String readNote(String path) { + readPaths.add(path); + if (!noteContents.containsKey(path)) { + throw new ObsidianApiException(404, "missing note"); + } + return noteContents.get(path); + } + + @Override + public void writeNote(String path, String content) { + writePaths.add(path); + writeContents.add(content); + noteContents.put(path, content); + } + + @Override + public void deleteNote(String path) { + deletePaths.add(path); + RuntimeException failure = deleteFailures.get(path); + if (failure != null) { + throw failure; + } + noteContents.remove(path); + } + + @Override + public List simpleSearch(String query, int contextLength) { + searchQueries.add(query); + searchContextLengths.add(contextLength); + return searchResults; + } + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java new file mode 100644 index 0000000..e6a1ccb --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/ObsidianVaultToolProviderTest.java @@ -0,0 +1,219 @@ +package me.golemcore.plugins.golemcore.obsidian; + +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ObsidianVaultToolProviderTest { + + private ObsidianVaultService service; + private ObsidianVaultToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(ObsidianVaultService.class); + provider = new ObsidianVaultToolProvider(service); + } + + @Test + void shouldExposeAllSupportedOperations() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + Map properties = (Map) schema.get("properties"); + Map operation = (Map) properties.get("operation"); + + assertEquals("obsidian_vault", definition.getName()); + assertEquals(List.of( + "list_directory", + "read_note", + "search_notes", + "create_note", + "update_note", + "delete_note", + "move_note", + "rename_note"), operation.get("enum")); + } + + @Test + void shouldRequirePathForReadNoteInSchema() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + List allOf = (List) schema.get("allOf"); + Map readRule = (Map) allOf.stream() + .map(Map.class::cast) + .filter(rule -> { + Map condition = (Map) rule.get("if"); + Map conditionProperties = (Map) condition.get("properties"); + Map operationProperty = (Map) conditionProperties.get("operation"); + return "read_note".equals(operationProperty.get("const")); + }) + .findFirst() + .orElseThrow(); + Map thenClause = (Map) readRule.get("then"); + + assertEquals(List.of("path"), thenClause.get("required")); + } + + @Test + void shouldRejectMissingOperation() { + ToolResult result = provider.execute(Map.of()).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("operation is required")); + } + + @Test + void shouldDispatchRenameToVaultService() { + when(service.renameNote("Projects/Todo.md", "Done.md")) + .thenReturn(ToolResult.success("renamed")); + + ToolResult result = provider.execute(Map.of( + "operation", "rename_note", + "path", "Projects/Todo.md", + "new_name", "Done.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).renameNote("Projects/Todo.md", "Done.md"); + } + + @Test + void shouldDispatchReadNoteToVaultService() { + when(service.readNote("Projects/Todo.md")) + .thenReturn(ToolResult.success("read")); + + ToolResult result = provider.execute(Map.of( + "operation", "read_note", + "path", "Projects/Todo.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).readNote("Projects/Todo.md"); + } + + @Test + void shouldDispatchSearchWithOptionalContextLength() { + when(service.searchNotes("daily review", 42)) + .thenReturn(ToolResult.success("found")); + + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "daily review", + "context_length", 42)).join(); + + assertTrue(result.isSuccess()); + verify(service).searchNotes("daily review", 42); + } + + @Test + void shouldRejectNonIntegerContextLength() { + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "daily review", + "context_length", 42.5d)).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("context_length")); + } + + @Test + void shouldRejectStringContextLength() { + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "daily review", + "context_length", "42")).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("context_length")); + } + + @Test + void shouldDispatchListDirectoryWithoutPath() { + when(service.listDirectory(null)) + .thenReturn(ToolResult.success("listed")); + + ToolResult result = provider.execute(Map.of("operation", "list_directory")).join(); + + assertTrue(result.isSuccess()); + verify(service).listDirectory(null); + } + + @Test + void shouldDispatchCreateToVaultService() { + when(service.createNote("Projects/Todo.md", "# Todo")) + .thenReturn(ToolResult.success("created")); + + ToolResult result = provider.execute(Map.of( + "operation", "create_note", + "path", "Projects/Todo.md", + "content", "# Todo")).join(); + + assertTrue(result.isSuccess()); + verify(service).createNote("Projects/Todo.md", "# Todo"); + } + + @Test + void shouldDispatchUpdateToVaultService() { + when(service.updateNote("Projects/Todo.md", "# Updated")) + .thenReturn(ToolResult.success("updated")); + + ToolResult result = provider.execute(Map.of( + "operation", "update_note", + "path", "Projects/Todo.md", + "content", "# Updated")).join(); + + assertTrue(result.isSuccess()); + verify(service).updateNote("Projects/Todo.md", "# Updated"); + } + + @Test + void shouldDispatchDeleteToVaultService() { + when(service.deleteNote("Projects/Todo.md")) + .thenReturn(ToolResult.success("deleted")); + + ToolResult result = provider.execute(Map.of( + "operation", "delete_note", + "path", "Projects/Todo.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).deleteNote("Projects/Todo.md"); + } + + @Test + void shouldDispatchMoveToVaultService() { + when(service.moveNote("Projects/Todo.md", "Archive/Todo.md")) + .thenReturn(ToolResult.success("moved")); + + ToolResult result = provider.execute(Map.of( + "operation", "move_note", + "path", "Projects/Todo.md", + "target_path", "Archive/Todo.md")).join(); + + assertTrue(result.isSuccess()); + verify(service).moveNote("Projects/Todo.md", "Archive/Todo.md"); + } + + @Test + void shouldRejectUnsupportedOperation() { + ToolResult result = provider.execute(Map.of("operation", "unknown")).join(); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("Unsupported")); + } +} diff --git a/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java new file mode 100644 index 0000000..c2c52e2 --- /dev/null +++ b/golemcore/obsidian/src/test/java/me/golemcore/plugins/golemcore/obsidian/support/ObsidianApiClientTest.java @@ -0,0 +1,354 @@ +package me.golemcore.plugins.golemcore.obsidian.support; + +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfig; +import me.golemcore.plugins.golemcore.obsidian.ObsidianPluginConfigService; +import me.golemcore.plugins.golemcore.obsidian.model.ObsidianSearchResult; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ObsidianApiClientTest { + + private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); + + private ObsidianPluginConfigService configService; + private MockObsidianApiClient client; + + @BeforeEach + void setUp() { + configService = mock(ObsidianPluginConfigService.class); + ObsidianPluginConfig config = ObsidianPluginConfig.builder() + .enabled(true) + .baseUrl("https://127.0.0.1:27124") + .apiKey("api-key") + .timeoutMs(30_000) + .allowInsecureTls(false) + .defaultSearchContextLength(100) + .build(); + when(configService.getConfig()).thenReturn(config); + client = new MockObsidianApiClient(configService); + } + + @Test + void shouldListRootDirectoryUsingVaultRootEndpoint() throws Exception { + client.enqueueResponse(200, "{\"files\":[\"Inbox.md\",\"Projects/\"]}"); + + List files = client.listDirectory(""); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("GET", request.method()); + assertEquals("https://127.0.0.1:27124/vault/", request.url().toString()); + assertEquals("Bearer api-key", request.header("Authorization")); + assertEquals(List.of("Inbox.md", "Projects/"), files); + } + + @Test + void shouldListNestedDirectoryUsingPathSafeVaultEndpoint() throws Exception { + client.enqueueResponse(200, "{\"files\":[\"Meeting Notes.md\"]}"); + + List files = client.listDirectory("Projects/2026 Notes"); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("GET", request.method()); + assertEquals("https://127.0.0.1:27124/vault/Projects/2026%20Notes/", request.url().toString()); + assertEquals(List.of("Meeting Notes.md"), files); + } + + @Test + void shouldReadAndWriteFilesUsingPathSafeVaultEndpoints() throws Exception { + client.enqueueResponse(200, """ + { + "content": "# Inbox\\nBody", + "frontmatter": {}, + "path": "Inbox Notes.md", + "stat": { + "ctime": 1, + "mtime": 2, + "size": 11 + }, + "tags": [] + } + """); + client.enqueueResponse(204, null); + + String note = client.readNote("Inbox Notes.md"); + client.writeNote("Projects/2026 Notes.md", "Updated body"); + + assertEquals("# Inbox\nBody", note); + + Request readRequest = client.getCapturedRequests().get(0); + assertEquals("GET", readRequest.method()); + assertEquals("https://127.0.0.1:27124/vault/Inbox%20Notes.md", readRequest.url().toString()); + assertEquals("application/vnd.olrapi.note+json", readRequest.header("Accept")); + + Request writeRequest = client.getCapturedRequests().get(1); + assertEquals("PUT", writeRequest.method()); + assertEquals("https://127.0.0.1:27124/vault/Projects/2026%20Notes.md", writeRequest.url().toString()); + assertEquals("text/markdown; charset=utf-8", writeRequest.header("Content-Type")); + assertEquals("Updated body", readRequestBody(writeRequest)); + } + + @Test + void shouldFailFastWhenSuccessfulReadNoteResponseIsMissingContent() { + client.enqueueResponse(200, """ + { + "path": "Inbox Notes.md", + "stat": { + "ctime": 1, + "mtime": 2, + "size": 11 + } + } + """); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox Notes.md")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("content")); + } + + @Test + void shouldFailFastWhenSuccessfulReadNoteResponseIsMalformed() { + client.enqueueResponse(200, "{not-json"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox Notes.md")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("JSON")); + } + + @Test + void shouldFailFastWhenSuccessfulReadNoteResponseIsBlank() { + client.enqueueResponse(200, ""); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox Notes.md")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("content")); + } + + @Test + void shouldMapUnauthorizedResponsesToAuthFailures() { + client.enqueueResponse(401, "{\"message\":\"unauthorized\"}"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.readNote("Inbox.md")); + + assertEquals(401, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("unauthorized")); + } + + @Test + void shouldWrapTransportFailuresWithoutSyntheticHttpStatus() { + client.enqueueFailure(new IOException("connect timed out")); + + ObsidianTransportException exception = assertThrows(ObsidianTransportException.class, + () -> client.readNote("Inbox.md")); + + assertTrue(exception.getMessage().contains("connect timed out")); + assertInstanceOf(IOException.class, exception.getCause()); + } + + @Test + void shouldSearchSimpleUsingTopLevelArrayAndNestedMatchContract() throws Exception { + client.enqueueResponse(200, """ + [ + { + "filename": "Inbox.md", + "score": 0.98, + "matches": [ + { + "context": "Daily review notes", + "match": { + "start": 6, + "end": 12, + "source": "content" + } + } + ] + } + ] + """); + + List results = client.simpleSearch("daily review", 42); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("POST", request.method()); + assertEquals("https://127.0.0.1:27124/search/simple/?query=daily%20review&contextLength=42", + request.url().toString()); + assertEquals(List.of("Inbox.md"), results.stream().map(ObsidianSearchResult::getFilename).toList()); + assertEquals(0.98, results.getFirst().getScore()); + assertEquals("Daily review notes", results.getFirst().getMatches().getFirst().getContext()); + assertEquals(6, results.getFirst().getMatches().getFirst().getMatch().getStart()); + assertEquals(12, results.getFirst().getMatches().getFirst().getMatch().getEnd()); + assertEquals("content", results.getFirst().getMatches().getFirst().getMatch().getSource()); + } + + @Test + void shouldAllowSearchMatchesWithoutOptionalSource() throws Exception { + client.enqueueResponse(200, """ + [ + { + "filename": "Inbox.md", + "score": 0.91, + "matches": [ + { + "context": "Daily review notes", + "match": { + "start": 6, + "end": 12 + } + } + ] + } + ] + """); + + List results = client.simpleSearch("daily review", 42); + + assertEquals("Inbox.md", results.getFirst().getFilename()); + assertEquals(0.91, results.getFirst().getScore()); + assertEquals("Daily review notes", results.getFirst().getMatches().getFirst().getContext()); + assertEquals(6, results.getFirst().getMatches().getFirst().getMatch().getStart()); + assertEquals(12, results.getFirst().getMatches().getFirst().getMatch().getEnd()); + assertEquals(null, results.getFirst().getMatches().getFirst().getMatch().getSource()); + } + + @Test + void shouldFailFastWhenSuccessfulSearchResponseUsesMissingFilename() { + client.enqueueResponse(200, """ + [ + { + "path": "Inbox.md", + "score": 0.98, + "matches": [ + { + "context": "Daily review notes", + "match": { + "start": 6, + "end": 12, + "source": "content" + } + } + ] + } + ] + """); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.simpleSearch("daily review", 42)); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("filename")); + } + + @Test + void shouldFailFastWhenSuccessfulListResponseHasWrongShape() { + client.enqueueResponse(200, "{\"files\":null}"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.listDirectory("")); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("Invalid")); + } + + @Test + void shouldFailFastWhenSuccessfulSearchResponseHasWrongShape() { + client.enqueueResponse(200, "{\"results\":[]}"); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.simpleSearch("daily review", 42)); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("array")); + } + + @Test + void shouldFailFastWhenSuccessfulSearchResponseIsBlank() { + client.enqueueResponse(200, ""); + + ObsidianApiException exception = assertThrows(ObsidianApiException.class, + () -> client.simpleSearch("daily review", 42)); + + assertEquals(200, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("array")); + } + + private String readRequestBody(Request request) throws IOException { + RequestBody body = request.body(); + if (body == null) { + return ""; + } + try (Buffer buffer = new Buffer()) { + body.writeTo(buffer); + return buffer.readUtf8(); + } + } + + private static final class MockObsidianApiClient extends ObsidianApiClient { + + private final Queue plannedResponses = new ArrayDeque<>(); + private final List capturedRequests = new ArrayList<>(); + + private MockObsidianApiClient(ObsidianPluginConfigService configService) { + super(configService); + } + + @Override + protected Response executeRequest(Request request) throws IOException { + capturedRequests.add(request); + PlannedResponse plannedResponse = plannedResponses.remove(); + if (plannedResponse.failure() != null) { + throw plannedResponse.failure(); + } + ResponseBody responseBody = ResponseBody.create( + plannedResponse.body() == null ? "" : plannedResponse.body(), + plannedResponse.mediaType()); + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(plannedResponse.code()) + .message("mock") + .body(responseBody) + .build(); + } + + private void enqueueResponse(int code, String body) { + plannedResponses.add(new PlannedResponse(code, body, APPLICATION_JSON, null)); + } + + private void enqueueFailure(IOException failure) { + plannedResponses.add(new PlannedResponse(0, null, null, failure)); + } + + private List getCapturedRequests() { + return capturedRequests; + } + } + + private record PlannedResponse(int code, String body, MediaType mediaType, IOException failure) { + } +} diff --git a/pom.xml b/pom.xml index 8011988..8a49ce9 100644 --- a/pom.xml +++ b/pom.xml @@ -75,6 +75,7 @@ golemcore/pinchtab golemcore/brave-search golemcore/browserless + golemcore/obsidian golemcore/tavily-search golemcore/firecrawl golemcore/perplexity-sonar diff --git a/registry/golemcore/obsidian/index.yaml b/registry/golemcore/obsidian/index.yaml new file mode 100644 index 0000000..ccc07d6 --- /dev/null +++ b/registry/golemcore/obsidian/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/obsidian +owner: golemcore +name: obsidian +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian" diff --git a/registry/golemcore/obsidian/versions/1.0.0.yaml b/registry/golemcore/obsidian/versions/1.0.0.yaml new file mode 100644 index 0000000..792ab23 --- /dev/null +++ b/registry/golemcore/obsidian/versions/1.0.0.yaml @@ -0,0 +1,13 @@ +id: golemcore/obsidian +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/obsidian/1.0.0/golemcore-obsidian-plugin-1.0.0.jar" +checksumSha256: "312a55b766c11fe8a957b8843af58f08a64dde9051d4aba2c781bded7b84b143" +publishedAt: "2026-03-29T19:04:32Z" +sourceCommit: "752eaace0dd4747ff1e4ab729a8912e36adf7079" +entrypoint: me.golemcore.plugins.golemcore.obsidian.ObsidianPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/obsidian" +license: "Apache-2.0" +maintainers: + - alexk-dev diff --git a/runtime-api/pom.xml b/runtime-api/pom.xml index afc146b..c1a2643 100644 --- a/runtime-api/pom.xml +++ b/runtime-api/pom.xml @@ -38,6 +38,13 @@ net.revelc.code.formatter formatter-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + false + +