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