diff --git a/extension-api/pom.xml b/extension-api/pom.xml index d115b76..bfa2bd1 100644 --- a/extension-api/pom.xml +++ b/extension-api/pom.xml @@ -11,7 +11,7 @@ ../pom.xml - 1.1.0 + 1.2.0 golemcore-plugin-extension-api golemcore-plugin-extension-api Compile-time plugin API and shared contracts for GolemCore plugins diff --git a/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagCorpusRef.java b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagCorpusRef.java new file mode 100644 index 0000000..2f7faf6 --- /dev/null +++ b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagCorpusRef.java @@ -0,0 +1,21 @@ +package me.golemcore.plugin.api.extension.model.rag; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +public record RagCorpusRef(String corpusId,String displayName){} diff --git a/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagDocument.java b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagDocument.java new file mode 100644 index 0000000..74b6c86 --- /dev/null +++ b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagDocument.java @@ -0,0 +1,25 @@ +package me.golemcore.plugin.api.extension.model.rag; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +import java.util.Map; + +public record RagDocument(String documentId,String title,String path,String content,String url,Mapmetadata){ + +public RagDocument{metadata=metadata==null?Map.of():Map.copyOf(metadata);}} diff --git a/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionCapabilities.java b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionCapabilities.java new file mode 100644 index 0000000..fcb7821 --- /dev/null +++ b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionCapabilities.java @@ -0,0 +1,21 @@ +package me.golemcore.plugin.api.extension.model.rag; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +public record RagIngestionCapabilities(boolean supportsDelete,boolean supportsReset,boolean supportsStatus,int maxBatchSize){} diff --git a/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionResult.java b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionResult.java new file mode 100644 index 0000000..ab1bcb5 --- /dev/null +++ b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionResult.java @@ -0,0 +1,21 @@ +package me.golemcore.plugin.api.extension.model.rag; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +public record RagIngestionResult(String status,int acceptedDocuments,int rejectedDocuments,String providerJobId,String message){} diff --git a/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionStatus.java b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionStatus.java new file mode 100644 index 0000000..0e61580 --- /dev/null +++ b/extension-api/src/main/java/me/golemcore/plugin/api/extension/model/rag/RagIngestionStatus.java @@ -0,0 +1,21 @@ +package me.golemcore.plugin.api.extension.model.rag; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +public record RagIngestionStatus(String status,String message,int pendingDocuments,int processedDocuments,int failedDocuments,String lastUpdatedAt){} diff --git a/extension-api/src/main/java/me/golemcore/plugin/api/extension/spi/RagIngestionProvider.java b/extension-api/src/main/java/me/golemcore/plugin/api/extension/spi/RagIngestionProvider.java new file mode 100644 index 0000000..e835ff1 --- /dev/null +++ b/extension-api/src/main/java/me/golemcore/plugin/api/extension/spi/RagIngestionProvider.java @@ -0,0 +1,45 @@ +package me.golemcore.plugin.api.extension.spi; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +import me.golemcore.plugin.api.extension.model.rag.RagCorpusRef; +import me.golemcore.plugin.api.extension.model.rag.RagDocument; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionCapabilities; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionResult; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionStatus; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface RagIngestionProvider { + + String getProviderId(); + + boolean isAvailable(); + + RagIngestionCapabilities getCapabilities(); + + CompletableFuture upsertDocuments(RagCorpusRef corpus, List documents); + + CompletableFuture deleteDocuments(RagCorpusRef corpus, List documentIds); + + CompletableFuture resetCorpus(RagCorpusRef corpus); + + CompletableFuture getStatus(RagCorpusRef corpus); +} diff --git a/golemcore/lightrag/plugin.yaml b/golemcore/lightrag/plugin.yaml index 8c0cd07..5c372f6 100644 --- a/golemcore/lightrag/plugin.yaml +++ b/golemcore/lightrag/plugin.yaml @@ -1,12 +1,12 @@ id: golemcore/lightrag provider: golemcore name: lightrag -version: 1.0.0 +version: 1.1.0 pluginApiVersion: 1 engineVersion: ">=0.0.0 <1.0.0" entrypoint: me.golemcore.plugins.golemcore.lightrag.LightRagPluginBootstrap -description: LightRAG-backed retrieval provider plugin for prompt augmentation and indexing. -sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/lightrag -license: Apache-2.0 +description: "LightRAG-backed retrieval provider plugin for prompt augmentation and indexing." +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/lightrag" +license: "Apache-2.0" maintainers: - alexk-dev diff --git a/golemcore/lightrag/pom.xml b/golemcore/lightrag/pom.xml index ea56657..c87d900 100644 --- a/golemcore/lightrag/pom.xml +++ b/golemcore/lightrag/pom.xml @@ -1,7 +1,5 @@ - - + + 4.0.0 @@ -11,7 +9,7 @@ ../../pom.xml - 1.0.0 + 1.1.0 golemcore-lightrag-plugin golemcore/lightrag LightRAG provider plugin for GolemCore @@ -51,6 +49,11 @@ okhttp-jvm ${okhttp.version} + + org.springframework.boot + spring-boot-starter-test + test + @@ -83,9 +86,7 @@ - + @@ -96,4 +97,4 @@ - + \ No newline at end of file diff --git a/golemcore/lightrag/src/main/java/me/golemcore/plugins/golemcore/lightrag/LightRagIngestionProvider.java b/golemcore/lightrag/src/main/java/me/golemcore/plugins/golemcore/lightrag/LightRagIngestionProvider.java new file mode 100644 index 0000000..959321d --- /dev/null +++ b/golemcore/lightrag/src/main/java/me/golemcore/plugins/golemcore/lightrag/LightRagIngestionProvider.java @@ -0,0 +1,190 @@ +package me.golemcore.plugins.golemcore.lightrag; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugin.api.extension.model.rag.RagCorpusRef; +import me.golemcore.plugin.api.extension.model.rag.RagDocument; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionCapabilities; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionResult; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionStatus; +import me.golemcore.plugin.api.extension.spi.RagIngestionProvider; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Component +public class LightRagIngestionProvider implements RagIngestionProvider { + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final RagIngestionCapabilities CAPABILITIES = new RagIngestionCapabilities(false, false, false, 32); + + private final LightRagPluginConfigService configService; + private final ObjectMapper objectMapper; + + public LightRagIngestionProvider(LightRagPluginConfigService configService) { + this.configService = configService; + this.objectMapper = new ObjectMapper(); + } + + @Override + public String getProviderId() { + return LightRagPluginConfigService.PLUGIN_ID; + } + + @Override + public boolean isAvailable() { + LightRagPluginConfig config = configService.getConfig(); + return Boolean.TRUE.equals(config.getEnabled()) && config.getUrl() != null && !config.getUrl().isBlank(); + } + + @Override + public RagIngestionCapabilities getCapabilities() { + return CAPABILITIES; + } + + @Override + public CompletableFuture upsertDocuments(RagCorpusRef corpus, List documents) { + return CompletableFuture.supplyAsync(() -> doUpsert(corpus, documents)); + } + + @Override + public CompletableFuture deleteDocuments(RagCorpusRef corpus, List documentIds) { + return CompletableFuture.completedFuture(new RagIngestionResult( + "failed", + 0, + documentIds != null ? documentIds.size() : 0, + null, + "LightRAG ingestion does not support deleting documents via plugin API.")); + } + + @Override + public CompletableFuture resetCorpus(RagCorpusRef corpus) { + return CompletableFuture.completedFuture(new RagIngestionResult( + "failed", + 0, + 0, + null, + "LightRAG ingestion does not support corpus reset via plugin API.")); + } + + @Override + public CompletableFuture getStatus(RagCorpusRef corpus) { + return CompletableFuture.completedFuture(new RagIngestionStatus( + "unknown", + "LightRAG ingestion status is not exposed via plugin API.", + 0, + 0, + 0, + null)); + } + + private RagIngestionResult doUpsert(RagCorpusRef corpus, List documents) { + List normalizedDocuments = documents != null ? documents : List.of(); + if (!isAvailable()) { + return new RagIngestionResult( + "failed", + 0, + normalizedDocuments.size(), + null, + "LightRAG is not enabled or URL is not configured."); + } + if (normalizedDocuments.isEmpty()) { + return new RagIngestionResult("accepted", 0, 0, null, "No documents to ingest."); + } + + int accepted = 0; + int rejected = 0; + String lastJobId = null; + for (RagDocument document : normalizedDocuments) { + if (!hasText(document.content())) { + rejected++; + continue; + } + try { + lastJobId = ingestDocument(corpus, document); + accepted++; + } catch (IOException | IllegalStateException ex) { + rejected++; + } + } + String status = rejected == 0 ? "accepted" : accepted == 0 ? "failed" : "partial"; + String message = rejected == 0 + ? "LightRAG accepted all documents for ingestion." + : "LightRAG accepted " + accepted + " document(s) and rejected " + rejected + "."; + return new RagIngestionResult(status, accepted, rejected, lastJobId, message); + } + + private String ingestDocument(RagCorpusRef corpus, RagDocument document) throws IOException { + LightRagPluginConfig config = configService.getConfig(); + String url = stripTrailingSlash(config.getUrl()) + "/documents/text"; + String payload = objectMapper.writeValueAsString(Map.of( + "text", document.content(), + "file_source", fileSource(corpus, document))); + Request.Builder requestBuilder = new Request.Builder() + .url(url) + .post(RequestBody.create(payload, JSON)); + addApiKeyHeader(requestBuilder, config); + try (Response response = client(config).newCall(requestBuilder.build()).execute(); + ResponseBody responseBody = response.body()) { + String rawBody = responseBody.string(); + if (!response.isSuccessful()) { + throw new IllegalStateException("LightRAG ingestion failed with status " + response.code()); + } + return parseTrackId(rawBody); + } + } + + private String parseTrackId(String rawBody) { + if (rawBody == null || rawBody.isBlank()) { + return null; + } + try { + return objectMapper.readTree(rawBody).path("track_id").asText(null); + } catch (JsonProcessingException ignored) { + return null; + } + } + + private String fileSource(RagCorpusRef corpus, RagDocument document) { + String corpusPrefix = corpus != null && hasText(corpus.corpusId()) ? corpus.corpusId().trim() + "/" : ""; + if (hasText(document.path())) { + return corpusPrefix + document.path().trim(); + } + if (hasText(document.title())) { + return corpusPrefix + document.title().trim(); + } + return corpusPrefix + document.documentId(); + } + + private OkHttpClient client(LightRagPluginConfig config) { + int timeoutSeconds = config.getTimeoutSeconds(); + return new OkHttpClient.Builder() + .callTimeout(timeoutSeconds, TimeUnit.SECONDS) + .readTimeout(timeoutSeconds, TimeUnit.SECONDS) + .build(); + } + + private void addApiKeyHeader(Request.Builder builder, LightRagPluginConfig config) { + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + builder.header("Authorization", "Bearer " + config.getApiKey()); + } + } + + private String stripTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/lightrag/src/test/java/me/golemcore/plugins/golemcore/lightrag/LightRagIngestionProviderTest.java b/golemcore/lightrag/src/test/java/me/golemcore/plugins/golemcore/lightrag/LightRagIngestionProviderTest.java new file mode 100644 index 0000000..29b7b25 --- /dev/null +++ b/golemcore/lightrag/src/test/java/me/golemcore/plugins/golemcore/lightrag/LightRagIngestionProviderTest.java @@ -0,0 +1,68 @@ +package me.golemcore.plugins.golemcore.lightrag; + +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.when; + +import me.golemcore.plugin.api.extension.model.rag.RagCorpusRef; +import me.golemcore.plugin.api.extension.model.rag.RagDocument; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionCapabilities; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class LightRagIngestionProviderTest { + + private LightRagPluginConfigService configService; + private LightRagIngestionProvider provider; + + @BeforeEach + void setUp() { + configService = mock(LightRagPluginConfigService.class); + provider = new LightRagIngestionProvider(configService); + } + + @Test + void shouldExposeCapabilitiesAndAvailability() { + when(configService.getConfig()).thenReturn(LightRagPluginConfig.builder() + .enabled(true) + .url("http://localhost:9621") + .build()); + + RagIngestionCapabilities capabilities = provider.getCapabilities(); + + assertEquals("golemcore/lightrag", provider.getProviderId()); + assertTrue(provider.isAvailable()); + assertFalse(capabilities.supportsDelete()); + assertFalse(capabilities.supportsReset()); + assertFalse(capabilities.supportsStatus()); + assertEquals(32, capabilities.maxBatchSize()); + } + + @Test + void shouldReturnAcceptedResultWhenTargetIsDisabled() { + when(configService.getConfig()).thenReturn(LightRagPluginConfig.builder() + .enabled(false) + .url("") + .build()); + + RagIngestionResult result = provider.upsertDocuments( + new RagCorpusRef("notion", "Notion"), + List.of(new RagDocument( + "doc-1", + "Projects/Todo", + "Projects/Todo", + "# Todo", + "https://notion.so/doc-1", + java.util.Map.of("source", "notion")))) + .join(); + + assertEquals("failed", result.status()); + assertEquals(0, result.acceptedDocuments()); + assertEquals(1, result.rejectedDocuments()); + } +} diff --git a/golemcore/notion/plugin.yaml b/golemcore/notion/plugin.yaml new file mode 100644 index 0000000..7bd0685 --- /dev/null +++ b/golemcore/notion/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/notion +provider: golemcore +name: notion +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.notion.NotionPluginBootstrap +description: Notion vault plugin backed by the official Notion HTTP API. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/notion +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/notion/pom.xml b/golemcore/notion/pom.xml new file mode 100644 index 0000000..5f615d3 --- /dev/null +++ b/golemcore/notion/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-notion-plugin + golemcore/notion + Notion vault plugin for GolemCore + + + golemcore + notion + ../../misc/formatter_eclipse.xml + 3.50.3.0 + + + + + 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.xerial + sqlite-jdbc + ${sqlite.jdbc.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/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginBootstrap.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginBootstrap.java new file mode 100644 index 0000000..dac12c1 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.notion; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class NotionPluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/notion") + .provider("golemcore") + .name("notion") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return NotionPluginConfiguration.class; + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfig.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfig.java new file mode 100644 index 0000000..7f5149b --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfig.java @@ -0,0 +1,121 @@ +package me.golemcore.plugins.golemcore.notion; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotionPluginConfig { + + static final String DEFAULT_BASE_URL = "https://api.notion.com"; + static final String DEFAULT_API_VERSION = "2026-03-11"; + static final int DEFAULT_TIMEOUT_MS = 30_000; + static final int DEFAULT_MAX_READ_CHARS = 12_000; + static final String DEFAULT_REINDEX_SCHEDULE_PRESET = "disabled"; + static final String DEFAULT_RAG_CORPUS_ID = "notion"; + + @Builder.Default + private Boolean enabled = false; + + @Builder.Default + private String baseUrl = DEFAULT_BASE_URL; + + @Builder.Default + private String apiVersion = DEFAULT_API_VERSION; + + private String apiKey; + private String rootPageId; + + @Builder.Default + private Integer timeoutMs = DEFAULT_TIMEOUT_MS; + + @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; + + @Builder.Default + private Boolean localIndexEnabled = false; + + @Builder.Default + private String reindexSchedulePreset = DEFAULT_REINDEX_SCHEDULE_PRESET; + + @Builder.Default + private String reindexCronExpression = ""; + + @Builder.Default + private Boolean ragSyncEnabled = false; + + @Builder.Default + private String targetRagProviderId = ""; + + @Builder.Default + private String ragCorpusId = DEFAULT_RAG_CORPUS_ID; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + baseUrl = normalizeUrl(baseUrl, DEFAULT_BASE_URL); + apiVersion = normalizeText(apiVersion, DEFAULT_API_VERSION); + rootPageId = normalizeText(rootPageId, ""); + if (timeoutMs == null || timeoutMs <= 0) { + timeoutMs = DEFAULT_TIMEOUT_MS; + } + 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; + } + if (localIndexEnabled == null) { + localIndexEnabled = false; + } + reindexSchedulePreset = normalizeText(reindexSchedulePreset, DEFAULT_REINDEX_SCHEDULE_PRESET); + reindexCronExpression = normalizeText(reindexCronExpression, ""); + if (ragSyncEnabled == null) { + ragSyncEnabled = false; + } + targetRagProviderId = normalizeText(targetRagProviderId, ""); + ragCorpusId = normalizeText(ragCorpusId, DEFAULT_RAG_CORPUS_ID); + } + + private String normalizeUrl(String value, String defaultValue) { + String trimmed = normalizeText(value, defaultValue); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isBlank() ? defaultValue : trimmed; + } + + private String normalizeText(String value, String defaultValue) { + if (value == null) { + return defaultValue; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? defaultValue : trimmed; + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfigService.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfigService.java new file mode 100644 index 0000000..dffa2bf --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfigService.java @@ -0,0 +1,33 @@ +package me.golemcore.plugins.golemcore.notion; + +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 NotionPluginConfigService { + + static final String PLUGIN_ID = "golemcore/notion"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public NotionPluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + NotionPluginConfig config = raw.isEmpty() + ? NotionPluginConfig.builder().build() + : objectMapper.convertValue(raw, NotionPluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(NotionPluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfiguration.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfiguration.java new file mode 100644 index 0000000..6c348c8 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfiguration.java @@ -0,0 +1,9 @@ +package me.golemcore.plugins.golemcore.notion; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = NotionPluginConfiguration.class) +public class NotionPluginConfiguration { +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginSettingsContributor.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginSettingsContributor.java new file mode 100644 index 0000000..26eb9cb --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionPluginSettingsContributor.java @@ -0,0 +1,333 @@ +package me.golemcore.plugins.golemcore.notion; + +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.PluginSettingsFieldOption; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugin.api.runtime.RagIngestionService; +import me.golemcore.plugin.api.runtime.model.RagIngestionTargetDescriptor; +import me.golemcore.plugins.golemcore.notion.support.NotionApiClient; +import me.golemcore.plugins.golemcore.notion.support.NotionApiException; +import me.golemcore.plugins.golemcore.notion.support.NotionReindexCoordinator; +import me.golemcore.plugins.golemcore.notion.support.NotionReindexSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionTransportException; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class NotionPluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; + private static final String ACTION_REINDEX_NOW = "reindex-now"; + + private final NotionPluginConfigService configService; + private final RagIngestionService ragIngestionService; + private final NotionApiClient apiClient; + private final NotionReindexCoordinator reindexCoordinator; + + @Override + public String getPluginId() { + return NotionPluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(NotionPluginConfigService.PLUGIN_ID) + .pluginName("notion") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("Notion") + .description("Notion vault connection, local search, and external RAG sync behavior.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(38) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + NotionPluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("baseUrl", config.getBaseUrl()); + values.put("apiVersion", config.getApiVersion()); + values.put("apiKey", ""); + values.put("rootPageId", config.getRootPageId()); + values.put("timeoutMs", config.getTimeoutMs()); + 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())); + values.put("localIndexEnabled", Boolean.TRUE.equals(config.getLocalIndexEnabled())); + values.put("reindexSchedulePreset", config.getReindexSchedulePreset()); + values.put("reindexCronExpression", config.getReindexCronExpression()); + values.put("ragSyncEnabled", Boolean.TRUE.equals(config.getRagSyncEnabled())); + values.put("targetRagProviderId", config.getTargetRagProviderId()); + values.put("ragCorpusId", config.getRagCorpusId()); + + return PluginSettingsSection.builder() + .title("Notion") + .description("Configure Notion vault access, local full-text indexing, and optional external RAG sync.") + .fields(List.of( + booleanField("enabled", "Enable Notion", + "Allow tools to use the Notion vault integration."), + textField("baseUrl", "Base URL", "Notion API base URL.", "https://api.notion.com"), + textField("apiVersion", "API Version", "Notion-Version header used for requests.", + NotionPluginConfig.DEFAULT_API_VERSION), + PluginSettingsField.builder() + .key("apiKey") + .type("secret") + .label("API Key") + .description("Leave blank to keep the current secret.") + .placeholder("secret_...") + .build(), + textField("rootPageId", "Root Page ID", + "Page ID that acts as the pseudo-vault root for traversal and CRUD.", + "page-id"), + numberField("timeoutMs", "Request Timeout (ms)", + "Timeout for Notion API requests.", 1_000.0, 300_000.0, 1_000.0), + numberField("maxReadChars", "Max Read Chars", + "Maximum number of characters returned when reading a page.", 1.0, null, 1.0), + booleanField("allowWrite", "Allow Write", + "Permit tools to create or update pages."), + booleanField("allowDelete", "Allow Delete", + "Permit tools to archive pages."), + booleanField("allowMove", "Allow Move", + "Permit tools to move pages to a new parent."), + booleanField("allowRename", "Allow Rename", + "Permit tools to rename pages."), + booleanField("localIndexEnabled", "Enable Local Index", + "Maintain a local full-text index for search_notes."), + PluginSettingsField.builder() + .key("reindexSchedulePreset") + .type("select") + .label("Reindex Schedule") + .description("Choose a friendly preset or switch to custom cron.") + .options(List.of( + option("disabled", "Disabled"), + option("hourly", "Every hour"), + option("every_6_hours", "Every 6 hours"), + option("daily", "Daily"), + option("weekly", "Weekly"), + option("custom", "Custom cron"))) + .build(), + textField("reindexCronExpression", "Custom Cron Expression", + "Used only when Reindex Schedule is set to Custom.", "0 0 * * * *"), + booleanField("ragSyncEnabled", "Enable External RAG Sync", + "Push Notion documents to a compatible external RAG target."), + PluginSettingsField.builder() + .key("targetRagProviderId") + .type("select") + .label("Target RAG Provider") + .description("Installed compatible RAG provider used as the sync target.") + .options(ragIngestionService.listInstalledTargets().stream() + .map(this::providerOption) + .toList()) + .build(), + textField("ragCorpusId", "RAG Corpus ID", + "Stable corpus or namespace used when syncing Notion documents.", "notion"))) + .values(values) + .actions(List.of(PluginSettingsAction.builder() + .actionId(ACTION_TEST_CONNECTION) + .label("Test Connection") + .variant("secondary") + .build(), + PluginSettingsAction.builder() + .actionId(ACTION_REINDEX_NOW) + .label("Reindex Now") + .variant("secondary") + .build())) + .build(); + } + + @Override + public PluginSettingsSection saveSection(String sectionKey, Map values) { + requireSection(sectionKey); + NotionPluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setBaseUrl(readString(values, "baseUrl", config.getBaseUrl())); + config.setApiVersion(readString(values, "apiVersion", config.getApiVersion())); + String apiKey = readString(values, "apiKey", null); + if (apiKey != null && !apiKey.isBlank()) { + config.setApiKey(apiKey); + } + config.setRootPageId(readString(values, "rootPageId", config.getRootPageId())); + config.setTimeoutMs(readInteger(values, "timeoutMs", config.getTimeoutMs())); + 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)); + config.setLocalIndexEnabled(readBoolean(values, "localIndexEnabled", false)); + config.setReindexSchedulePreset(readString(values, "reindexSchedulePreset", config.getReindexSchedulePreset())); + config.setReindexCronExpression(readString(values, "reindexCronExpression", + config.getReindexCronExpression())); + config.setRagSyncEnabled(readBoolean(values, "ragSyncEnabled", false)); + config.setTargetRagProviderId(readString(values, "targetRagProviderId", + config.getTargetRagProviderId())); + config.setRagCorpusId(readString(values, "ragCorpusId", config.getRagCorpusId())); + configService.save(config); + reindexCoordinator.refreshSchedule(); + return getSection(sectionKey); + } + + @Override + public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { + requireSection(sectionKey); + if (ACTION_TEST_CONNECTION.equals(actionId)) { + return testConnection(); + } + if (ACTION_REINDEX_NOW.equals(actionId)) { + return reindexNow(); + } + throw new IllegalArgumentException("Unknown Notion action: " + actionId); + } + + private PluginActionResult testConnection() { + NotionPluginConfig config = configService.getConfig(); + if (!hasText(config.getApiKey())) { + return PluginActionResult.builder() + .status("error") + .message("Notion API key is not configured.") + .build(); + } + if (!hasText(config.getRootPageId())) { + return PluginActionResult.builder() + .status("error") + .message("Root page ID is not configured.") + .build(); + } + try { + String title = apiClient.retrievePageTitle(config.getRootPageId()); + return PluginActionResult.builder() + .status("ok") + .message("Connected to Notion. Root page: " + title + ".") + .build(); + } catch (IllegalArgumentException | IllegalStateException | NotionApiException | NotionTransportException ex) { + return PluginActionResult.builder() + .status("error") + .message("Connection failed: " + ex.getMessage()) + .build(); + } + } + + private PluginActionResult reindexNow() { + try { + NotionReindexSummary summary = reindexCoordinator.reindexNow(); + return PluginActionResult.builder() + .status("ok") + .message("Reindex completed. Indexed " + summary.pagesIndexed() + + " page(s), " + summary.chunksIndexed() + " chunk(s), and synced " + + summary.documentsSynced() + " document(s).") + .build(); + } catch (IllegalArgumentException | IllegalStateException ex) { + return PluginActionResult.builder() + .status("error") + .message("Reindex failed: " + ex.getMessage()) + .build(); + } + } + + private void requireSection(String sectionKey) { + if (!SECTION_KEY.equals(sectionKey)) { + throw new IllegalArgumentException("Unknown Notion settings section: " + sectionKey); + } + } + + private PluginSettingsField booleanField(String key, String label, String description) { + return PluginSettingsField.builder() + .key(key) + .type("boolean") + .label(label) + .description(description) + .build(); + } + + private PluginSettingsField textField(String key, String label, String description, String placeholder) { + return PluginSettingsField.builder() + .key(key) + .type("text") + .label(label) + .description(description) + .placeholder(placeholder) + .build(); + } + + private PluginSettingsField numberField( + String key, + String label, + String description, + Double min, + Double max, + Double step) { + return PluginSettingsField.builder() + .key(key) + .type("number") + .label(label) + .description(description) + .min(min) + .max(max) + .step(step) + .build(); + } + + private PluginSettingsFieldOption option(String value, String label) { + return PluginSettingsFieldOption.builder().value(value).label(label).build(); + } + + private PluginSettingsFieldOption providerOption(RagIngestionTargetDescriptor descriptor) { + String label = descriptor.displayName() != null && !descriptor.displayName().isBlank() + ? descriptor.displayName() + : descriptor.providerId(); + return PluginSettingsFieldOption.builder() + .value(descriptor.providerId()) + .label(label) + .description(descriptor.pluginId()) + .build(); + } + + 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/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionVaultService.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionVaultService.java new file mode 100644 index 0000000..f581a59 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionVaultService.java @@ -0,0 +1,334 @@ +package me.golemcore.plugins.golemcore.notion; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.notion.support.NotionApiClient; +import me.golemcore.plugins.golemcore.notion.support.NotionApiException; +import me.golemcore.plugins.golemcore.notion.support.NotionLocalIndexService; +import me.golemcore.plugins.golemcore.notion.support.NotionPageSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionPathValidator; +import me.golemcore.plugins.golemcore.notion.support.NotionSearchHit; +import me.golemcore.plugins.golemcore.notion.support.NotionTransportException; +import me.golemcore.plugins.golemcore.notion.support.NotionRagSyncService; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class NotionVaultService { + + private final NotionApiClient apiClient; + private final NotionPluginConfigService configService; + private final NotionLocalIndexService localIndexService; + private final NotionRagSyncService ragSyncService; + private final NotionPathValidator pathValidator = new NotionPathValidator(); + + public NotionVaultService( + NotionApiClient apiClient, + NotionPluginConfigService configService, + NotionLocalIndexService localIndexService, + NotionRagSyncService ragSyncService) { + this.apiClient = apiClient; + this.configService = configService; + this.localIndexService = localIndexService; + this.ragSyncService = ragSyncService; + } + + public ToolResult listDirectory(String path) { + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + ResolvedPage page = resolveExistingPage(normalizedPath); + List entries = apiClient.listChildPages(page.pageId()).stream() + .map(NotionPageSummary::title) + .toList(); + 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 | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readNote(String path) { + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + ResolvedPage page = resolveExistingPage(normalizedPath); + String content = apiClient.retrievePageMarkdown(page.pageId()); + 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 | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult searchNotes(String query, String path, Integer limit) { + if (!Boolean.TRUE.equals(configService.getConfig().getLocalIndexEnabled())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Notion local index is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + List hits = localIndexService.search(query, normalizedPath, limit != null ? limit : 5); + Map data = new LinkedHashMap<>(); + data.put("query", query != null ? query : ""); + data.put("path", normalizedPath); + data.put("count", hits.size()); + data.put("results", hits.stream().map(this::toSearchResult).toList()); + return ToolResult.success("Found " + hits.size() + " matching note chunk(s)", data); + } catch (IllegalArgumentException | IllegalStateException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createNote(String path, String content) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + requireNonRootPath(normalizedPath, "create"); + requireContent(content); + String parentPath = pathValidator.parentPath(normalizedPath); + String title = pathValidator.leafName(normalizedPath); + ResolvedPage parentPage = resolveExistingPage(parentPath); + ensureChildAbsent(parentPage.pageId(), title); + NotionPageSummary created = apiClient.createChildPage(parentPage.pageId(), title, content); + refreshIndexesAfterMutation( + () -> ragSyncService.upsertDocument( + created.id(), + normalizedPath, + title, + content, + created.url())); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("page_id", created.id()); + return ToolResult.success("Created note " + normalizedPath, data); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult updateNote(String path, String content) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + requireContent(content); + ResolvedPage page = resolveExistingPage(normalizedPath); + apiClient.updatePageMarkdown(page.pageId(), content); + refreshIndexesAfterMutation( + () -> ragSyncService.upsertDocument( + page.pageId(), + normalizedPath, + page.title(), + content, + page.url())); + return ToolResult.success("Updated note " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult deleteNote(String path) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion delete is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + requireNonRootPath(normalizedPath, "delete"); + ResolvedPage page = resolveExistingPage(normalizedPath); + apiClient.archivePage(page.pageId()); + refreshIndexesAfterMutation(() -> ragSyncService.deleteDocument(page.pageId())); + return ToolResult.success("Archived note " + normalizedPath, Map.of("path", normalizedPath)); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException 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, "Notion move is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + String normalizedTargetPath = pathValidator.normalizeNotePath(targetPath); + requireNonRootPath(normalizedPath, "move"); + requireNonRootPath(normalizedTargetPath, "move"); + if (normalizedPath.equals(normalizedTargetPath)) { + throw new IllegalArgumentException("Source and target paths must differ"); + } + ResolvedPage source = resolveExistingPage(normalizedPath); + String targetParentPath = pathValidator.parentPath(normalizedTargetPath); + String targetTitle = pathValidator.leafName(normalizedTargetPath); + ResolvedPage targetParent = resolveExistingPage(targetParentPath); + ensureChildAbsent(targetParent.pageId(), targetTitle); + if (!source.parentPageId().equals(targetParent.pageId())) { + apiClient.movePage(source.pageId(), targetParent.pageId()); + } + if (!source.title().equals(targetTitle)) { + apiClient.renamePage(source.pageId(), targetTitle); + } + String content = apiClient.retrievePageMarkdown(source.pageId()); + refreshIndexesAfterMutation( + () -> ragSyncService.upsertDocument( + source.pageId(), + normalizedTargetPath, + targetTitle, + content, + source.url())); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("target_path", normalizedTargetPath); + return ToolResult.success("Moved note from " + normalizedPath + " to " + normalizedTargetPath, data); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException 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, "Notion rename is disabled in plugin settings"); + } + try { + String normalizedPath = pathValidator.normalizeNotePath(path); + requireNonRootPath(normalizedPath, "rename"); + String targetPath = pathValidator.resolveSiblingPath(normalizedPath, newName); + if (normalizedPath.equals(targetPath)) { + throw new IllegalArgumentException("Source and target paths must differ"); + } + ResolvedPage source = resolveExistingPage(normalizedPath); + ensureChildAbsent(source.parentPageId(), pathValidator.leafName(targetPath)); + apiClient.renamePage(source.pageId(), pathValidator.leafName(targetPath)); + String content = apiClient.retrievePageMarkdown(source.pageId()); + refreshIndexesAfterMutation( + () -> ragSyncService.upsertDocument( + source.pageId(), + targetPath, + pathValidator.leafName(targetPath), + content, + source.url())); + Map data = new LinkedHashMap<>(); + data.put("path", normalizedPath); + data.put("target_path", targetPath); + return ToolResult.success("Renamed note from " + normalizedPath + " to " + targetPath, data); + } catch (IllegalArgumentException ex) { + return executionFailure(ex.getMessage()); + } + } + + private ResolvedPage resolveExistingPage(String normalizedPath) { + NotionPluginConfig config = configService.getConfig(); + if (config.getRootPageId() == null || config.getRootPageId().isBlank()) { + throw new IllegalArgumentException("Root page ID is not configured."); + } + if (normalizedPath.isBlank()) { + return new ResolvedPage( + config.getRootPageId(), + "", + "", + apiClient.retrievePageTitle(config.getRootPageId()), + ""); + } + String currentPageId = config.getRootPageId(); + String parentPageId = ""; + String title = ""; + String url = ""; + for (String segment : normalizedPath.split("/")) { + NotionPageSummary match = apiClient.listChildPages(currentPageId).stream() + .filter(page -> segment.equals(page.title())) + .findFirst() + .orElse(null); + if (match == null) { + throw new IllegalArgumentException("Page does not exist: " + normalizedPath); + } + parentPageId = currentPageId; + currentPageId = match.id(); + title = match.title(); + url = match.url(); + } + return new ResolvedPage(currentPageId, parentPageId, normalizedPath, title, url); + } + + private void ensureChildAbsent(String parentPageId, String title) { + boolean exists = apiClient.listChildPages(parentPageId).stream() + .anyMatch(page -> title.equals(page.title())); + if (exists) { + throw new IllegalArgumentException("Target page already exists: " + title); + } + } + + private void requireContent(String content) { + if (content == null) { + throw new IllegalArgumentException("Content is required"); + } + } + + private void requireNonRootPath(String normalizedPath, String operation) { + if (normalizedPath.isBlank()) { + throw new IllegalArgumentException("The configured root page cannot be used for " + operation + "."); + } + } + + private ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); + } + + private String displayPath(String normalizedPath) { + return normalizedPath.isBlank() ? "/" : normalizedPath; + } + + private Map toSearchResult(NotionSearchHit hit) { + Map data = new LinkedHashMap<>(); + data.put("chunk_id", hit.chunkId()); + data.put("page_id", hit.pageId()); + data.put("path", hit.path()); + data.put("title", hit.title()); + data.put("heading_path", hit.headingPath()); + data.put("snippet", hit.snippet()); + return data; + } + + private void refreshIndexesAfterMutation(Runnable ragSyncMutation) { + if (!Boolean.TRUE.equals(configService.getConfig().getLocalIndexEnabled())) { + runBestEffort(ragSyncMutation); + return; + } + runBestEffort(localIndexService::reindexAll); + runBestEffort(ragSyncMutation); + } + + private void runBestEffort(Runnable action) { + try { + action.run(); + } catch (RuntimeException ignored) { + // Mutation succeeded; downstream indexing is best effort. + } + } + + private record ResolvedPage( + String pageId, + String parentPageId, + String path, + String title, + String url) { + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProvider.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProvider.java new file mode 100644 index 0000000..957db22 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProvider.java @@ -0,0 +1,149 @@ +package me.golemcore.plugins.golemcore.notion; + +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 NotionVaultToolProvider 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 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_LIMIT = "limit"; + 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 NotionVaultService service; + + public NotionVaultToolProvider(NotionVaultService service) { + this.service = service; + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("notion_vault") + .description("Use Notion pages as a vault through the official Notion HTTP API.") + .inputSchema(Map.of( + TYPE, TYPE_OBJECT, + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of( + TYPE, TYPE_STRING, + "enum", List.of( + "list_directory", + "search_notes", + "read_note", + "create_note", + "update_note", + "delete_note", + "move_note", + "rename_note")), + PARAM_QUERY, Map.of( + TYPE, TYPE_STRING, + "description", "Full-text query against the local Notion index."), + PARAM_PATH, Map.of( + TYPE, TYPE_STRING, + "description", "Pseudo-path to a Notion page or subtree."), + PARAM_LIMIT, Map.of( + TYPE, "integer", + "description", "Maximum number of search results to return."), + 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 pseudo-path for move_note."), + PARAM_NEW_NAME, Map.of( + TYPE, TYPE_STRING, + "description", "New leaf page title for rename_note.")), + REQUIRED, List.of(PARAM_OPERATION), + "allOf", List.of( + requiredWhen("search_notes", List.of(PARAM_QUERY)), + requiredWhen("read_note", List.of(PARAM_PATH)), + 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 "search_notes" -> service.searchNotes( + readString(parameters.get(PARAM_QUERY)), + readString(parameters.get(PARAM_PATH)), + readInteger(parameters.get(PARAM_LIMIT))); + case "read_note" -> service.readNote(readString(parameters.get(PARAM_PATH))); + 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 notion_vault operation: " + operation); + }; + } + + 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 text) { + return text; + } + return null; + } + + private Integer readInteger(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionApiClient.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionApiClient.java new file mode 100644 index 0000000..b084166 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionApiClient.java @@ -0,0 +1,239 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Component +public class NotionApiClient { + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private final NotionPluginConfigService configService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public NotionApiClient(NotionPluginConfigService configService) { + this.configService = configService; + } + + public String retrievePageTitle(String pageId) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + JsonNode page = getJson("/v1/pages/" + pageId); + return extractPageTitle(page); + } + + public List listChildPages(String parentPageId) { + if (parentPageId == null || parentPageId.isBlank()) { + throw new IllegalArgumentException("parentPageId is required"); + } + List pages = new ArrayList<>(); + String nextCursor = ""; + boolean hasMore; + do { + StringBuilder pathBuilder = new StringBuilder("/v1/blocks/") + .append(parentPageId) + .append("/children?page_size=100"); + if (!nextCursor.isBlank()) { + pathBuilder.append("&start_cursor=") + .append(URLEncoder.encode(nextCursor, StandardCharsets.UTF_8)); + } + JsonNode response = getJson(pathBuilder.toString()); + for (JsonNode result : response.path("results")) { + if (!"child_page".equals(result.path("type").asText())) { + continue; + } + pages.add(new NotionPageSummary( + result.path("id").asText(), + result.path("child_page").path("title").asText(), + result.path("url").asText(""))); + } + hasMore = response.path("has_more").asBoolean(false); + nextCursor = hasMore ? response.path("next_cursor").asText("") : ""; + } while (hasMore && !nextCursor.isBlank()); + return pages; + } + + public String retrievePageMarkdown(String pageId) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + JsonNode response = getJson("/v1/pages/" + pageId + "/markdown"); + return response.path("markdown").asText(""); + } + + public NotionPageSummary createChildPage(String parentPageId, String title, String markdown) { + if (parentPageId == null || parentPageId.isBlank()) { + throw new IllegalArgumentException("parentPageId is required"); + } + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("title is required"); + } + JsonNode response = sendJson("POST", "/v1/pages", Map.of( + "parent", Map.of("page_id", parentPageId), + "properties", Map.of( + "title", Map.of( + "title", List.of(Map.of( + "text", Map.of("content", title))))), + "markdown", markdown != null ? markdown : "")); + return toPageSummary(response); + } + + public void updatePageMarkdown(String pageId, String markdown) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + sendJson("PATCH", "/v1/pages/" + pageId + "/markdown", Map.of( + "type", "replace_content", + "replace_content", Map.of("new_str", markdown != null ? markdown : ""))); + } + + public void archivePage(String pageId) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + sendJson("PATCH", "/v1/pages/" + pageId, Map.of("archived", true)); + } + + public void movePage(String pageId, String targetParentPageId) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + if (targetParentPageId == null || targetParentPageId.isBlank()) { + throw new IllegalArgumentException("targetParentPageId is required"); + } + sendJson("POST", "/v1/pages/" + pageId + "/move", Map.of( + "parent", Map.of("page_id", targetParentPageId))); + } + + public void renamePage(String pageId, String title) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("title is required"); + } + sendJson("PATCH", "/v1/pages/" + pageId, Map.of( + "properties", Map.of( + "title", Map.of( + "title", List.of(Map.of( + "text", Map.of("content", title))))))); + } + + private JsonNode getJson(String path) { + return sendJson("GET", path, null); + } + + private JsonNode sendJson(String method, String path, Object body) { + NotionPluginConfig config = requireConfiguredClient(); + Request.Builder builder = new Request.Builder() + .url(stripTrailingSlash(config.getBaseUrl()) + path) + .header("Authorization", "Bearer " + config.getApiKey()) + .header("Notion-Version", config.getApiVersion()) + .header("Accept", "application/json"); + try { + if ("GET".equals(method)) { + builder.get(); + } else { + String jsonBody = objectMapper.writeValueAsString(body != null ? body : Map.of()); + builder.header("Content-Type", "application/json"); + builder.method(method, RequestBody.create(jsonBody, JSON)); + } + } catch (IOException e) { + throw new NotionTransportException("Notion request serialization failed: " + e.getMessage(), e); + } + + try (Response response = client(config).newCall(builder.build()).execute(); + ResponseBody responseBody = response.body()) { + String rawBody = responseBody == null ? "" : responseBody.string(); + if (!response.isSuccessful()) { + throw new NotionApiException(response.code(), errorMessage(rawBody, response.message())); + } + return rawBody.isBlank() ? objectMapper.createObjectNode() : objectMapper.readTree(rawBody); + } catch (IOException e) { + throw new NotionTransportException("Notion transport failed: " + e.getMessage(), e); + } + } + + private NotionPluginConfig requireConfiguredClient() { + NotionPluginConfig config = configService.getConfig(); + if (config.getApiKey() == null || config.getApiKey().isBlank()) { + throw new IllegalStateException("Notion API key is not configured."); + } + if (config.getBaseUrl() == null || config.getBaseUrl().isBlank()) { + throw new IllegalStateException("Notion base URL is not configured."); + } + return config; + } + + private OkHttpClient client(NotionPluginConfig config) { + return new OkHttpClient.Builder() + .callTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS) + .readTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS) + .build(); + } + + private NotionPageSummary toPageSummary(JsonNode page) { + return new NotionPageSummary( + page.path("id").asText(), + extractPageTitle(page), + page.path("url").asText("")); + } + + private String extractPageTitle(JsonNode page) { + JsonNode properties = page.path("properties"); + if (properties.isObject()) { + var fields = properties.fields(); + while (fields.hasNext()) { + JsonNode property = fields.next().getValue(); + if (!"title".equals(property.path("type").asText())) { + continue; + } + String title = property.path("title").findValuesAsText("plain_text").stream() + .reduce("", String::concat); + if (!title.isBlank()) { + return title; + } + } + } + return ""; + } + + private String errorMessage(String rawBody, String fallback) { + try { + JsonNode json = rawBody.isBlank() ? objectMapper.createObjectNode() : objectMapper.readTree(rawBody); + JsonNode message = json.path("message"); + if (!message.isMissingNode() && !message.asText().isBlank()) { + return message.asText(); + } + } catch (IOException ignored) { + // fall through to raw body or fallback message + } + return rawBody.isBlank() ? fallback : rawBody; + } + + private String stripTrailingSlash(String url) { + String stripped = url; + while (stripped.endsWith("/")) { + stripped = stripped.substring(0, stripped.length() - 1); + } + return stripped; + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionApiException.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionApiException.java new file mode 100644 index 0000000..f25ace7 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionApiException.java @@ -0,0 +1,17 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public class NotionApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public NotionApiException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionLocalIndexService.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionLocalIndexService.java new file mode 100644 index 0000000..3718043 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionLocalIndexService.java @@ -0,0 +1,381 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import org.springframework.stereotype.Service; + +import java.nio.file.Files; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +public class NotionLocalIndexService { + + private static final Pattern SPLIT_LINES = Pattern.compile("\\R"); + private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,6})\\s+(.*)$"); + private static final Pattern QUERY_TOKEN_PATTERN = Pattern.compile("[\\p{L}\\p{N}_-]{2,}"); + private static final int DEFAULT_LIMIT = 5; + private static final int MAX_LIMIT = 20; + private static final int CHUNK_MAX_CHARS = 1_500; + + private final NotionApiClient apiClient; + private final NotionPluginConfigService configService; + private final NotionStoragePaths storagePaths; + private final NotionPathValidator pathValidator = new NotionPathValidator(); + + public NotionLocalIndexService( + NotionApiClient apiClient, + NotionPluginConfigService configService, + NotionStoragePaths storagePaths) { + this.apiClient = apiClient; + this.configService = configService; + this.storagePaths = storagePaths; + } + + public synchronized NotionReindexSummary reindexAll() { + NotionPluginConfig config = configService.getConfig(); + if (config.getRootPageId() == null || config.getRootPageId().isBlank()) { + throw new IllegalStateException("Root page ID is not configured."); + } + storagePaths.ensureStorageDirectories(); + List pages = new ArrayList<>(); + crawlPage(config.getRootPageId(), "", apiClient.retrievePageTitle(config.getRootPageId()), pages); + try (Connection connection = openConnection()) { + initializeSchema(connection); + connection.setAutoCommit(false); + clearIndex(connection); + int chunkCount = 0; + for (IndexedPage page : pages) { + upsertPage(connection, page); + List chunks = chunkPage(page); + chunkCount += chunks.size(); + insertChunks(connection, chunks); + } + connection.commit(); + return new NotionReindexSummary(pages.size(), chunkCount); + } catch (SQLException ex) { + throw new IllegalStateException("Failed to rebuild Notion local index: " + ex.getMessage(), ex); + } + } + + public synchronized List search(String query, String pathPrefix, int limit) { + String ftsQuery = toFtsQuery(query); + if (ftsQuery.isBlank()) { + return List.of(); + } + if (!Files.exists(storagePaths.indexDatabasePath())) { + return List.of(); + } + String normalizedPathPrefix = pathValidator.normalizeNotePath(pathPrefix); + int normalizedLimit = Math.max(1, Math.min(limit <= 0 ? DEFAULT_LIMIT : limit, MAX_LIMIT)); + try (Connection connection = openConnection()) { + initializeSchema(connection); + return executeSearch(connection, ftsQuery, normalizedPathPrefix, normalizedLimit, query); + } catch (SQLException ex) { + throw new IllegalStateException("Failed to query Notion local index: " + ex.getMessage(), ex); + } + } + + private void crawlPage(String pageId, String path, String title, List pages) { + String safeTitle = title != null ? title : ""; + pages.add(new IndexedPage( + pageId, + path, + safeTitle, + apiClient.retrievePageMarkdown(pageId))); + for (NotionPageSummary child : apiClient.listChildPages(pageId)) { + String childPath = path.isBlank() ? child.title() : path + "/" + child.title(); + crawlPage(child.id(), childPath, child.title(), pages); + } + } + + private void initializeSchema(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS pages ( + page_id TEXT PRIMARY KEY, + pseudo_path TEXT NOT NULL, + title TEXT NOT NULL, + plain_text TEXT NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS chunks ( + chunk_id TEXT PRIMARY KEY, + page_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + heading_path TEXT NOT NULL, + plain_text TEXT NOT NULL + ) + """); + statement.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + chunk_id UNINDEXED, + page_id UNINDEXED, + pseudo_path, + title, + heading_path, + body + ) + """); + } + } + + private void clearIndex(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM chunks_fts"); + statement.executeUpdate("DELETE FROM chunks"); + statement.executeUpdate("DELETE FROM pages"); + } + } + + private void upsertPage(Connection connection, IndexedPage page) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO pages(page_id, pseudo_path, title, plain_text) + VALUES (?, ?, ?, ?) + """)) { + statement.setString(1, page.pageId()); + statement.setString(2, page.path()); + statement.setString(3, page.title()); + statement.setString(4, normalizeText(page.markdown())); + statement.executeUpdate(); + } + } + + private void insertChunks(Connection connection, List chunks) throws SQLException { + try (PreparedStatement chunkStatement = connection.prepareStatement(""" + INSERT INTO chunks(chunk_id, page_id, ordinal, heading_path, plain_text) + VALUES (?, ?, ?, ?, ?) + """); + PreparedStatement ftsStatement = connection.prepareStatement(""" + INSERT INTO chunks_fts(chunk_id, page_id, pseudo_path, title, heading_path, body) + VALUES (?, ?, ?, ?, ?, ?) + """)) { + for (IndexedChunk chunk : chunks) { + chunkStatement.setString(1, chunk.chunkId()); + chunkStatement.setString(2, chunk.pageId()); + chunkStatement.setInt(3, chunk.ordinal()); + chunkStatement.setString(4, chunk.headingPath()); + chunkStatement.setString(5, chunk.body()); + chunkStatement.addBatch(); + + ftsStatement.setString(1, chunk.chunkId()); + ftsStatement.setString(2, chunk.pageId()); + ftsStatement.setString(3, chunk.path()); + ftsStatement.setString(4, chunk.title()); + ftsStatement.setString(5, chunk.headingPath()); + ftsStatement.setString(6, chunk.body()); + ftsStatement.addBatch(); + } + chunkStatement.executeBatch(); + ftsStatement.executeBatch(); + } + } + + private List chunkPage(IndexedPage page) { + List chunks = new ArrayList<>(); + String markdown = page.markdown() != null ? page.markdown() : ""; + if (markdown.isBlank()) { + chunks.add(new IndexedChunk( + randomChunkId(), + page.pageId(), + page.path(), + page.title(), + page.title(), + 0, + page.title())); + return chunks; + } + + Deque headingStack = new ArrayDeque<>(); + StringBuilder body = new StringBuilder(); + int ordinal = 0; + for (String line : SPLIT_LINES.split(markdown, -1)) { + var headingMatcher = HEADING_PATTERN.matcher(line); + if (headingMatcher.matches()) { + ordinal = flushChunk(page, chunks, headingStack, body, ordinal); + int level = headingMatcher.group(1).length(); + while (headingStack.size() >= level) { + headingStack.removeLast(); + } + headingStack.addLast(normalizeText(headingMatcher.group(2))); + body.append(normalizeText(headingMatcher.group(2))).append('\n'); + continue; + } + if (body.length() >= CHUNK_MAX_CHARS && !normalizeText(body.toString()).isBlank()) { + ordinal = flushChunk(page, chunks, headingStack, body, ordinal); + } + body.append(line).append('\n'); + } + flushChunk(page, chunks, headingStack, body, ordinal); + if (chunks.isEmpty()) { + chunks.add(new IndexedChunk( + randomChunkId(), + page.pageId(), + page.path(), + page.title(), + page.title(), + 0, + page.title())); + } + return chunks; + } + + private int flushChunk( + IndexedPage page, + List chunks, + Deque headingStack, + StringBuilder body, + int ordinal) { + String normalizedBody = normalizeText(body.toString()); + body.setLength(0); + if (normalizedBody.isBlank()) { + return ordinal; + } + chunks.add(new IndexedChunk( + randomChunkId(), + page.pageId(), + page.path(), + page.title(), + headingPath(headingStack, page.title()), + ordinal, + normalizedBody)); + return ordinal + 1; + } + + private List executeSearch( + Connection connection, + String ftsQuery, + String normalizedPathPrefix, + int limit, + String rawQuery) throws SQLException { + String sql = """ + SELECT chunk_id, page_id, pseudo_path, title, heading_path, body + FROM chunks_fts + WHERE chunks_fts MATCH ? + """ + + (normalizedPathPrefix.isBlank() ? "" : " AND (pseudo_path = ? OR pseudo_path LIKE ?)") + + """ + ORDER BY bm25(chunks_fts) + LIMIT ? + """; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + int index = 1; + statement.setString(index, ftsQuery); + index++; + if (!normalizedPathPrefix.isBlank()) { + statement.setString(index, normalizedPathPrefix); + index++; + statement.setString(index, normalizedPathPrefix + "/%"); + index++; + } + statement.setInt(index, limit); + try (ResultSet resultSet = statement.executeQuery()) { + List hits = new ArrayList<>(); + while (resultSet.next()) { + String body = resultSet.getString("body"); + hits.add(new NotionSearchHit( + resultSet.getString("chunk_id"), + resultSet.getString("page_id"), + resultSet.getString("pseudo_path"), + resultSet.getString("title"), + buildSnippet(body, rawQuery), + resultSet.getString("heading_path"))); + } + return hits; + } + } + } + + private Connection openConnection() throws SQLException { + return DriverManager.getConnection("jdbc:sqlite:" + storagePaths.indexDatabasePath()); + } + + private String toFtsQuery(String query) { + if (query == null || query.isBlank()) { + return ""; + } + Set tokens = new LinkedHashSet<>(); + var matcher = QUERY_TOKEN_PATTERN.matcher(query.toLowerCase(Locale.ROOT)); + while (matcher.find()) { + tokens.add(matcher.group()); + } + if (tokens.isEmpty()) { + return ""; + } + return tokens.stream() + .map(token -> "\"" + token.replace("\"", "\"\"") + "\"") + .reduce((left, right) -> left + " AND " + right) + .orElse(""); + } + + private String buildSnippet(String body, String query) { + String normalizedBody = normalizeText(body); + if (normalizedBody.isBlank()) { + return ""; + } + String lowerBody = normalizedBody.toLowerCase(Locale.ROOT); + int anchor = Integer.MAX_VALUE; + var matcher = QUERY_TOKEN_PATTERN.matcher(query != null ? query.toLowerCase(Locale.ROOT) : ""); + while (matcher.find()) { + int position = lowerBody.indexOf(matcher.group()); + if (position >= 0) { + anchor = Math.min(anchor, position); + } + } + if (anchor == Integer.MAX_VALUE) { + anchor = 0; + } + int start = Math.max(0, anchor - 40); + int end = Math.min(normalizedBody.length(), start + 180); + String snippet = normalizedBody.substring(start, end).trim(); + if (start > 0) { + snippet = "... " + snippet; + } + if (end < normalizedBody.length()) { + snippet = snippet + " ..."; + } + return snippet; + } + + private String normalizeText(String text) { + return text == null ? "" : text.replaceAll("\\s+", " ").trim(); + } + + private String headingPath(Deque headingStack, String fallbackTitle) { + if (headingStack.isEmpty()) { + return fallbackTitle != null ? fallbackTitle : ""; + } + return String.join(" / ", headingStack); + } + + private String randomChunkId() { + return UUID.randomUUID().toString(); + } + + private record IndexedPage(String pageId, String path, String title, String markdown) { + } + + private record IndexedChunk( + String chunkId, + String pageId, + String path, + String title, + String headingPath, + int ordinal, + String body) { + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPageSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPageSummary.java new file mode 100644 index 0000000..2b28431 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPageSummary.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public record NotionPageSummary(String id,String title,String url){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPathValidator.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPathValidator.java new file mode 100644 index 0000000..b38b29a --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPathValidator.java @@ -0,0 +1,72 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; + +public class NotionPathValidator { + + public String normalizeNotePath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + Deque normalized = new ArrayDeque<>(); + Arrays.stream(path.trim().split("/")) + .map(String::trim) + .forEach(segment -> consumeSegment(normalized, segment)); + return String.join("/", normalized); + } + + public String parentPath(String normalizedPath) { + List segments = segments(normalizedPath); + if (segments.isEmpty()) { + return ""; + } + if (segments.size() == 1) { + return ""; + } + return String.join("/", segments.subList(0, segments.size() - 1)); + } + + public String leafName(String normalizedPath) { + List segments = segments(normalizedPath); + if (segments.isEmpty()) { + throw new IllegalArgumentException("Path must point to a page"); + } + return segments.getLast(); + } + + public String resolveSiblingPath(String normalizedPath, String newName) { + if (newName == null || newName.isBlank()) { + throw new IllegalArgumentException("newName is required"); + } + String trimmed = newName.trim(); + if (trimmed.contains("/")) { + throw new IllegalArgumentException("newName must not contain path separators"); + } + String parentPath = parentPath(normalizedPath); + return parentPath.isBlank() ? trimmed : parentPath + "/" + trimmed; + } + + private void consumeSegment(Deque normalized, String segment) { + if (segment.isEmpty() || ".".equals(segment)) { + return; + } + if ("..".equals(segment)) { + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Path traversal is not allowed"); + } + normalized.removeLast(); + return; + } + normalized.addLast(segment); + } + + private List segments(String normalizedPath) { + if (normalizedPath == null || normalizedPath.isBlank()) { + return List.of(); + } + return List.of(normalizedPath.split("/")); + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionRagSyncService.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionRagSyncService.java new file mode 100644 index 0000000..acb0a52 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionRagSyncService.java @@ -0,0 +1,165 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import me.golemcore.plugin.api.extension.model.rag.RagCorpusRef; +import me.golemcore.plugin.api.extension.model.rag.RagDocument; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionCapabilities; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionResult; +import me.golemcore.plugin.api.runtime.RagIngestionService; +import me.golemcore.plugin.api.runtime.model.RagIngestionTargetDescriptor; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +public class NotionRagSyncService { + + private static final int DEFAULT_BATCH_SIZE = 50; + + private final NotionPluginConfigService configService; + private final NotionApiClient apiClient; + private final RagIngestionService ragIngestionService; + private final AtomicBoolean fullReindexRequired = new AtomicBoolean(false); + + public NotionRagSyncService( + NotionPluginConfigService configService, + NotionApiClient apiClient, + RagIngestionService ragIngestionService) { + this.configService = configService; + this.apiClient = apiClient; + this.ragIngestionService = ragIngestionService; + } + + public void upsertDocument(String pageId, String path, String title, String content, String url) { + if (!isSyncConfigured() || !hasText(pageId)) { + return; + } + RagDocument document = toDocument(pageId, path, title, content, url); + ragIngestionService.upsertDocuments( + configService.getConfig().getTargetRagProviderId(), + corpusRef(), + List.of(document)).join(); + } + + public void deleteDocument(String pageId) { + if (!isSyncConfigured() || !hasText(pageId)) { + return; + } + RagIngestionCapabilities capabilities = targetCapabilities().orElse(null); + if (capabilities == null || !capabilities.supportsDelete()) { + fullReindexRequired.set(true); + return; + } + ragIngestionService.deleteDocuments( + configService.getConfig().getTargetRagProviderId(), + corpusRef(), + List.of(pageId)).join(); + } + + public int reindexAll() { + if (!isSyncConfigured()) { + return 0; + } + NotionPluginConfig config = configService.getConfig(); + if (!hasText(config.getRootPageId())) { + throw new IllegalStateException("Root page ID is not configured."); + } + RagIngestionTargetDescriptor target = targetDescriptor().orElseThrow( + () -> new IllegalStateException( + "Configured RAG target is not installed: " + config.getTargetRagProviderId())); + RagCorpusRef corpus = corpusRef(); + if (target.capabilities().supportsReset()) { + ragIngestionService.resetCorpus(target.providerId(), corpus).join(); + } + + List documents = new ArrayList<>(); + crawlDocuments( + config.getRootPageId(), + "", + apiClient.retrievePageTitle(config.getRootPageId()), + "", + documents); + + int batchSize = target.capabilities().maxBatchSize() > 0 + ? target.capabilities().maxBatchSize() + : DEFAULT_BATCH_SIZE; + int acceptedDocuments = 0; + for (int start = 0; start < documents.size(); start += batchSize) { + List batch = documents.subList(start, Math.min(documents.size(), start + batchSize)); + RagIngestionResult result = ragIngestionService.upsertDocuments( + target.providerId(), + corpus, + List.copyOf(batch)).join(); + acceptedDocuments += Math.max(0, result.acceptedDocuments()); + } + fullReindexRequired.set(false); + return acceptedDocuments; + } + + boolean isFullReindexRequired() { + return fullReindexRequired.get(); + } + + private void crawlDocuments( + String pageId, + String path, + String title, + String url, + List documents) { + documents.add(toDocument( + pageId, + path, + title != null ? title : "", + apiClient.retrievePageMarkdown(pageId), + url != null ? url : "")); + for (NotionPageSummary child : apiClient.listChildPages(pageId)) { + String childPath = path.isBlank() ? child.title() : path + "/" + child.title(); + crawlDocuments(child.id(), childPath, child.title(), child.url(), documents); + } + } + + private Optional targetDescriptor() { + String providerId = configService.getConfig().getTargetRagProviderId(); + if (!hasText(providerId)) { + return Optional.empty(); + } + return ragIngestionService.listInstalledTargets().stream() + .filter(target -> providerId.equals(target.providerId())) + .findFirst(); + } + + private Optional targetCapabilities() { + return targetDescriptor().map(RagIngestionTargetDescriptor::capabilities); + } + + private RagCorpusRef corpusRef() { + String corpusId = configService.getConfig().getRagCorpusId(); + return new RagCorpusRef(corpusId, corpusId); + } + + private RagDocument toDocument(String pageId, String path, String title, String content, String url) { + return new RagDocument( + pageId, + title != null ? title : "", + path != null ? path : "", + content != null ? content : "", + url != null ? url : "", + Map.of( + "source", "notion", + "page_id", pageId)); + } + + private boolean isSyncConfigured() { + NotionPluginConfig config = configService.getConfig(); + return Boolean.TRUE.equals(config.getRagSyncEnabled()) && hasText(config.getTargetRagProviderId()); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexCoordinator.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexCoordinator.java new file mode 100644 index 0000000..ebc07c5 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexCoordinator.java @@ -0,0 +1,129 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Component +public class NotionReindexCoordinator { + + private final NotionPluginConfigService configService; + private final NotionLocalIndexService localIndexService; + private final NotionRagSyncService ragSyncService; + private final NotionReindexScheduleResolver scheduleResolver; + private final ScheduledExecutorService scheduler; + private final Clock clock; + + private Optional> scheduledFuture = Optional.empty(); + + public NotionReindexCoordinator( + NotionPluginConfigService configService, + NotionLocalIndexService localIndexService, + NotionRagSyncService ragSyncService, + NotionReindexScheduleResolver scheduleResolver) { + this( + configService, + localIndexService, + ragSyncService, + scheduleResolver, + Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "notion-reindex"); + thread.setDaemon(true); + return thread; + }), + Clock.systemDefaultZone()); + } + + NotionReindexCoordinator( + NotionPluginConfigService configService, + NotionLocalIndexService localIndexService, + NotionRagSyncService ragSyncService, + NotionReindexScheduleResolver scheduleResolver, + ScheduledExecutorService scheduler, + Clock clock) { + this.configService = configService; + this.localIndexService = localIndexService; + this.ragSyncService = ragSyncService; + this.scheduleResolver = scheduleResolver; + this.scheduler = scheduler; + this.clock = clock; + } + + @PostConstruct + public void start() { + refreshSchedule(); + } + + @PreDestroy + public synchronized void stop() { + cancelScheduledFuture(); + scheduler.shutdownNow(); + } + + public synchronized void refreshSchedule() { + cancelScheduledFuture(); + Optional cronExpression = scheduleResolver.resolveCronExpression(configService.getConfig()); + if (cronExpression.isEmpty()) { + return; + } + scheduleNext(cronExpression.get()); + } + + public NotionReindexSummary reindexNow() { + NotionPluginConfig config = configService.getConfig(); + if (!Boolean.TRUE.equals(config.getLocalIndexEnabled()) && !Boolean.TRUE.equals(config.getRagSyncEnabled())) { + throw new IllegalStateException("No indexing target is enabled."); + } + NotionReindexSummary localSummary = new NotionReindexSummary(0, 0, 0); + if (Boolean.TRUE.equals(config.getLocalIndexEnabled())) { + localSummary = localIndexService.reindexAll(); + } + int documentsSynced = Boolean.TRUE.equals(config.getRagSyncEnabled()) + ? ragSyncService.reindexAll() + : 0; + return new NotionReindexSummary( + localSummary.pagesIndexed(), + localSummary.chunksIndexed(), + documentsSynced); + } + + private synchronized void scheduleNext(String cronExpression) { + CronExpression cron = CronExpression.parse(cronExpression); + ZonedDateTime now = ZonedDateTime.ofInstant(clock.instant(), ZoneId.systemDefault()); + ZonedDateTime next = cron.next(now); + if (next == null) { + return; + } + long delayMillis = Math.max(1L, Duration.between(now, next).toMillis()); + scheduledFuture = Optional + .of(scheduler.schedule(this::runScheduledReindex, delayMillis, TimeUnit.MILLISECONDS)); + } + + private void runScheduledReindex() { + try { + reindexNow(); + } catch (RuntimeException ignored) { + // Best effort background task; failures should not break the scheduler loop. + } finally { + refreshSchedule(); + } + } + + private void cancelScheduledFuture() { + scheduledFuture.ifPresent(future -> future.cancel(false)); + scheduledFuture = Optional.empty(); + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexScheduleResolver.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexScheduleResolver.java new file mode 100644 index 0000000..720b691 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexScheduleResolver.java @@ -0,0 +1,34 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class NotionReindexScheduleResolver { + + public Optional resolveCronExpression(NotionPluginConfig config) { + if (config == null || !Boolean.TRUE.equals(config.getLocalIndexEnabled())) { + return Optional.empty(); + } + String preset = config.getReindexSchedulePreset(); + if (preset == null) { + return Optional.empty(); + } + String expression = switch (preset) { + case "hourly" -> "0 0 * * * *"; + case "every_6_hours" -> "0 0 */6 * * *"; + case "daily" -> "0 0 3 * * *"; + case "weekly" -> "0 0 3 * * MON"; + case "custom" -> config.getReindexCronExpression(); + case "disabled" -> ""; + default -> ""; + }; + if (expression == null || expression.isBlank() || !CronExpression.isValidExpression(expression)) { + return Optional.empty(); + } + return Optional.of(expression); + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexSummary.java new file mode 100644 index 0000000..df1ce24 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexSummary.java @@ -0,0 +1,5 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public record NotionReindexSummary(int pagesIndexed,int chunksIndexed,int documentsSynced){ + +public NotionReindexSummary(int pagesIndexed,int chunksIndexed){this(pagesIndexed,chunksIndexed,0);}} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionSearchHit.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionSearchHit.java new file mode 100644 index 0000000..59319a5 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionSearchHit.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public record NotionSearchHit(String chunkId,String pageId,String path,String title,String snippet,String headingPath){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionStoragePaths.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionStoragePaths.java new file mode 100644 index 0000000..ccb8f4f --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionStoragePaths.java @@ -0,0 +1,44 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Component +public class NotionStoragePaths { + + static final String STORAGE_DIR_PROPERTY = "golemcore.notion.storageDir"; + + private final Path storageRoot; + + public NotionStoragePaths() { + this(defaultStorageRoot()); + } + + public NotionStoragePaths(Path storageRoot) { + this.storageRoot = storageRoot; + } + + public Path indexDatabasePath() { + return storageRoot.resolve("notion-index.sqlite"); + } + + public void ensureStorageDirectories() { + try { + Files.createDirectories(storageRoot); + } catch (IOException ex) { + throw new IllegalStateException("Failed to prepare Notion plugin storage: " + ex.getMessage(), ex); + } + } + + private static Path defaultStorageRoot() { + String override = System.getProperty(STORAGE_DIR_PROPERTY); + if (override != null && !override.isBlank()) { + return Paths.get(override); + } + return Paths.get(System.getProperty("user.home"), ".golemcore", "plugins", "golemcore-notion"); + } +} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionTransportException.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionTransportException.java new file mode 100644 index 0000000..a49fe56 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionTransportException.java @@ -0,0 +1,10 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public class NotionTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public NotionTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/golemcore/notion/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap b/golemcore/notion/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap new file mode 100644 index 0000000..edeb32d --- /dev/null +++ b/golemcore/notion/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap @@ -0,0 +1 @@ +me.golemcore.plugins.golemcore.notion.NotionPluginBootstrap diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginBootstrapTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginBootstrapTest.java new file mode 100644 index 0000000..7a0c11f --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.notion; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +class NotionPluginBootstrapTest { + + @Test + void shouldDescribeNotionPlugin() { + NotionPluginBootstrap bootstrap = new NotionPluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/notion", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("notion", descriptor.getName()); + assertEquals(NotionPluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfigTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfigTest.java new file mode 100644 index 0000000..4b975d3 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginConfigTest.java @@ -0,0 +1,48 @@ +package me.golemcore.plugins.golemcore.notion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +class NotionPluginConfigTest { + + @Test + void shouldNormalizeSafeDefaultsAndFriendlySchedulePreset() { + NotionPluginConfig config = NotionPluginConfig.builder() + .enabled(null) + .baseUrl(" https://api.notion.com/ ") + .apiVersion(" ") + .timeoutMs(0) + .maxReadChars(0) + .allowWrite(null) + .allowDelete(null) + .allowMove(null) + .allowRename(null) + .localIndexEnabled(null) + .reindexSchedulePreset(null) + .reindexCronExpression(null) + .ragSyncEnabled(null) + .targetRagProviderId(null) + .ragCorpusId(null) + .build(); + + config.normalize(); + + assertFalse(config.getEnabled()); + assertEquals("https://api.notion.com", config.getBaseUrl()); + assertEquals("2026-03-11", config.getApiVersion()); + assertEquals(30_000, config.getTimeoutMs()); + assertEquals(12_000, config.getMaxReadChars()); + assertFalse(config.getAllowWrite()); + assertFalse(config.getAllowDelete()); + assertFalse(config.getAllowMove()); + assertFalse(config.getAllowRename()); + assertFalse(config.getLocalIndexEnabled()); + assertEquals("disabled", config.getReindexSchedulePreset()); + assertEquals("", config.getReindexCronExpression()); + assertFalse(config.getRagSyncEnabled()); + assertEquals("", config.getTargetRagProviderId()); + assertEquals("notion", config.getRagCorpusId()); + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginSettingsContributorTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginSettingsContributorTest.java new file mode 100644 index 0000000..05ef367 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionPluginSettingsContributorTest.java @@ -0,0 +1,238 @@ +package me.golemcore.plugins.golemcore.notion; + +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; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsField; +import me.golemcore.plugin.api.extension.spi.PluginSettingsFieldOption; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugin.api.runtime.RagIngestionService; +import me.golemcore.plugin.api.runtime.model.RagIngestionTargetDescriptor; +import me.golemcore.plugins.golemcore.notion.support.NotionApiClient; +import me.golemcore.plugins.golemcore.notion.support.NotionReindexCoordinator; +import me.golemcore.plugins.golemcore.notion.support.NotionReindexSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionTransportException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class NotionPluginSettingsContributorTest { + + private NotionPluginConfigService configService; + private RagIngestionService ragIngestionService; + private NotionApiClient apiClient; + private NotionReindexCoordinator reindexCoordinator; + private NotionPluginSettingsContributor contributor; + private NotionPluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(NotionPluginConfigService.class); + ragIngestionService = mock(RagIngestionService.class); + apiClient = mock(NotionApiClient.class); + reindexCoordinator = mock(NotionReindexCoordinator.class); + contributor = new NotionPluginSettingsContributor( + configService, + ragIngestionService, + apiClient, + reindexCoordinator); + config = NotionPluginConfig.builder().build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + when(ragIngestionService.listInstalledTargets()).thenReturn(List.of( + new RagIngestionTargetDescriptor( + "golemcore/lightrag", + "golemcore/lightrag", + "LightRAG", + null), + new RagIngestionTargetDescriptor( + "acme/raggy", + "acme/raggy", + "Raggy", + null))); + } + + @Test + void shouldExposeSafeDefaultsAndFriendlyScheduleOptions() { + PluginSettingsSection section = contributor.getSection("main"); + + assertEquals(false, section.getValues().get("enabled")); + assertEquals("https://api.notion.com", section.getValues().get("baseUrl")); + assertEquals("2026-03-11", section.getValues().get("apiVersion")); + assertEquals("", section.getValues().get("apiKey")); + assertEquals("", section.getValues().get("rootPageId")); + assertEquals(30_000, section.getValues().get("timeoutMs")); + assertEquals(12_000, section.getValues().get("maxReadChars")); + assertEquals(false, section.getValues().get("localIndexEnabled")); + assertEquals("disabled", section.getValues().get("reindexSchedulePreset")); + assertEquals("", section.getValues().get("reindexCronExpression")); + assertEquals(false, section.getValues().get("ragSyncEnabled")); + assertEquals("", section.getValues().get("targetRagProviderId")); + assertEquals("notion", section.getValues().get("ragCorpusId")); + assertEquals(2, section.getActions().size()); + assertEquals("test-connection", section.getActions().getFirst().getActionId()); + assertEquals("reindex-now", section.getActions().get(1).getActionId()); + + PluginSettingsField scheduleField = section.getFields().stream() + .filter(field -> "reindexSchedulePreset".equals(field.getKey())) + .findFirst() + .orElseThrow(); + assertEquals("select", scheduleField.getType()); + assertEquals(List.of("disabled", "hourly", "every_6_hours", "daily", "weekly", "custom"), + scheduleField.getOptions().stream().map(PluginSettingsFieldOption::getValue).toList()); + + PluginSettingsField ragTargetField = section.getFields().stream() + .filter(field -> "targetRagProviderId".equals(field.getKey())) + .findFirst() + .orElseThrow(); + assertEquals("select", ragTargetField.getType()); + assertEquals(List.of("golemcore/lightrag", "acme/raggy"), + ragTargetField.getOptions().stream().map(PluginSettingsFieldOption::getValue).toList()); + } + + @Test + void shouldRoundTripSavedValuesWithoutOverwritingBlankSecret() { + NotionPluginConfig initialConfig = NotionPluginConfig.builder() + .apiKey("existing-secret") + .build(); + initialConfig.normalize(); + NotionPluginConfig persistedConfig = NotionPluginConfig.builder() + .enabled(true) + .baseUrl("https://api.notion.com") + .apiVersion("2026-03-11") + .apiKey("existing-secret") + .rootPageId("root-page") + .timeoutMs(45_000) + .maxReadChars(8_000) + .allowWrite(true) + .allowDelete(false) + .allowMove(true) + .allowRename(false) + .localIndexEnabled(true) + .reindexSchedulePreset("daily") + .reindexCronExpression("0 0 3 * * *") + .ragSyncEnabled(true) + .targetRagProviderId("golemcore/lightrag") + .ragCorpusId("team-notes") + .build(); + persistedConfig.normalize(); + when(configService.getConfig()).thenReturn(initialConfig, persistedConfig); + + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("baseUrl", "https://api.notion.com"); + values.put("apiVersion", "2026-03-11"); + values.put("apiKey", ""); + values.put("rootPageId", "root-page"); + values.put("timeoutMs", 45_000); + values.put("maxReadChars", 8_000); + values.put("allowWrite", true); + values.put("allowDelete", false); + values.put("allowMove", true); + values.put("allowRename", false); + values.put("localIndexEnabled", true); + values.put("reindexSchedulePreset", "daily"); + values.put("reindexCronExpression", "0 0 3 * * *"); + values.put("ragSyncEnabled", true); + values.put("targetRagProviderId", "golemcore/lightrag"); + values.put("ragCorpusId", "team-notes"); + + PluginSettingsSection section = contributor.saveSection("main", values); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotionPluginConfig.class); + verify(configService).save(captor.capture()); + verify(reindexCoordinator).refreshSchedule(); + NotionPluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getApiKey()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertTrue(saved.getAllowMove()); + assertFalse(saved.getAllowRename()); + assertTrue(saved.getLocalIndexEnabled()); + assertEquals("daily", saved.getReindexSchedulePreset()); + assertTrue(saved.getRagSyncEnabled()); + assertEquals("golemcore/lightrag", saved.getTargetRagProviderId()); + + assertEquals("", section.getValues().get("apiKey")); + assertEquals("daily", section.getValues().get("reindexSchedulePreset")); + assertEquals(true, section.getValues().get("ragSyncEnabled")); + assertEquals("golemcore/lightrag", section.getValues().get("targetRagProviderId")); + } + + @Test + void shouldReturnOkWhenConnectionTestSucceeds() { + config.setApiKey("secret"); + config.setRootPageId("root-page"); + when(apiClient.retrievePageTitle("root-page")).thenReturn("Knowledge Vault"); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertEquals("Connected to Notion. Root page: Knowledge Vault.", result.getMessage()); + verify(apiClient).retrievePageTitle("root-page"); + } + + @Test + void shouldReturnErrorWhenApiKeyIsMissing() { + config.setRootPageId("root-page"); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Notion API key is not configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenRootPageIdIsMissing() { + config.setApiKey("secret"); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Root page ID is not configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionTestFails() { + config.setApiKey("secret"); + config.setRootPageId("root-page"); + when(apiClient.retrievePageTitle("root-page")) + .thenThrow(new NotionTransportException("Notion transport failed: timeout", null)); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: Notion transport failed: timeout", result.getMessage()); + } + + @Test + void shouldRunReindexActionWhenRequested() { + when(reindexCoordinator.reindexNow()).thenReturn(new NotionReindexSummary(4, 7, 3)); + + PluginActionResult result = contributor.executeAction("main", "reindex-now", Map.of()); + + assertEquals("ok", result.getStatus()); + assertEquals("Reindex completed. Indexed 4 page(s), 7 chunk(s), and synced 3 document(s).", + result.getMessage()); + verify(reindexCoordinator).reindexNow(); + } + + @Test + void shouldReturnErrorWhenReindexActionFails() { + when(reindexCoordinator.reindexNow()).thenThrow(new IllegalStateException("No indexing target is enabled.")); + + PluginActionResult result = contributor.executeAction("main", "reindex-now", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Reindex failed: No indexing target is enabled.", result.getMessage()); + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultServiceTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultServiceTest.java new file mode 100644 index 0000000..fa72459 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultServiceTest.java @@ -0,0 +1,278 @@ +package me.golemcore.plugins.golemcore.notion; + +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; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.notion.support.NotionApiClient; +import me.golemcore.plugins.golemcore.notion.support.NotionLocalIndexService; +import me.golemcore.plugins.golemcore.notion.support.NotionSearchHit; +import me.golemcore.plugins.golemcore.notion.support.NotionPageSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionRagSyncService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.verify; + +class NotionVaultServiceTest { + + private NotionPluginConfigService configService; + private StubNotionApiClient apiClient; + private NotionLocalIndexService localIndexService; + private NotionRagSyncService ragSyncService; + private NotionVaultService service; + + @BeforeEach + void setUp() { + configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(config(true, true, true, true, 12_000)); + apiClient = new StubNotionApiClient(configService); + localIndexService = mock(NotionLocalIndexService.class); + ragSyncService = mock(NotionRagSyncService.class); + service = new NotionVaultService(apiClient, configService, localIndexService, ragSyncService); + } + + @Test + void shouldListRootChildrenUsingPseudoPaths() { + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.addChild("root-page", "inbox-page", "Inbox"); + + ToolResult result = service.listDirectory(""); + + assertTrue(result.isSuccess()); + assertEquals(List.of("root-page"), apiClient.listChildCalls); + assertEquals("Listed 2 item(s) in /", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("", data.get("path")); + assertEquals(List.of("Projects", "Inbox"), data.get("entries")); + } + + @Test + void shouldReadRootPageWhenPathIsBlankAndRespectMaxChars() { + when(configService.getConfig()).thenReturn(config(true, true, true, true, 5)); + apiClient.pageMarkdown.put("root-page", "123456789"); + + ToolResult result = service.readNote(""); + + assertTrue(result.isSuccess()); + assertEquals(List.of("root-page"), apiClient.readMarkdownCalls); + assertEquals("12345", result.getOutput()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("", data.get("path")); + assertEquals(true, data.get("truncated")); + assertEquals(9, data.get("originalLength")); + } + + @Test + void shouldSearchNotesUsingLocalIndex() { + when(configService.getConfig()).thenReturn(config(true, true, true, true, 12_000, true)); + when(localIndexService.search("deployment", "Projects", 3)).thenReturn(List.of( + new NotionSearchHit("chunk-1", "todo-page", "Projects/Todo", "Todo", + "deployment checklist ...", "Deploy"))); + + ToolResult result = service.searchNotes("deployment", "Projects", 3); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("deployment", data.get("query")); + assertEquals("Projects", data.get("path")); + assertEquals(1, data.get("count")); + assertEquals(List.of(Map.of( + "chunk_id", "chunk-1", + "page_id", "todo-page", + "path", "Projects/Todo", + "title", "Todo", + "heading_path", "Deploy", + "snippet", "deployment checklist ...")), data.get("results")); + } + + @Test + void shouldRejectSearchWhenLocalIndexDisabled() { + ToolResult result = service.searchNotes("deployment", null, 5); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertTrue(result.getError().contains("local index")); + } + + @Test + void shouldCreateChildPageUsingParentPseudoPathAndLeafTitle() { + apiClient.addChild("root-page", "projects-page", "Projects"); + + ToolResult result = service.createNote("Projects/Todo", "# Todo\n\nBody"); + + assertTrue(result.isSuccess()); + assertEquals(List.of(new CreateCall("projects-page", "Todo", "# Todo\n\nBody")), apiClient.createCalls); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("Projects/Todo", data.get("path")); + assertEquals("todo-page", data.get("page_id")); + verify(ragSyncService).upsertDocument( + "todo-page", + "Projects/Todo", + "Todo", + "# Todo\n\nBody", + "https://notion.so/todo-page"); + } + + @Test + void shouldArchiveResolvedPageWhenDeleteEnabled() { + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.addChild("projects-page", "todo-page", "Todo"); + + ToolResult result = service.deleteNote("Projects/Todo"); + + assertTrue(result.isSuccess()); + assertEquals(List.of("root-page", "projects-page"), apiClient.listChildCalls); + assertEquals(List.of("todo-page"), apiClient.archiveCalls); + verify(ragSyncService).deleteDocument("todo-page"); + } + + @Test + void shouldMoveAndRenamePageWhenTargetPathChangesParentAndLeaf() { + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.addChild("root-page", "archive-page", "Archive"); + apiClient.addChild("projects-page", "todo-page", "Todo"); + apiClient.pageMarkdown.put("todo-page", "# Todo"); + + ToolResult result = service.moveNote("Projects/Todo", "Archive/Done"); + + assertTrue(result.isSuccess()); + assertEquals(List.of(new MoveCall("todo-page", "archive-page")), apiClient.moveCalls); + assertEquals(List.of(new RenameCall("todo-page", "Done")), apiClient.renameCalls); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("Projects/Todo", data.get("path")); + assertEquals("Archive/Done", data.get("target_path")); + verify(ragSyncService).upsertDocument( + "todo-page", + "Archive/Done", + "Done", + "# Todo", + "https://notion.so/todo-page"); + } + + @Test + void shouldRejectDeletingConfiguredRootPage() { + ToolResult result = service.deleteNote(""); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.EXECUTION_FAILED, result.getFailureKind()); + assertTrue(result.getError().contains("root page")); + assertTrue(apiClient.archiveCalls.isEmpty()); + } + + private NotionPluginConfig config( + boolean allowWrite, + boolean allowDelete, + boolean allowMove, + boolean allowRename, + int maxReadChars) { + return config(allowWrite, allowDelete, allowMove, allowRename, maxReadChars, false); + } + + private NotionPluginConfig config( + boolean allowWrite, + boolean allowDelete, + boolean allowMove, + boolean allowRename, + int maxReadChars, + boolean localIndexEnabled) { + return NotionPluginConfig.builder() + .enabled(true) + .baseUrl("https://api.notion.com") + .apiVersion("2026-03-11") + .apiKey("secret") + .rootPageId("root-page") + .timeoutMs(30_000) + .maxReadChars(maxReadChars) + .allowWrite(allowWrite) + .allowDelete(allowDelete) + .allowMove(allowMove) + .allowRename(allowRename) + .localIndexEnabled(localIndexEnabled) + .reindexSchedulePreset("disabled") + .reindexCronExpression("") + .ragSyncEnabled(false) + .targetRagProviderId("") + .ragCorpusId("notion") + .build(); + } + + private static final class StubNotionApiClient extends NotionApiClient { + + private final Map> childrenByParent = new LinkedHashMap<>(); + private final Map pageMarkdown = new LinkedHashMap<>(); + private final Map pageTitles = new LinkedHashMap<>(); + private final List listChildCalls = new ArrayList<>(); + private final List readMarkdownCalls = new ArrayList<>(); + private final List createCalls = new ArrayList<>(); + private final List archiveCalls = new ArrayList<>(); + private final List moveCalls = new ArrayList<>(); + private final List renameCalls = new ArrayList<>(); + + private StubNotionApiClient(NotionPluginConfigService configService) { + super(configService); + pageTitles.put("root-page", "Root"); + } + + @Override + public String retrievePageTitle(String pageId) { + return pageTitles.getOrDefault(pageId, ""); + } + + @Override + public List listChildPages(String parentPageId) { + listChildCalls.add(parentPageId); + return childrenByParent.getOrDefault(parentPageId, List.of()); + } + + @Override + public String retrievePageMarkdown(String pageId) { + readMarkdownCalls.add(pageId); + return pageMarkdown.getOrDefault(pageId, ""); + } + + @Override + public NotionPageSummary createChildPage(String parentPageId, String title, String markdown) { + createCalls.add(new CreateCall(parentPageId, title, markdown)); + return new NotionPageSummary("todo-page", title, "https://notion.so/todo-page"); + } + + @Override + public void archivePage(String pageId) { + archiveCalls.add(pageId); + } + + @Override + public void movePage(String pageId, String targetParentPageId) { + moveCalls.add(new MoveCall(pageId, targetParentPageId)); + } + + @Override + public void renamePage(String pageId, String title) { + renameCalls.add(new RenameCall(pageId, title)); + } + + private void addChild(String parentPageId, String pageId, String title) { + childrenByParent.computeIfAbsent(parentPageId, ignored -> new ArrayList<>()) + .add(new NotionPageSummary(pageId, title, "https://notion.so/" + pageId)); + } + } + + private record CreateCall(String parentPageId, String title, String markdown) { + } + + private record MoveCall(String pageId, String targetParentPageId) { + } + + private record RenameCall(String pageId, String title) { + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProviderTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProviderTest.java new file mode 100644 index 0000000..74f52bd --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProviderTest.java @@ -0,0 +1,167 @@ +package me.golemcore.plugins.golemcore.notion; + +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; + +import java.util.List; +import java.util.Map; + +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; + +class NotionVaultToolProviderTest { + + private NotionVaultService service; + private NotionVaultToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(NotionVaultService.class); + provider = new NotionVaultToolProvider(service); + } + + @Test + void shouldExposeSupportedVaultOperations() { + ToolDefinition definition = provider.getDefinition(); + + Map schema = definition.getInputSchema(); + Map properties = (Map) schema.get("properties"); + Map operation = (Map) properties.get("operation"); + + assertEquals("notion_vault", definition.getName()); + assertEquals(List.of( + "list_directory", + "search_notes", + "read_note", + "create_note", + "update_note", + "delete_note", + "move_note", + "rename_note"), operation.get("enum")); + } + + @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 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 shouldDispatchReadNoteToVaultService() { + when(service.readNote("Projects/Todo")).thenReturn(ToolResult.success("read")); + + ToolResult result = provider.execute(Map.of( + "operation", "read_note", + "path", "Projects/Todo")).join(); + + assertTrue(result.isSuccess()); + verify(service).readNote("Projects/Todo"); + } + + @Test + void shouldDispatchSearchNotesToVaultService() { + when(service.searchNotes("deployment", "Projects", 3)).thenReturn(ToolResult.success("searched")); + + ToolResult result = provider.execute(Map.of( + "operation", "search_notes", + "query", "deployment", + "path", "Projects", + "limit", 3)).join(); + + assertTrue(result.isSuccess()); + verify(service).searchNotes("deployment", "Projects", 3); + } + + @Test + void shouldDispatchCreateToVaultService() { + when(service.createNote("Projects/Todo", "# Todo")).thenReturn(ToolResult.success("created")); + + ToolResult result = provider.execute(Map.of( + "operation", "create_note", + "path", "Projects/Todo", + "content", "# Todo")).join(); + + assertTrue(result.isSuccess()); + verify(service).createNote("Projects/Todo", "# Todo"); + } + + @Test + void shouldDispatchUpdateToVaultService() { + when(service.updateNote("Projects/Todo", "# Updated")).thenReturn(ToolResult.success("updated")); + + ToolResult result = provider.execute(Map.of( + "operation", "update_note", + "path", "Projects/Todo", + "content", "# Updated")).join(); + + assertTrue(result.isSuccess()); + verify(service).updateNote("Projects/Todo", "# Updated"); + } + + @Test + void shouldDispatchDeleteToVaultService() { + when(service.deleteNote("Projects/Todo")).thenReturn(ToolResult.success("deleted")); + + ToolResult result = provider.execute(Map.of( + "operation", "delete_note", + "path", "Projects/Todo")).join(); + + assertTrue(result.isSuccess()); + verify(service).deleteNote("Projects/Todo"); + } + + @Test + void shouldDispatchMoveToVaultService() { + when(service.moveNote("Projects/Todo", "Archive/Done")).thenReturn(ToolResult.success("moved")); + + ToolResult result = provider.execute(Map.of( + "operation", "move_note", + "path", "Projects/Todo", + "target_path", "Archive/Done")).join(); + + assertTrue(result.isSuccess()); + verify(service).moveNote("Projects/Todo", "Archive/Done"); + } + + @Test + void shouldDispatchRenameToVaultService() { + when(service.renameNote("Projects/Todo", "Done")).thenReturn(ToolResult.success("renamed")); + + ToolResult result = provider.execute(Map.of( + "operation", "rename_note", + "path", "Projects/Todo", + "new_name", "Done")).join(); + + assertTrue(result.isSuccess()); + verify(service).renameNote("Projects/Todo", "Done"); + } + + @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/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionApiClientTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionApiClientTest.java new file mode 100644 index 0000000..1bbf632 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionApiClientTest.java @@ -0,0 +1,268 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NotionApiClientTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private HttpServer server; + private NotionPluginConfigService configService; + private NotionApiClient client; + private Queue responses; + private List requests; + + @BeforeEach + void setUp() throws IOException { + responses = new ArrayDeque<>(); + requests = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", this::handle); + server.start(); + + configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(NotionPluginConfig.builder() + .enabled(true) + .baseUrl(baseUrl()) + .apiVersion("2026-03-11") + .apiKey("secret") + .rootPageId("root-page") + .timeoutMs(5_000) + .maxReadChars(12_000) + .allowWrite(true) + .allowDelete(true) + .allowMove(true) + .allowRename(true) + .localIndexEnabled(false) + .reindexSchedulePreset("disabled") + .reindexCronExpression("") + .ragSyncEnabled(false) + .targetRagProviderId("") + .ragCorpusId("notion") + .build()); + client = new NotionApiClient(configService); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void shouldRetrievePageTitleWithNotionHeaders() { + respondJson(200, """ + { + "id": "root-page", + "properties": { + "title": { + "id": "title", + "type": "title", + "title": [ + { "plain_text": "Knowledge Vault" } + ] + } + } + } + """); + + String title = client.retrievePageTitle("root-page"); + + assertEquals("Knowledge Vault", title); + CapturedRequest request = requests.getFirst(); + assertEquals("GET", request.method()); + assertEquals("/v1/pages/root-page", request.path()); + assertEquals("Bearer secret", request.authorization()); + assertEquals("2026-03-11", request.notionVersion()); + } + + @Test + void shouldListChildPagesAcrossPagination() { + respondJson(200, """ + { + "results": [ + { + "id": "projects-id", + "type": "child_page", + "child_page": { "title": "Projects" } + }, + { + "id": "ignored-block", + "type": "paragraph" + } + ], + "has_more": true, + "next_cursor": "next-1" + } + """); + respondJson(200, """ + { + "results": [ + { + "id": "inbox-id", + "type": "child_page", + "child_page": { "title": "Inbox" } + } + ], + "has_more": false + } + """); + + List pages = client.listChildPages("root-page"); + + assertEquals(List.of("Projects", "Inbox"), pages.stream().map(NotionPageSummary::title).toList()); + assertEquals("/v1/blocks/root-page/children?page_size=100", requests.get(0).path()); + assertEquals("/v1/blocks/root-page/children?page_size=100&start_cursor=next-1", requests.get(1).path()); + } + + @Test + void shouldRetrieveMarkdownFromMarkdownEndpoint() { + respondJson(200, """ + { + "object": "page_markdown", + "id": "todo-id", + "markdown": "# Todo\\n\\nBody", + "truncated": false, + "unknown_block_ids": [] + } + """); + + String markdown = client.retrievePageMarkdown("todo-id"); + + assertEquals("# Todo\n\nBody", markdown); + assertEquals("/v1/pages/todo-id/markdown", requests.getFirst().path()); + } + + @Test + void shouldCreateChildPageWithExplicitTitleAndMarkdown() throws Exception { + respondJson(200, """ + { + "id": "todo-id", + "url": "https://notion.so/todo-id", + "properties": { + "title": { + "id": "title", + "type": "title", + "title": [ + { "plain_text": "Todo" } + ] + } + } + } + """); + + NotionPageSummary page = client.createChildPage("projects-id", "Todo", "# Todo\n\nBody"); + + assertEquals("todo-id", page.id()); + assertEquals("Todo", page.title()); + JsonNode body = objectMapper.readTree(requests.getFirst().body()); + assertEquals("projects-id", body.path("parent").path("page_id").asText()); + assertEquals("# Todo\n\nBody", body.path("markdown").asText()); + assertEquals("Todo", + body.path("properties").path("title").path("title").get(0).path("text").path("content").asText()); + } + + @Test + void shouldReplaceWholePageContentWhenUpdatingMarkdown() throws Exception { + respondJson(200, """ + { + "object": "page_markdown", + "id": "todo-id", + "markdown": "# Fresh", + "truncated": false, + "unknown_block_ids": [] + } + """); + + client.updatePageMarkdown("todo-id", "# Fresh"); + + CapturedRequest request = requests.getFirst(); + assertEquals("PATCH", request.method()); + assertEquals("/v1/pages/todo-id/markdown", request.path()); + JsonNode body = objectMapper.readTree(request.body()); + assertEquals("replace_content", body.path("type").asText()); + assertEquals("# Fresh", body.path("replace_content").path("new_str").asText()); + } + + @Test + void shouldArchiveMoveAndRenamePagesUsingOfficialEndpoints() throws Exception { + respondJson(200, "{\"id\":\"todo-id\"}"); + respondJson(200, "{\"id\":\"todo-id\"}"); + respondJson(200, "{\"id\":\"todo-id\"}"); + + client.archivePage("todo-id"); + client.movePage("todo-id", "archive-id"); + client.renamePage("todo-id", "Done"); + + assertEquals("PATCH", requests.get(0).method()); + assertEquals("/v1/pages/todo-id", requests.get(0).path()); + assertTrue(objectMapper.readTree(requests.get(0).body()).path("archived").asBoolean()); + + assertEquals("POST", requests.get(1).method()); + assertEquals("/v1/pages/todo-id/move", requests.get(1).path()); + assertEquals("archive-id", + objectMapper.readTree(requests.get(1).body()).path("parent").path("page_id").asText()); + + assertEquals("PATCH", requests.get(2).method()); + assertEquals("/v1/pages/todo-id", requests.get(2).path()); + assertEquals("Done", objectMapper.readTree(requests.get(2).body()) + .path("properties").path("title").path("title").get(0).path("text").path("content").asText()); + } + + private void respondJson(int status, String body) { + responses.add(new StubResponse(status, body)); + } + + private void handle(HttpExchange exchange) throws IOException { + byte[] requestBody = exchange.getRequestBody().readAllBytes(); + requests.add(new CapturedRequest( + exchange.getRequestMethod(), + exchange.getRequestURI().toString(), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("Notion-Version"), + new String(requestBody, StandardCharsets.UTF_8))); + StubResponse response = responses.remove(); + byte[] responseBytes = response.body().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(response.status(), responseBytes.length); + exchange.getResponseBody().write(responseBytes); + exchange.close(); + } + + private String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private record StubResponse(int status, String body) { + } + + private record CapturedRequest( + String method, + String path, + String authorization, + String notionVersion, + String body) { + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionLocalIndexServiceTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionLocalIndexServiceTest.java new file mode 100644 index 0000000..7c3c553 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionLocalIndexServiceTest.java @@ -0,0 +1,144 @@ +package me.golemcore.plugins.golemcore.notion.support; + +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.when; + +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +class NotionLocalIndexServiceTest { + + @TempDir + Path tempDir; + + private NotionPluginConfigService configService; + private StubNotionApiClient apiClient; + private NotionLocalIndexService service; + + @BeforeEach + void setUp() { + configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(NotionPluginConfig.builder() + .enabled(true) + .baseUrl("https://api.notion.com") + .apiVersion("2026-03-11") + .apiKey("secret") + .rootPageId("root-page") + .timeoutMs(30_000) + .maxReadChars(12_000) + .allowWrite(true) + .allowDelete(true) + .allowMove(true) + .allowRename(true) + .localIndexEnabled(true) + .reindexSchedulePreset("disabled") + .reindexCronExpression("") + .ragSyncEnabled(false) + .targetRagProviderId("") + .ragCorpusId("notion") + .build()); + apiClient = new StubNotionApiClient(configService); + service = new NotionLocalIndexService(apiClient, configService, new NotionStoragePaths(tempDir)); + } + + @Test + void shouldReindexVaultTreeAndSearchByPseudoPathPrefix() { + apiClient.rootTitle = "Workspace"; + apiClient.pageMarkdown.put("root-page", "Workspace overview"); + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.addChild("root-page", "archive-page", "Archive"); + apiClient.pageMarkdown.put("projects-page", "Project overview"); + apiClient.pageMarkdown.put("archive-page", "Historical notes"); + apiClient.addChild("projects-page", "todo-page", "Todo"); + apiClient.addChild("archive-page", "logs-page", "Logs"); + apiClient.pageMarkdown.put("todo-page", "# Deploy\n\nDeployment checklist for production release."); + apiClient.pageMarkdown.put("logs-page", "# Deploy\n\nOld deployment notes from last quarter."); + + NotionReindexSummary summary = service.reindexAll(); + + assertEquals(5, summary.pagesIndexed()); + List filtered = service.search("deployment release", "Projects", 10); + assertEquals(1, filtered.size()); + assertEquals("Projects/Todo", filtered.getFirst().path()); + assertEquals("Todo", filtered.getFirst().title()); + assertTrue(filtered.getFirst().snippet().toLowerCase(Locale.ROOT).contains("deployment")); + assertEquals("Deploy", filtered.getFirst().headingPath()); + + List unfiltered = service.search("deployment", "", 10); + assertEquals(2, unfiltered.size()); + assertEquals("Projects/Todo", unfiltered.getFirst().path()); + assertTrue(unfiltered.stream().anyMatch(hit -> "Archive/Logs".equals(hit.path()))); + } + + @Test + void shouldReturnEmptyResultsForBlankQuery() { + apiClient.rootTitle = "Workspace"; + apiClient.pageMarkdown.put("root-page", "Workspace overview"); + + service.reindexAll(); + List results = service.search(" ", "", 5); + + assertTrue(results.isEmpty()); + } + + @Test + void shouldOverwritePreviousChunksWhenReindexingAgain() { + apiClient.rootTitle = "Workspace"; + apiClient.pageMarkdown.put("root-page", "Workspace overview"); + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.pageMarkdown.put("projects-page", "alpha beta"); + + service.reindexAll(); + assertFalse(service.search("gamma", "", 5).stream().findAny().isPresent()); + + apiClient.pageMarkdown.put("projects-page", "beta gamma"); + service.reindexAll(); + + assertEquals(1, service.search("gamma", "", 5).size()); + assertEquals(0, service.search("alpha", "", 5).size()); + } + + private static final class StubNotionApiClient extends NotionApiClient { + + private String rootTitle = ""; + private final Map> childrenByParent = new LinkedHashMap<>(); + private final Map pageMarkdown = new LinkedHashMap<>(); + + private StubNotionApiClient(NotionPluginConfigService configService) { + super(configService); + } + + @Override + public String retrievePageTitle(String pageId) { + return "root-page".equals(pageId) ? rootTitle : ""; + } + + @Override + public List listChildPages(String parentPageId) { + return childrenByParent.getOrDefault(parentPageId, List.of()); + } + + @Override + public String retrievePageMarkdown(String pageId) { + return pageMarkdown.getOrDefault(pageId, ""); + } + + private void addChild(String parentPageId, String pageId, String title) { + childrenByParent.computeIfAbsent(parentPageId, ignored -> new ArrayList<>()) + .add(new NotionPageSummary(pageId, title, "https://notion.so/" + pageId)); + } + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionRagSyncServiceTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionRagSyncServiceTest.java new file mode 100644 index 0000000..b2b74cd --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionRagSyncServiceTest.java @@ -0,0 +1,196 @@ +package me.golemcore.plugins.golemcore.notion.support; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.golemcore.plugin.api.extension.model.rag.RagCorpusRef; +import me.golemcore.plugin.api.extension.model.rag.RagDocument; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionCapabilities; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionResult; +import me.golemcore.plugin.api.runtime.RagIngestionService; +import me.golemcore.plugin.api.runtime.model.RagIngestionTargetDescriptor; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +class NotionRagSyncServiceTest { + + private NotionPluginConfigService configService; + private RagIngestionService ragIngestionService; + private StubNotionApiClient apiClient; + private NotionRagSyncService service; + + @BeforeEach + void setUp() { + configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(config(true)); + ragIngestionService = mock(RagIngestionService.class); + when(ragIngestionService.listInstalledTargets()).thenReturn(List.of(new RagIngestionTargetDescriptor( + "golemcore/lightrag", + "golemcore/lightrag", + "golemcore/lightrag", + new RagIngestionCapabilities(true, true, true, 2)))); + apiClient = new StubNotionApiClient(configService); + service = new NotionRagSyncService(configService, apiClient, ragIngestionService); + } + + @Test + void shouldUpsertSingleDocumentForMutationSync() { + when(ragIngestionService.upsertDocuments( + eq("golemcore/lightrag"), + eq(new RagCorpusRef("notion", "notion")), + eq(List.of(new RagDocument( + "todo-page", + "Todo", + "Projects/Todo", + "# Todo", + "https://notion.so/todo-page", + Map.of("source", "notion", "page_id", "todo-page")))))) + .thenReturn(CompletableFuture.completedFuture( + new RagIngestionResult("accepted", 1, 0, null, "ok"))); + + service.upsertDocument("todo-page", "Projects/Todo", "Todo", "# Todo", "https://notion.so/todo-page"); + + verify(ragIngestionService).upsertDocuments( + "golemcore/lightrag", + new RagCorpusRef("notion", "notion"), + List.of(new RagDocument( + "todo-page", + "Todo", + "Projects/Todo", + "# Todo", + "https://notion.so/todo-page", + Map.of("source", "notion", "page_id", "todo-page")))); + } + + @Test + void shouldMarkFullReindexRequiredWhenDeleteIsUnsupported() { + when(ragIngestionService.listInstalledTargets()).thenReturn(List.of(new RagIngestionTargetDescriptor( + "golemcore/lightrag", + "golemcore/lightrag", + "golemcore/lightrag", + new RagIngestionCapabilities(false, false, false, 32)))); + + service.deleteDocument("todo-page"); + + assertTrue(service.isFullReindexRequired()); + } + + @Test + void shouldResetAndBulkUpsertDocumentsDuringFullReindex() { + apiClient.pageTitles.put("root-page", "Workspace"); + apiClient.pageMarkdown.put("root-page", "# Workspace"); + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.pageMarkdown.put("projects-page", "# Projects"); + apiClient.addChild("projects-page", "todo-page", "Todo"); + apiClient.pageMarkdown.put("todo-page", "# Todo"); + when(ragIngestionService.resetCorpus( + "golemcore/lightrag", + new RagCorpusRef("notion", "notion"))) + .thenReturn(CompletableFuture.completedFuture( + new RagIngestionResult("accepted", 0, 0, null, "ok"))); + when(ragIngestionService.upsertDocuments( + eq("golemcore/lightrag"), + eq(new RagCorpusRef("notion", "notion")), + eq(List.of( + new RagDocument( + "root-page", + "Workspace", + "", + "# Workspace", + "", + Map.of("source", "notion", "page_id", "root-page")), + new RagDocument( + "projects-page", + "Projects", + "Projects", + "# Projects", + "https://notion.so/projects-page", + Map.of("source", "notion", "page_id", "projects-page")))))) + .thenReturn(CompletableFuture.completedFuture( + new RagIngestionResult("accepted", 2, 0, null, "ok"))); + when(ragIngestionService.upsertDocuments( + eq("golemcore/lightrag"), + eq(new RagCorpusRef("notion", "notion")), + eq(List.of(new RagDocument( + "todo-page", + "Todo", + "Projects/Todo", + "# Todo", + "https://notion.so/todo-page", + Map.of("source", "notion", "page_id", "todo-page")))))) + .thenReturn(CompletableFuture.completedFuture( + new RagIngestionResult("accepted", 1, 0, null, "ok"))); + + int synced = service.reindexAll(); + + assertEquals(3, synced); + assertFalse(service.isFullReindexRequired()); + verify(ragIngestionService).resetCorpus("golemcore/lightrag", new RagCorpusRef("notion", "notion")); + } + + private NotionPluginConfig config(boolean ragSyncEnabled) { + return NotionPluginConfig.builder() + .enabled(true) + .baseUrl("https://api.notion.com") + .apiVersion("2026-03-11") + .apiKey("secret") + .rootPageId("root-page") + .timeoutMs(30_000) + .maxReadChars(12_000) + .allowWrite(true) + .allowDelete(true) + .allowMove(true) + .allowRename(true) + .localIndexEnabled(false) + .reindexSchedulePreset("disabled") + .reindexCronExpression("") + .ragSyncEnabled(ragSyncEnabled) + .targetRagProviderId("golemcore/lightrag") + .ragCorpusId("notion") + .build(); + } + + private static final class StubNotionApiClient extends NotionApiClient { + + private final Map> childrenByParent = new LinkedHashMap<>(); + private final Map pageMarkdown = new LinkedHashMap<>(); + private final Map pageTitles = new LinkedHashMap<>(); + + private StubNotionApiClient(NotionPluginConfigService configService) { + super(configService); + } + + @Override + public String retrievePageTitle(String pageId) { + return pageTitles.getOrDefault(pageId, ""); + } + + @Override + public List listChildPages(String parentPageId) { + return childrenByParent.getOrDefault(parentPageId, List.of()); + } + + @Override + public String retrievePageMarkdown(String pageId) { + return pageMarkdown.getOrDefault(pageId, ""); + } + + private void addChild(String parentPageId, String pageId, String title) { + childrenByParent.computeIfAbsent(parentPageId, ignored -> new ArrayList<>()) + .add(new NotionPageSummary(pageId, title, "https://notion.so/" + pageId)); + } + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexCoordinatorTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexCoordinatorTest.java new file mode 100644 index 0000000..aaa33c5 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexCoordinatorTest.java @@ -0,0 +1,216 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.Callable; +import java.util.concurrent.Delayed; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +class NotionReindexCoordinatorTest { + + @Test + void shouldScheduleNextRunForFriendlyPreset() { + NotionPluginConfigService configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(NotionPluginConfig.builder() + .localIndexEnabled(true) + .reindexSchedulePreset("hourly") + .build()); + NotionLocalIndexService indexService = mock(NotionLocalIndexService.class); + try (RecordingScheduler scheduler = new RecordingScheduler()) { + NotionReindexCoordinator coordinator = new NotionReindexCoordinator( + configService, + indexService, + mock(NotionRagSyncService.class), + new NotionReindexScheduleResolver(), + scheduler, + Clock.fixed(Instant.parse("2026-03-30T15:30:00Z"), ZoneId.of("UTC"))); + + coordinator.refreshSchedule(); + + assertTrue(scheduler.lastDelayMillis > 0); + } + } + + @Test + void shouldDelegateManualReindexWhenIndexEnabled() { + NotionPluginConfigService configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(NotionPluginConfig.builder() + .localIndexEnabled(true) + .ragSyncEnabled(true) + .reindexSchedulePreset("disabled") + .build()); + NotionLocalIndexService indexService = mock(NotionLocalIndexService.class); + when(indexService.reindexAll()).thenReturn(new NotionReindexSummary(3, 9)); + NotionRagSyncService ragSyncService = mock(NotionRagSyncService.class); + when(ragSyncService.reindexAll()).thenReturn(4); + + try (RecordingScheduler scheduler = new RecordingScheduler()) { + NotionReindexCoordinator coordinator = new NotionReindexCoordinator( + configService, + indexService, + ragSyncService, + new NotionReindexScheduleResolver(), + scheduler, + Clock.systemUTC()); + + NotionReindexSummary summary = coordinator.reindexNow(); + + assertEquals(3, summary.pagesIndexed()); + assertEquals(9, summary.chunksIndexed()); + assertEquals(4, summary.documentsSynced()); + verify(indexService).reindexAll(); + verify(ragSyncService).reindexAll(); + } + } + + @Test + void shouldRejectManualReindexWhenNoIndexTargetIsEnabled() { + NotionPluginConfigService configService = mock(NotionPluginConfigService.class); + when(configService.getConfig()).thenReturn(NotionPluginConfig.builder() + .localIndexEnabled(false) + .ragSyncEnabled(false) + .build()); + + try (RecordingScheduler scheduler = new RecordingScheduler()) { + NotionReindexCoordinator coordinator = new NotionReindexCoordinator( + configService, + mock(NotionLocalIndexService.class), + mock(NotionRagSyncService.class), + new NotionReindexScheduleResolver(), + scheduler, + Clock.systemUTC()); + + IllegalStateException error = assertThrows(IllegalStateException.class, coordinator::reindexNow); + assertEquals("No indexing target is enabled.", error.getMessage()); + } + } + + private static final class RecordingScheduler extends AbstractExecutorService implements ScheduledExecutorService { + + private long lastDelayMillis = -1; + + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + lastDelayMillis = TimeUnit.MILLISECONDS.convert(delay, unit); + return new CompletedScheduledFuture(); + } + + @Override + public void shutdown() { + } + + @Override + public List shutdownNow() { + return List.of(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void close() { + shutdownNow(); + } + + @Override + public void execute(Runnable command) { + command.run(); + } + + @Override + public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay( + Runnable command, + long initialDelay, + long delay, + TimeUnit unit) { + throw new UnsupportedOperationException(); + } + } + + private static final class CompletedScheduledFuture implements ScheduledFuture { + + @Override + public long getDelay(TimeUnit unit) { + return 0; + } + + @Override + public int compareTo(Delayed other) { + return 0; + } + + @Override + public boolean equals(Object other) { + return other instanceof CompletedScheduledFuture; + } + + @Override + public int hashCode() { + return CompletedScheduledFuture.class.hashCode(); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return true; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public Object get() { + return null; + } + + @Override + public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException { + return null; + } + } +} diff --git a/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexScheduleResolverTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexScheduleResolverTest.java new file mode 100644 index 0000000..88c6c46 --- /dev/null +++ b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/support/NotionReindexScheduleResolverTest.java @@ -0,0 +1,48 @@ +package me.golemcore.plugins.golemcore.notion.support; + +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 me.golemcore.plugins.golemcore.notion.NotionPluginConfig; +import org.junit.jupiter.api.Test; + +class NotionReindexScheduleResolverTest { + + private final NotionReindexScheduleResolver resolver = new NotionReindexScheduleResolver(); + + @Test + void shouldResolveFriendlyPresetToCronExpression() { + NotionPluginConfig config = NotionPluginConfig.builder() + .localIndexEnabled(true) + .reindexSchedulePreset("daily") + .build(); + + assertEquals("0 0 3 * * *", resolver.resolveCronExpression(config).orElseThrow()); + } + + @Test + void shouldUseValidatedCustomCronExpression() { + NotionPluginConfig config = NotionPluginConfig.builder() + .localIndexEnabled(true) + .reindexSchedulePreset("custom") + .reindexCronExpression("0 15 * * * *") + .build(); + + assertEquals("0 15 * * * *", resolver.resolveCronExpression(config).orElseThrow()); + } + + @Test + void shouldReturnEmptyWhenIndexingIsDisabledOrExpressionInvalid() { + assertTrue(resolver.resolveCronExpression(NotionPluginConfig.builder() + .localIndexEnabled(false) + .reindexSchedulePreset("hourly") + .build()).isEmpty()); + + assertFalse(resolver.resolveCronExpression(NotionPluginConfig.builder() + .localIndexEnabled(true) + .reindexSchedulePreset("custom") + .reindexCronExpression("not-a-cron") + .build()).isPresent()); + } +} diff --git a/pom.xml b/pom.xml index 8a49ce9..cd3edb3 100644 --- a/pom.xml +++ b/pom.xml @@ -40,7 +40,7 @@ UTF-8 UTF-8 2026-03-07T00:00:00Z - 1.1.0 + 1.2.0 4.0.3 5.3.2 1.49.0 @@ -76,6 +76,7 @@ golemcore/brave-search golemcore/browserless golemcore/obsidian + golemcore/notion golemcore/tavily-search golemcore/firecrawl golemcore/perplexity-sonar diff --git a/registry/golemcore/lightrag/index.yaml b/registry/golemcore/lightrag/index.yaml index b550f3f..0a0264e 100644 --- a/registry/golemcore/lightrag/index.yaml +++ b/registry/golemcore/lightrag/index.yaml @@ -1,7 +1,8 @@ id: golemcore/lightrag owner: golemcore name: lightrag -latest: 1.0.0 +latest: 1.1.0 versions: - 1.0.0 -source: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/lightrag + - 1.1.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/lightrag" diff --git a/registry/golemcore/lightrag/versions/1.1.0.yaml b/registry/golemcore/lightrag/versions/1.1.0.yaml new file mode 100644 index 0000000..49ea117 --- /dev/null +++ b/registry/golemcore/lightrag/versions/1.1.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/lightrag +version: 1.1.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/lightrag/1.1.0/golemcore-lightrag-plugin-1.1.0.jar" +publishedAt: "2026-03-30T20:05:51Z" +sourceCommit: "71737dfd22753f733dd8662ba590a734115f625f" +entrypoint: me.golemcore.plugins.golemcore.lightrag.LightRagPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/lightrag" +license: "Apache-2.0" +maintainers: + - alexk-dev diff --git a/registry/golemcore/notion/index.yaml b/registry/golemcore/notion/index.yaml new file mode 100644 index 0000000..44f3870 --- /dev/null +++ b/registry/golemcore/notion/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/notion +owner: golemcore +name: notion +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/notion" diff --git a/registry/golemcore/notion/versions/1.0.0.yaml b/registry/golemcore/notion/versions/1.0.0.yaml new file mode 100644 index 0000000..527584f --- /dev/null +++ b/registry/golemcore/notion/versions/1.0.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/notion +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/notion/1.0.0/golemcore-notion-plugin-1.0.0.jar" +publishedAt: "2026-03-30T20:04:39Z" +sourceCommit: "71737dfd22753f733dd8662ba590a734115f625f" +entrypoint: me.golemcore.plugins.golemcore.notion.NotionPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/notion" +license: "Apache-2.0" +maintainers: + - alexk-dev diff --git a/runtime-api/pom.xml b/runtime-api/pom.xml index c1a2643..c9a1d4b 100644 --- a/runtime-api/pom.xml +++ b/runtime-api/pom.xml @@ -11,7 +11,7 @@ ../pom.xml - 1.1.0 + 1.2.0 golemcore-plugin-runtime-api golemcore-plugin-runtime-api Host-facing interfaces and DTOs for isolated GolemCore plugins @@ -21,6 +21,11 @@ + + me.golemcore.plugins + golemcore-plugin-extension-api + ${project.version} + org.projectlombok lombok diff --git a/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/RagIngestionService.java b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/RagIngestionService.java new file mode 100644 index 0000000..d2b0df8 --- /dev/null +++ b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/RagIngestionService.java @@ -0,0 +1,47 @@ +package me.golemcore.plugin.api.runtime; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +import me.golemcore.plugin.api.extension.model.rag.RagCorpusRef; +import me.golemcore.plugin.api.extension.model.rag.RagDocument; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionResult; +import me.golemcore.plugin.api.extension.model.rag.RagIngestionStatus; +import me.golemcore.plugin.api.runtime.model.RagIngestionTargetDescriptor; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface RagIngestionService { + + List listInstalledTargets(); + + CompletableFuture upsertDocuments( + String providerId, + RagCorpusRef corpus, + List documents); + + CompletableFuture deleteDocuments( + String providerId, + RagCorpusRef corpus, + List documentIds); + + CompletableFuture resetCorpus(String providerId, RagCorpusRef corpus); + + CompletableFuture getStatus(String providerId, RagCorpusRef corpus); +} diff --git a/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/RagProviderDiscoveryService.java b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/RagProviderDiscoveryService.java new file mode 100644 index 0000000..e6f9704 --- /dev/null +++ b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/RagProviderDiscoveryService.java @@ -0,0 +1,31 @@ +package me.golemcore.plugin.api.runtime; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +import me.golemcore.plugin.api.runtime.model.RagProviderDescriptor; + +import java.util.List; + +/** + * Host-backed discovery service for installed RAG providers. + */ +public interface RagProviderDiscoveryService { + + List listInstalledProviders(); +} diff --git a/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/model/RagIngestionTargetDescriptor.java b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/model/RagIngestionTargetDescriptor.java new file mode 100644 index 0000000..f6447c4 --- /dev/null +++ b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/model/RagIngestionTargetDescriptor.java @@ -0,0 +1,23 @@ +package me.golemcore.plugin.api.runtime.model; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +import me.golemcore.plugin.api.extension.model.rag.RagIngestionCapabilities; + +public record RagIngestionTargetDescriptor(String providerId,String pluginId,String displayName,RagIngestionCapabilities capabilities){} diff --git a/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/model/RagProviderDescriptor.java b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/model/RagProviderDescriptor.java new file mode 100644 index 0000000..0ca0e5d --- /dev/null +++ b/runtime-api/src/main/java/me/golemcore/plugin/api/runtime/model/RagProviderDescriptor.java @@ -0,0 +1,24 @@ +package me.golemcore.plugin.api.runtime.model; + +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + * + * Contact: alex@kuleshov.tech + */ + +/** + * Runtime metadata for an installed plugin-contributed RAG provider. + */ +public record RagProviderDescriptor(String providerId,String pluginId,String displayName){}