diff --git a/golemcore/notion/plugin.yaml b/golemcore/notion/plugin.yaml index dbe787d..9530697 100644 --- a/golemcore/notion/plugin.yaml +++ b/golemcore/notion/plugin.yaml @@ -1,12 +1,12 @@ id: golemcore/notion provider: golemcore name: notion -version: 1.0.1 +version: 1.1.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 +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 index 6c434ce..91990e9 100644 --- a/golemcore/notion/pom.xml +++ b/golemcore/notion/pom.xml @@ -11,7 +11,7 @@ ../../pom.xml - 1.0.1 + 1.1.0 golemcore-notion-plugin golemcore/notion Notion vault plugin for GolemCore 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 index f581a59..e83180c 100644 --- 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 @@ -1,17 +1,28 @@ package me.golemcore.plugins.golemcore.notion; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; 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.NotionChildSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionDataSourceQueryResult; +import me.golemcore.plugins.golemcore.notion.support.NotionDataSourceSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionDatabaseSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionFileAttachmentSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionFileUploadSummary; import me.golemcore.plugins.golemcore.notion.support.NotionLocalIndexService; +import me.golemcore.plugins.golemcore.notion.support.NotionPageDetails; import me.golemcore.plugins.golemcore.notion.support.NotionPageSummary; import me.golemcore.plugins.golemcore.notion.support.NotionPathValidator; +import me.golemcore.plugins.golemcore.notion.support.NotionRagSyncService; 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.nio.file.Path; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -24,6 +35,7 @@ public class NotionVaultService { private final NotionLocalIndexService localIndexService; private final NotionRagSyncService ragSyncService; private final NotionPathValidator pathValidator = new NotionPathValidator(); + private final ObjectMapper objectMapper = new ObjectMapper(); public NotionVaultService( NotionApiClient apiClient, @@ -40,15 +52,15 @@ 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(); + List items = apiClient.listChildItems(page.pageId()); + List entries = items.stream().map(NotionChildSummary::title).toList(); Map data = new LinkedHashMap<>(); data.put("path", normalizedPath); data.put("entries", entries); data.put("files", entries); + data.put("items", items.stream().map(this::toChildItem).toList()); return ToolResult.success( - "Listed " + entries.size() + " item(s) in " + displayPath(normalizedPath), + "Listed " + items.size() + " item(s) in " + displayPath(normalizedPath), data); } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { return executionFailure(ex.getMessage()); @@ -64,11 +76,14 @@ public ToolResult readNote(String path) { int maxReadChars = configService.getConfig().getMaxReadChars(); boolean truncated = originalLength > maxReadChars; String visibleContent = truncated ? content.substring(0, maxReadChars) : content; + NotionPageDetails details = apiClient.retrievePageDetails(page.pageId()); Map data = new LinkedHashMap<>(); data.put("path", normalizedPath); + data.put("page_id", page.pageId()); data.put("content", visibleContent); data.put("truncated", truncated); + data.put("files", details.files().stream().map(this::toFileAttachment).toList()); if (truncated) { data.put("originalLength", originalLength); } @@ -108,7 +123,7 @@ public ToolResult createNote(String path, String content) { String parentPath = pathValidator.parentPath(normalizedPath); String title = pathValidator.leafName(normalizedPath); ResolvedPage parentPage = resolveExistingPage(parentPath); - ensureChildAbsent(parentPage.pageId(), title); + ensureChildTitleAbsent(parentPage.pageId(), title); NotionPageSummary created = apiClient.createChildPage(parentPage.pageId(), title, content); refreshIndexesAfterMutation( () -> ragSyncService.upsertDocument( @@ -180,7 +195,7 @@ public ToolResult moveNote(String path, String targetPath) { String targetParentPath = pathValidator.parentPath(normalizedTargetPath); String targetTitle = pathValidator.leafName(normalizedTargetPath); ResolvedPage targetParent = resolveExistingPage(targetParentPath); - ensureChildAbsent(targetParent.pageId(), targetTitle); + ensureChildTitleAbsent(targetParent.pageId(), targetTitle); if (!source.parentPageId().equals(targetParent.pageId())) { apiClient.movePage(source.pageId(), targetParent.pageId()); } @@ -216,7 +231,7 @@ public ToolResult renameNote(String path, String newName) { throw new IllegalArgumentException("Source and target paths must differ"); } ResolvedPage source = resolveExistingPage(normalizedPath); - ensureChildAbsent(source.parentPageId(), pathValidator.leafName(targetPath)); + ensureChildTitleAbsent(source.parentPageId(), pathValidator.leafName(targetPath)); apiClient.renamePage(source.pageId(), pathValidator.leafName(targetPath)); String content = apiClient.retrievePageMarkdown(source.pageId()); refreshIndexesAfterMutation( @@ -230,7 +245,336 @@ public ToolResult renameNote(String path, String newName) { data.put("path", normalizedPath); data.put("target_path", targetPath); return ToolResult.success("Renamed note from " + normalizedPath + " to " + targetPath, data); - } catch (IllegalArgumentException ex) { + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createDatabase( + String parentPath, + String title, + String description, + String propertiesJson, + Boolean inline, + String iconEmoji, + String coverUrl) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + String normalizedParentPath = pathValidator.normalizeNotePath(parentPath); + ResolvedPage parentPage = resolveExistingPage(normalizedParentPath); + ensureChildTitleAbsent(parentPage.pageId(), requiredText(title, "title")); + NotionDatabaseSummary database = apiClient.createDatabase( + parentPage.pageId(), + requiredText(title, "title"), + description, + parseJsonObject(propertiesJson), + Boolean.TRUE.equals(inline), + iconEmoji, + coverUrl); + return ToolResult.success( + "Created database " + database.title(), + Map.of( + "parent_path", normalizedParentPath, + "database", toDatabaseSummary(database))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readDatabase(String databaseId) { + try { + NotionDatabaseSummary database = apiClient.retrieveDatabase(requiredText(databaseId, "database_id")); + return ToolResult.success( + "Retrieved database " + database.title(), + Map.of("database", toDatabaseSummary(database))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult updateDatabase( + String databaseId, + String title, + String description, + String iconEmoji, + String coverUrl) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionDatabaseSummary database = apiClient.updateDatabase( + requiredText(databaseId, "database_id"), + title, + description, + iconEmoji, + coverUrl); + return ToolResult.success( + "Updated database " + database.title(), + Map.of("database", toDatabaseSummary(database))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createDataSource(String databaseId, String title, String propertiesJson, String iconEmoji) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionDataSourceSummary dataSource = apiClient.createDataSource( + requiredText(databaseId, "database_id"), + title, + parseRequiredJsonObject(propertiesJson, "properties_json"), + iconEmoji); + return ToolResult.success( + "Created data source " + dataSource.title(), + Map.of("data_source", toDataSourceSummary(dataSource))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readDataSource(String dataSourceId) { + try { + NotionDataSourceSummary dataSource = apiClient + .retrieveDataSource(requiredText(dataSourceId, "data_source_id")); + return ToolResult.success( + "Retrieved data source " + dataSource.title(), + Map.of("data_source", toDataSourceSummary(dataSource))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult updateDataSource(String dataSourceId, String title, String propertiesJson, String iconEmoji) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionDataSourceSummary dataSource = apiClient.updateDataSource( + requiredText(dataSourceId, "data_source_id"), + title, + parseJsonObject(propertiesJson), + iconEmoji); + return ToolResult.success( + "Updated data source " + dataSource.title(), + Map.of("data_source", toDataSourceSummary(dataSource))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult queryDatabase( + String databaseId, + String dataSourceId, + String filterJson, + String sortsJson, + Integer limit, + String cursor) { + try { + String resolvedDataSourceId = resolveDataSourceId(databaseId, dataSourceId); + NotionDataSourceQueryResult queryResult = apiClient.queryDataSource( + resolvedDataSourceId, + filterJson, + sortsJson, + limit, + cursor); + Map data = new LinkedHashMap<>(); + data.put("data_source_id", resolvedDataSourceId); + data.put("count", queryResult.count()); + data.put("has_more", queryResult.hasMore()); + data.put("next_cursor", queryResult.nextCursor()); + data.put("results", queryResult.results()); + return ToolResult.success("Retrieved " + queryResult.count() + " database item(s)", data); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createDatabaseEntry( + String databaseId, + String dataSourceId, + String propertiesJson, + String markdown, + String contentJson, + String iconEmoji, + String coverUrl) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + String resolvedDataSourceId = resolveDataSourceId(databaseId, dataSourceId); + NotionPageSummary entry = apiClient.createDataSourceEntry( + resolvedDataSourceId, + requiredText(propertiesJson, "properties_json"), + markdown, + contentJson, + iconEmoji, + coverUrl); + return ToolResult.success( + "Created database entry " + entry.title(), + Map.of( + "data_source_id", resolvedDataSourceId, + "page_id", entry.id(), + "title", entry.title(), + "url", entry.url())); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readDatabaseEntry(String pageId) { + try { + NotionPageDetails page = apiClient.retrievePageDetails(requiredText(pageId, "page_id")); + return ToolResult.success( + "Retrieved database entry " + page.title(), + Map.of("page", toPageDetails(page))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult updateDatabaseEntry(String pageId, String propertiesJson, String iconEmoji, String coverUrl) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionPageSummary page = apiClient.updateDataSourceEntry( + requiredText(pageId, "page_id"), + propertiesJson, + iconEmoji, + coverUrl); + return ToolResult.success( + "Updated database entry " + page.title(), + Map.of( + "page_id", page.id(), + "title", page.title(), + "url", page.url())); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult listPageFiles(String pageId) { + try { + NotionPageDetails page = apiClient.retrievePageDetails(requiredText(pageId, "page_id")); + Map data = new LinkedHashMap<>(); + data.put("page_id", page.id()); + data.put("title", page.title()); + data.put("count", page.files().size()); + data.put("files", page.files().stream().map(this::toFileAttachment).toList()); + return ToolResult.success("Found " + page.files().size() + " file(s) on page " + page.title(), data); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createFileUpload( + String mode, + String filename, + String contentType, + Integer numberOfParts, + String externalUrl) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionFileUploadSummary upload = apiClient.createFileUpload( + mode, + filename, + contentType, + numberOfParts, + externalUrl); + return ToolResult.success( + "Created file upload " + upload.id(), + Map.of("file_upload", toFileUploadSummary(upload))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult uploadFileContent(String fileUploadId, String localPath, String contentType) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionFileUploadSummary upload = apiClient.uploadFileContent( + requiredText(fileUploadId, "file_upload_id"), + Path.of(requiredText(localPath, "local_path")), + contentType); + return ToolResult.success( + "Uploaded file content for " + upload.id(), + Map.of("file_upload", toFileUploadSummary(upload))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult completeFileUpload(String fileUploadId) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionFileUploadSummary upload = apiClient.completeFileUpload(requiredText(fileUploadId, "file_upload_id")); + return ToolResult.success( + "Completed file upload " + upload.id(), + Map.of("file_upload", toFileUploadSummary(upload))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult readFileUpload(String fileUploadId) { + try { + NotionFileUploadSummary upload = apiClient.retrieveFileUpload(requiredText(fileUploadId, "file_upload_id")); + return ToolResult.success( + "Retrieved file upload " + upload.id(), + Map.of("file_upload", toFileUploadSummary(upload))); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult listFileUploads(String status, Integer limit, String cursor) { + try { + List uploads = apiClient.listFileUploads(status, limit, cursor); + Map data = new LinkedHashMap<>(); + data.put("count", uploads.size()); + data.put("uploads", uploads.stream().map(this::toFileUploadSummary).toList()); + return ToolResult.success("Listed " + uploads.size() + " file upload(s)", data); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult attachFileToPage( + String pageId, + String fileUploadId, + String externalUrl, + String fileName, + String caption, + String blockType) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, "Notion write is disabled in plugin settings"); + } + try { + NotionPageSummary block = apiClient.appendFileBlock( + requiredText(pageId, "page_id"), + fileUploadId, + externalUrl, + fileName, + caption, + blockType); + return ToolResult.success( + "Attached file to page " + pageId, + Map.of( + "page_id", pageId, + "block_id", block.id(), + "url", block.url(), + "file_name", fileName != null ? fileName : "")); + } catch (IllegalArgumentException | NotionApiException | NotionTransportException ex) { return executionFailure(ex.getMessage()); } } @@ -268,11 +612,11 @@ private ResolvedPage resolveExistingPage(String normalizedPath) { 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())); + private void ensureChildTitleAbsent(String parentPageId, String title) { + boolean exists = apiClient.listChildItems(parentPageId).stream() + .anyMatch(item -> title.equals(item.title())); if (exists) { - throw new IllegalArgumentException("Target page already exists: " + title); + throw new IllegalArgumentException("Target item already exists: " + title); } } @@ -288,6 +632,58 @@ private void requireNonRootPath(String normalizedPath, String operation) { } } + private String resolveDataSourceId(String databaseId, String dataSourceId) { + if (dataSourceId != null && !dataSourceId.isBlank()) { + return dataSourceId; + } + if (databaseId == null || databaseId.isBlank()) { + throw new IllegalArgumentException("database_id or data_source_id is required"); + } + NotionDatabaseSummary database = apiClient.retrieveDatabase(databaseId); + if (database.dataSources().isEmpty()) { + throw new IllegalArgumentException("Database has no data sources: " + databaseId); + } + if (database.dataSources().size() > 1) { + throw new IllegalArgumentException( + "Database has multiple data sources; provide data_source_id explicitly: " + databaseId); + } + Object id = database.dataSources().getFirst().get("id"); + if (!(id instanceof String text) || text.isBlank()) { + throw new IllegalArgumentException("Database data source ID is missing: " + databaseId); + } + return text; + } + + private String requiredText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " is required"); + } + return value; + } + + private Map parseJsonObject(String rawJson) { + if (rawJson == null || rawJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(rawJson, new TypeReference<>() { + }); + } catch (JsonProcessingException ex) { + throw new IllegalArgumentException("Expected JSON object: " + ex.getMessage(), ex); + } + } + + private Map parseRequiredJsonObject(String rawJson, String fieldName) { + if (rawJson == null || rawJson.isBlank()) { + throw new IllegalArgumentException(fieldName + " is required"); + } + Map parsed = parseJsonObject(rawJson); + if (parsed.isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be empty"); + } + return parsed; + } + private ToolResult executionFailure(String message) { return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); } @@ -307,6 +703,66 @@ private Map toSearchResult(NotionSearchHit hit) { return data; } + private Map toChildItem(NotionChildSummary child) { + Map data = new LinkedHashMap<>(); + data.put("id", child.id()); + data.put("title", child.title()); + data.put("url", child.url()); + data.put("type", child.kind()); + return data; + } + + private Map toDatabaseSummary(NotionDatabaseSummary database) { + Map data = new LinkedHashMap<>(); + data.put("id", database.id()); + data.put("title", database.title()); + data.put("url", database.url()); + data.put("data_sources", database.dataSources()); + return data; + } + + private Map toDataSourceSummary(NotionDataSourceSummary dataSource) { + Map data = new LinkedHashMap<>(); + data.put("id", dataSource.id()); + data.put("title", dataSource.title()); + data.put("url", dataSource.url()); + data.put("properties", dataSource.properties()); + return data; + } + + private Map toPageDetails(NotionPageDetails page) { + Map data = new LinkedHashMap<>(); + data.put("id", page.id()); + data.put("title", page.title()); + data.put("url", page.url()); + data.put("files", page.files().stream().map(this::toFileAttachment).toList()); + data.put("properties", page.rawProperties()); + return data; + } + + private Map toFileUploadSummary(NotionFileUploadSummary upload) { + Map data = new LinkedHashMap<>(); + data.put("id", upload.id()); + data.put("status", upload.status()); + data.put("filename", upload.filename()); + data.put("content_type", upload.contentType()); + data.put("content_length", upload.contentLength()); + data.put("upload_url", upload.uploadUrl()); + data.put("expiry_time", upload.expiryTime()); + return data; + } + + private Map toFileAttachment(NotionFileAttachmentSummary file) { + Map data = new LinkedHashMap<>(); + data.put("name", file.name()); + data.put("type", file.type()); + data.put("url", file.url()); + data.put("expiry_time", file.expiryTime()); + data.put("source_kind", file.sourceKind()); + data.put("source_name", file.sourceName()); + return data; + } + private void refreshIndexesAfterMutation(Runnable ragSyncMutation) { if (!Boolean.TRUE.equals(configService.getConfig().getLocalIndexEnabled())) { runBestEffort(ragSyncMutation); 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 index 957db22..1cc9eb4 100644 --- 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 @@ -6,6 +6,7 @@ import me.golemcore.plugin.api.extension.spi.ToolProvider; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -16,6 +17,8 @@ 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 TYPE_INTEGER = "integer"; + private static final String TYPE_BOOLEAN = "boolean"; private static final String PROPERTIES = "properties"; private static final String REQUIRED = "required"; private static final String PARAM_OPERATION = "operation"; @@ -23,8 +26,33 @@ public class NotionVaultToolProvider implements ToolProvider { 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_CONTENT_JSON = "content_json"; private static final String PARAM_TARGET_PATH = "target_path"; private static final String PARAM_NEW_NAME = "new_name"; + private static final String PARAM_PARENT_PATH = "parent_path"; + private static final String PARAM_TITLE = "title"; + private static final String PARAM_DATABASE_ID = "database_id"; + private static final String PARAM_DATA_SOURCE_ID = "data_source_id"; + private static final String PARAM_PAGE_ID = "page_id"; + private static final String PARAM_DESCRIPTION = "description"; + private static final String PARAM_PROPERTIES_JSON = "properties_json"; + private static final String PARAM_FILTER_JSON = "filter_json"; + private static final String PARAM_SORTS_JSON = "sorts_json"; + private static final String PARAM_CURSOR = "cursor"; + private static final String PARAM_INLINE = "inline"; + private static final String PARAM_ICON_EMOJI = "icon_emoji"; + private static final String PARAM_COVER_URL = "cover_url"; + private static final String PARAM_MODE = "mode"; + private static final String PARAM_FILENAME = "filename"; + private static final String PARAM_CONTENT_TYPE = "content_type"; + private static final String PARAM_NUMBER_OF_PARTS = "number_of_parts"; + private static final String PARAM_EXTERNAL_URL = "external_url"; + private static final String PARAM_LOCAL_PATH = "local_path"; + private static final String PARAM_FILE_UPLOAD_ID = "file_upload_id"; + private static final String PARAM_FILE_NAME = "file_name"; + private static final String PARAM_CAPTION = "caption"; + private static final String PARAM_BLOCK_TYPE = "block_type"; + private static final String PARAM_STATUS = "status"; private final NotionVaultService service; @@ -34,50 +62,107 @@ public NotionVaultToolProvider(NotionVaultService service) { @Override public ToolDefinition getDefinition() { + Map properties = new LinkedHashMap<>(); + properties.put(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", + "create_database", + "read_database", + "update_database", + "create_data_source", + "read_data_source", + "update_data_source", + "query_database", + "create_database_entry", + "read_database_entry", + "update_database_entry", + "list_page_files", + "create_file_upload", + "upload_file_content", + "complete_file_upload", + "read_file_upload", + "list_file_uploads", + "attach_file_to_page"))); + properties.put(PARAM_QUERY, stringProperty("Full-text query against the local Notion index.")); + properties.put(PARAM_PATH, stringProperty("Pseudo-path to a Notion page or subtree.")); + properties.put(PARAM_LIMIT, integerProperty("Maximum number of results to return.")); + properties.put(PARAM_CONTENT, stringProperty("Markdown content for note or page creation.")); + properties.put(PARAM_CONTENT_JSON, stringProperty("JSON array of block children for page creation.")); + properties.put(PARAM_TARGET_PATH, stringProperty("Target pseudo-path for move_note.")); + properties.put(PARAM_NEW_NAME, stringProperty("New leaf page title for rename_note.")); + properties.put(PARAM_PARENT_PATH, + stringProperty("Pseudo-path of the parent page used when creating a database.")); + properties.put(PARAM_TITLE, stringProperty("Human-readable title for a database or data source.")); + properties.put(PARAM_DATABASE_ID, stringProperty("Notion database ID.")); + properties.put(PARAM_DATA_SOURCE_ID, stringProperty("Notion data source ID.")); + properties.put(PARAM_PAGE_ID, stringProperty("Notion page ID.")); + properties.put(PARAM_DESCRIPTION, stringProperty("Optional description text.")); + properties.put(PARAM_PROPERTIES_JSON, + stringProperty("JSON object with database schema or page property values.")); + properties.put(PARAM_FILTER_JSON, stringProperty("JSON object with a Notion data source filter.")); + properties.put(PARAM_SORTS_JSON, stringProperty("JSON array with Notion sort definitions.")); + properties.put(PARAM_CURSOR, stringProperty("Pagination cursor for data source or file upload listing.")); + properties.put(PARAM_INLINE, booleanProperty("Whether the new database should be inline.")); + properties.put(PARAM_ICON_EMOJI, stringProperty("Optional emoji icon.")); + properties.put(PARAM_COVER_URL, stringProperty("Optional external URL for a page or database cover.")); + properties.put(PARAM_MODE, stringProperty("File upload mode: single_part, multi_part, or external_url.")); + properties.put(PARAM_FILENAME, stringProperty("Upload filename.")); + properties.put(PARAM_CONTENT_TYPE, stringProperty("MIME type of the uploaded file.")); + properties.put(PARAM_NUMBER_OF_PARTS, integerProperty("Number of parts for multi-part uploads.")); + properties.put(PARAM_EXTERNAL_URL, stringProperty("Public external URL for importing or attaching a file.")); + properties.put(PARAM_LOCAL_PATH, stringProperty("Local filesystem path used by upload_file_content.")); + properties.put(PARAM_FILE_UPLOAD_ID, stringProperty("Notion file upload ID.")); + properties.put(PARAM_FILE_NAME, stringProperty("Display file name when attaching a file block.")); + properties.put(PARAM_CAPTION, stringProperty("Optional file block caption.")); + properties.put(PARAM_BLOCK_TYPE, stringProperty("File block type: file, image, pdf, audio, or video.")); + properties.put(PARAM_STATUS, stringProperty("Filter file uploads by status.")); + + Map schema = new LinkedHashMap<>(); + schema.put(TYPE, TYPE_OBJECT); + schema.put(PROPERTIES, properties); + schema.put(REQUIRED, List.of(PARAM_OPERATION)); + schema.put("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)), + requiredWhen("create_database", List.of(PARAM_PARENT_PATH, PARAM_TITLE)), + requiredWhen("read_database", List.of(PARAM_DATABASE_ID)), + requiredWhen("update_database", List.of(PARAM_DATABASE_ID)), + requiredWhen("create_data_source", List.of(PARAM_DATABASE_ID, PARAM_PROPERTIES_JSON)), + requiredWhen("read_data_source", List.of(PARAM_DATA_SOURCE_ID)), + requiredWhen("update_data_source", List.of(PARAM_DATA_SOURCE_ID)), + requiredWhenAny("query_database", List.of( + List.of(PARAM_DATABASE_ID), + List.of(PARAM_DATA_SOURCE_ID))), + requiredWhenAny("create_database_entry", List.of( + List.of(PARAM_DATABASE_ID, PARAM_PROPERTIES_JSON), + List.of(PARAM_DATA_SOURCE_ID, PARAM_PROPERTIES_JSON))), + requiredWhen("read_database_entry", List.of(PARAM_PAGE_ID)), + requiredWhen("update_database_entry", List.of(PARAM_PAGE_ID)), + requiredWhen("list_page_files", List.of(PARAM_PAGE_ID)), + requiredWhen("upload_file_content", List.of(PARAM_FILE_UPLOAD_ID, PARAM_LOCAL_PATH)), + requiredWhen("complete_file_upload", List.of(PARAM_FILE_UPLOAD_ID)), + requiredWhen("read_file_upload", List.of(PARAM_FILE_UPLOAD_ID)), + requiredWhenAny("attach_file_to_page", List.of( + List.of(PARAM_PAGE_ID, PARAM_FILE_UPLOAD_ID), + List.of(PARAM_PAGE_ID, PARAM_EXTERNAL_URL))))); + 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))))) + .description("Use Notion pages, databases, and files through the official Notion HTTP API.") + .inputSchema(schema) .build(); } @@ -112,11 +197,94 @@ private ToolResult executeOperation(Map parameters) { case "rename_note" -> service.renameNote( readString(parameters.get(PARAM_PATH)), readString(parameters.get(PARAM_NEW_NAME))); + case "create_database" -> service.createDatabase( + readString(parameters.get(PARAM_PARENT_PATH)), + readString(parameters.get(PARAM_TITLE)), + readString(parameters.get(PARAM_DESCRIPTION)), + readString(parameters.get(PARAM_PROPERTIES_JSON)), + readBoolean(parameters.get(PARAM_INLINE)), + readString(parameters.get(PARAM_ICON_EMOJI)), + readString(parameters.get(PARAM_COVER_URL))); + case "read_database" -> service.readDatabase(readString(parameters.get(PARAM_DATABASE_ID))); + case "update_database" -> service.updateDatabase( + readString(parameters.get(PARAM_DATABASE_ID)), + readString(parameters.get(PARAM_TITLE)), + readString(parameters.get(PARAM_DESCRIPTION)), + readString(parameters.get(PARAM_ICON_EMOJI)), + readString(parameters.get(PARAM_COVER_URL))); + case "create_data_source" -> service.createDataSource( + readString(parameters.get(PARAM_DATABASE_ID)), + readString(parameters.get(PARAM_TITLE)), + readString(parameters.get(PARAM_PROPERTIES_JSON)), + readString(parameters.get(PARAM_ICON_EMOJI))); + case "read_data_source" -> service.readDataSource(readString(parameters.get(PARAM_DATA_SOURCE_ID))); + case "update_data_source" -> service.updateDataSource( + readString(parameters.get(PARAM_DATA_SOURCE_ID)), + readString(parameters.get(PARAM_TITLE)), + readString(parameters.get(PARAM_PROPERTIES_JSON)), + readString(parameters.get(PARAM_ICON_EMOJI))); + case "query_database" -> service.queryDatabase( + readString(parameters.get(PARAM_DATABASE_ID)), + readString(parameters.get(PARAM_DATA_SOURCE_ID)), + readString(parameters.get(PARAM_FILTER_JSON)), + readString(parameters.get(PARAM_SORTS_JSON)), + readInteger(parameters.get(PARAM_LIMIT)), + readString(parameters.get(PARAM_CURSOR))); + case "create_database_entry" -> service.createDatabaseEntry( + readString(parameters.get(PARAM_DATABASE_ID)), + readString(parameters.get(PARAM_DATA_SOURCE_ID)), + readString(parameters.get(PARAM_PROPERTIES_JSON)), + readString(parameters.get(PARAM_CONTENT)), + readString(parameters.get(PARAM_CONTENT_JSON)), + readString(parameters.get(PARAM_ICON_EMOJI)), + readString(parameters.get(PARAM_COVER_URL))); + case "read_database_entry" -> service.readDatabaseEntry(readString(parameters.get(PARAM_PAGE_ID))); + case "update_database_entry" -> service.updateDatabaseEntry( + readString(parameters.get(PARAM_PAGE_ID)), + readString(parameters.get(PARAM_PROPERTIES_JSON)), + readString(parameters.get(PARAM_ICON_EMOJI)), + readString(parameters.get(PARAM_COVER_URL))); + case "list_page_files" -> service.listPageFiles(readString(parameters.get(PARAM_PAGE_ID))); + case "create_file_upload" -> service.createFileUpload( + readString(parameters.get(PARAM_MODE)), + readString(parameters.get(PARAM_FILENAME)), + readString(parameters.get(PARAM_CONTENT_TYPE)), + readInteger(parameters.get(PARAM_NUMBER_OF_PARTS)), + readString(parameters.get(PARAM_EXTERNAL_URL))); + case "upload_file_content" -> service.uploadFileContent( + readString(parameters.get(PARAM_FILE_UPLOAD_ID)), + readString(parameters.get(PARAM_LOCAL_PATH)), + readString(parameters.get(PARAM_CONTENT_TYPE))); + case "complete_file_upload" -> service.completeFileUpload(readString(parameters.get(PARAM_FILE_UPLOAD_ID))); + case "read_file_upload" -> service.readFileUpload(readString(parameters.get(PARAM_FILE_UPLOAD_ID))); + case "list_file_uploads" -> service.listFileUploads( + readString(parameters.get(PARAM_STATUS)), + readInteger(parameters.get(PARAM_LIMIT)), + readString(parameters.get(PARAM_CURSOR))); + case "attach_file_to_page" -> service.attachFileToPage( + readString(parameters.get(PARAM_PAGE_ID)), + readString(parameters.get(PARAM_FILE_UPLOAD_ID)), + readString(parameters.get(PARAM_EXTERNAL_URL)), + readString(parameters.get(PARAM_FILE_NAME)), + readString(parameters.get(PARAM_CAPTION)), + readString(parameters.get(PARAM_BLOCK_TYPE))); default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "Unsupported notion_vault operation: " + operation); }; } + private Map stringProperty(String description) { + return Map.of(TYPE, TYPE_STRING, "description", description); + } + + private Map integerProperty(String description) { + return Map.of(TYPE, TYPE_INTEGER, "description", description); + } + + private Map booleanProperty(String description) { + return Map.of(TYPE, TYPE_BOOLEAN, "description", description); + } + private Map requiredWhen(String operation, List requiredFields) { return Map.of( "if", Map.of( @@ -126,6 +294,17 @@ private Map requiredWhen(String operation, List required REQUIRED, requiredFields)); } + private Map requiredWhenAny(String operation, List> alternatives) { + return Map.of( + "if", Map.of( + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of("const", operation))), + "then", Map.of( + "anyOf", alternatives.stream() + .map(required -> Map.of(REQUIRED, required)) + .toList())); + } + private String readString(Object value) { if (value instanceof String text) { return text; @@ -146,4 +325,14 @@ private Integer readInteger(Object value) { } return null; } + + private boolean readBoolean(Object value) { + if (value instanceof Boolean bool) { + return bool; + } + if (value instanceof String text && !text.isBlank()) { + return Boolean.parseBoolean(text); + } + return false; + } } 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 index b084166..fc1fb5e 100644 --- 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 @@ -5,6 +5,7 @@ import me.golemcore.plugins.golemcore.notion.NotionPluginConfig; import me.golemcore.plugins.golemcore.notion.NotionPluginConfigService; import okhttp3.MediaType; +import okhttp3.MultipartBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; @@ -15,8 +16,13 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -24,6 +30,7 @@ public class NotionApiClient { private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream"); private final NotionPluginConfigService configService; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -41,10 +48,17 @@ public String retrievePageTitle(String pageId) { } public List listChildPages(String parentPageId) { + return listChildItems(parentPageId).stream() + .filter(child -> "page".equals(child.kind())) + .map(child -> new NotionPageSummary(child.id(), child.title(), child.url())) + .toList(); + } + + public List listChildItems(String parentPageId) { if (parentPageId == null || parentPageId.isBlank()) { throw new IllegalArgumentException("parentPageId is required"); } - List pages = new ArrayList<>(); + List items = new ArrayList<>(); String nextCursor = ""; boolean hasMore; do { @@ -57,18 +71,27 @@ public List listChildPages(String parentPageId) { } JsonNode response = getJson(pathBuilder.toString()); for (JsonNode result : response.path("results")) { - if (!"child_page".equals(result.path("type").asText())) { + String type = result.path("type").asText(""); + if ("child_page".equals(type)) { + items.add(new NotionChildSummary( + result.path("id").asText(), + result.path("child_page").path("title").asText(""), + result.path("url").asText(""), + "page")); continue; } - pages.add(new NotionPageSummary( - result.path("id").asText(), - result.path("child_page").path("title").asText(), - result.path("url").asText(""))); + if ("child_database".equals(type)) { + items.add(new NotionChildSummary( + result.path("id").asText(), + result.path("child_database").path("title").asText(""), + result.path("url").asText(""), + "database")); + } } hasMore = response.path("has_more").asBoolean(false); nextCursor = hasMore ? response.path("next_cursor").asText("") : ""; } while (hasMore && !nextCursor.isBlank()); - return pages; + return List.copyOf(items); } public String retrievePageMarkdown(String pageId) { @@ -87,7 +110,7 @@ public NotionPageSummary createChildPage(String parentPageId, String title, Stri throw new IllegalArgumentException("title is required"); } JsonNode response = sendJson("POST", "/v1/pages", Map.of( - "parent", Map.of("page_id", parentPageId), + "parent", Map.of("type", "page_id", "page_id", parentPageId), "properties", Map.of( "title", Map.of( "title", List.of(Map.of( @@ -137,17 +160,359 @@ public void renamePage(String pageId, String title) { "text", Map.of("content", title))))))); } + public NotionDatabaseSummary createDatabase( + String parentPageId, + String title, + String description, + Map properties, + boolean inline, + String iconEmoji, + String coverExternalUrl) { + if (parentPageId == null || parentPageId.isBlank()) { + throw new IllegalArgumentException("parentPageId is required"); + } + Map payload = new LinkedHashMap<>(); + payload.put("parent", Map.of("type", "page_id", "page_id", parentPageId)); + payload.put("title", richTextArray(title)); + if (description != null && !description.isBlank()) { + payload.put("description", richTextArray(description)); + } + payload.put("is_inline", inline); + if (properties != null && !properties.isEmpty()) { + payload.put("initial_data_source", Map.of("properties", properties)); + } + if (iconEmoji != null && !iconEmoji.isBlank()) { + payload.put("icon", Map.of("type", "emoji", "emoji", iconEmoji)); + } + if (coverExternalUrl != null && !coverExternalUrl.isBlank()) { + payload.put("cover", Map.of( + "type", "external", + "external", Map.of("url", coverExternalUrl))); + } + JsonNode response = sendJson("POST", "/v1/databases", payload); + return toDatabaseSummary(response); + } + + public NotionDatabaseSummary retrieveDatabase(String databaseId) { + if (databaseId == null || databaseId.isBlank()) { + throw new IllegalArgumentException("databaseId is required"); + } + return toDatabaseSummary(getJson("/v1/databases/" + databaseId)); + } + + public NotionDatabaseSummary updateDatabase( + String databaseId, + String title, + String description, + String iconEmoji, + String coverExternalUrl) { + if (databaseId == null || databaseId.isBlank()) { + throw new IllegalArgumentException("databaseId is required"); + } + Map payload = new LinkedHashMap<>(); + if (title != null) { + payload.put("title", richTextArray(title)); + } + if (description != null) { + payload.put("description", richTextArray(description)); + } + if (iconEmoji != null) { + payload.put("icon", iconEmoji.isBlank() + ? null + : Map.of("type", "emoji", "emoji", iconEmoji)); + } + if (coverExternalUrl != null) { + payload.put("cover", coverExternalUrl.isBlank() + ? null + : Map.of("type", "external", "external", Map.of("url", coverExternalUrl))); + } + JsonNode response = sendJson("PATCH", "/v1/databases/" + databaseId, payload); + return toDatabaseSummary(response); + } + + public NotionDataSourceSummary createDataSource( + String databaseId, + String title, + Map properties, + String iconEmoji) { + if (databaseId == null || databaseId.isBlank()) { + throw new IllegalArgumentException("databaseId is required"); + } + if (properties == null || properties.isEmpty()) { + throw new IllegalArgumentException("properties are required"); + } + Map payload = new LinkedHashMap<>(); + payload.put("parent", Map.of("type", "database_id", "database_id", databaseId)); + payload.put("properties", properties); + if (title != null && !title.isBlank()) { + payload.put("title", richTextArray(title)); + } + if (iconEmoji != null && !iconEmoji.isBlank()) { + payload.put("icon", Map.of("type", "emoji", "emoji", iconEmoji)); + } + JsonNode response = sendJson("POST", "/v1/data_sources", payload); + return toDataSourceSummary(response); + } + + public NotionDataSourceSummary retrieveDataSource(String dataSourceId) { + if (dataSourceId == null || dataSourceId.isBlank()) { + throw new IllegalArgumentException("dataSourceId is required"); + } + return toDataSourceSummary(getJson("/v1/data_sources/" + dataSourceId)); + } + + public NotionDataSourceSummary updateDataSource( + String dataSourceId, + String title, + Map properties, + String iconEmoji) { + if (dataSourceId == null || dataSourceId.isBlank()) { + throw new IllegalArgumentException("dataSourceId is required"); + } + Map payload = new LinkedHashMap<>(); + if (title != null) { + payload.put("title", richTextArray(title)); + } + if (properties != null) { + payload.put("properties", properties); + } + if (iconEmoji != null) { + payload.put("icon", iconEmoji.isBlank() + ? null + : Map.of("type", "emoji", "emoji", iconEmoji)); + } + JsonNode response = sendJson("PATCH", "/v1/data_sources/" + dataSourceId, payload); + return toDataSourceSummary(response); + } + + public NotionDataSourceQueryResult queryDataSource( + String dataSourceId, + String filterJson, + String sortsJson, + Integer limit, + String cursor) { + if (dataSourceId == null || dataSourceId.isBlank()) { + throw new IllegalArgumentException("dataSourceId is required"); + } + Map payload = new LinkedHashMap<>(); + if (filterJson != null && !filterJson.isBlank()) { + payload.put("filter", parseJsonObject(filterJson, "filterJson")); + } + if (sortsJson != null && !sortsJson.isBlank()) { + payload.put("sorts", parseJsonArray(sortsJson, "sortsJson")); + } + if (limit != null && limit > 0) { + payload.put("page_size", limit); + } + if (cursor != null && !cursor.isBlank()) { + payload.put("start_cursor", cursor); + } + JsonNode response = sendJson("POST", "/v1/data_sources/" + dataSourceId + "/query", payload); + List> results = new ArrayList<>(); + for (JsonNode item : response.path("results")) { + results.add(pageSummaryMap(item)); + } + return new NotionDataSourceQueryResult( + dataSourceId, + results.size(), + response.path("has_more").asBoolean(false), + response.path("next_cursor").asText(""), + List.copyOf(results)); + } + + public NotionPageSummary createDataSourceEntry( + String dataSourceId, + String propertiesJson, + String markdown, + String contentJson, + String iconEmoji, + String coverExternalUrl) { + if (dataSourceId == null || dataSourceId.isBlank()) { + throw new IllegalArgumentException("dataSourceId is required"); + } + Map payload = new LinkedHashMap<>(); + payload.put("parent", Map.of("type", "data_source_id", "data_source_id", dataSourceId)); + payload.put("properties", parseJsonObject(propertiesJson, "propertiesJson")); + applyContentPayload(payload, markdown, contentJson); + if (iconEmoji != null && !iconEmoji.isBlank()) { + payload.put("icon", Map.of("type", "emoji", "emoji", iconEmoji)); + } + if (coverExternalUrl != null && !coverExternalUrl.isBlank()) { + payload.put("cover", Map.of("type", "external", "external", Map.of("url", coverExternalUrl))); + } + JsonNode response = sendJson("POST", "/v1/pages", payload); + return toPageSummary(response); + } + + public NotionPageSummary updateDataSourceEntry( + String pageId, + String propertiesJson, + String iconEmoji, + String coverExternalUrl) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + Map payload = new LinkedHashMap<>(); + if (propertiesJson != null) { + payload.put("properties", parseJsonObject(propertiesJson, "propertiesJson")); + } + if (iconEmoji != null) { + payload.put("icon", iconEmoji.isBlank() + ? null + : Map.of("type", "emoji", "emoji", iconEmoji)); + } + if (coverExternalUrl != null) { + payload.put("cover", coverExternalUrl.isBlank() + ? null + : Map.of("type", "external", "external", Map.of("url", coverExternalUrl))); + } + JsonNode response = sendJson("PATCH", "/v1/pages/" + pageId, payload); + return toPageSummary(response); + } + + public NotionPageDetails retrievePageDetails(String pageId) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + JsonNode page = getJson("/v1/pages/" + pageId); + return new NotionPageDetails( + page.path("id").asText(), + extractPageTitle(page), + page.path("url").asText(""), + extractPageFiles(page), + extractPropertiesSummary(page.path("properties"))); + } + + public NotionFileUploadSummary createFileUpload( + String mode, + String filename, + String contentType, + Integer numberOfParts, + String externalUrl) { + Map payload = new LinkedHashMap<>(); + if (mode != null && !mode.isBlank()) { + payload.put("mode", mode); + } + if (filename != null && !filename.isBlank()) { + payload.put("filename", filename); + } + if (contentType != null && !contentType.isBlank()) { + payload.put("content_type", contentType); + } + if (numberOfParts != null && numberOfParts > 0) { + payload.put("number_of_parts", numberOfParts); + } + if (externalUrl != null && !externalUrl.isBlank()) { + payload.put("external_url", externalUrl); + } + JsonNode response = sendJson("POST", "/v1/file_uploads", payload); + return toFileUploadSummary(response); + } + + public NotionFileUploadSummary uploadFileContent(String fileUploadId, Path filePath, String contentType) { + if (fileUploadId == null || fileUploadId.isBlank()) { + throw new IllegalArgumentException("fileUploadId is required"); + } + if (filePath == null) { + throw new IllegalArgumentException("filePath is required"); + } + if (!Files.exists(filePath)) { + throw new IllegalArgumentException("File does not exist: " + filePath); + } + try { + byte[] bytes = Files.readAllBytes(filePath); + Path leafPath = filePath.getFileName(); + String fileName = leafPath == null ? "upload.bin" : leafPath.toString(); + MediaType mediaType = contentType != null && !contentType.isBlank() + ? MediaType.get(contentType) + : OCTET_STREAM; + MultipartBody body = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", fileName, RequestBody.create(bytes, mediaType)) + .build(); + JsonNode response = sendMultipart("POST", "/v1/file_uploads/" + fileUploadId + "/send", body); + return toFileUploadSummary(response); + } catch (IOException ex) { + throw new NotionTransportException("Failed to read upload file: " + ex.getMessage(), ex); + } + } + + public NotionFileUploadSummary completeFileUpload(String fileUploadId) { + if (fileUploadId == null || fileUploadId.isBlank()) { + throw new IllegalArgumentException("fileUploadId is required"); + } + JsonNode response = sendJson("POST", "/v1/file_uploads/" + fileUploadId + "/complete", Map.of()); + return toFileUploadSummary(response); + } + + public NotionFileUploadSummary retrieveFileUpload(String fileUploadId) { + if (fileUploadId == null || fileUploadId.isBlank()) { + throw new IllegalArgumentException("fileUploadId is required"); + } + return toFileUploadSummary(getJson("/v1/file_uploads/" + fileUploadId)); + } + + public List listFileUploads(String status, Integer limit, String cursor) { + StringBuilder pathBuilder = new StringBuilder("/v1/file_uploads"); + List queryParts = new ArrayList<>(); + if (status != null && !status.isBlank()) { + queryParts.add("status=" + URLEncoder.encode(status, StandardCharsets.UTF_8)); + } + if (limit != null && limit > 0) { + queryParts.add("page_size=" + limit); + } + if (cursor != null && !cursor.isBlank()) { + queryParts.add("start_cursor=" + URLEncoder.encode(cursor, StandardCharsets.UTF_8)); + } + if (!queryParts.isEmpty()) { + pathBuilder.append('?').append(String.join("&", queryParts)); + } + JsonNode response = getJson(pathBuilder.toString()); + List uploads = new ArrayList<>(); + for (JsonNode item : response.path("results")) { + uploads.add(toFileUploadSummary(item)); + } + return List.copyOf(uploads); + } + + public NotionPageSummary appendFileBlock( + String pageId, + String fileUploadId, + String externalUrl, + String fileName, + String caption, + String blockType) { + if (pageId == null || pageId.isBlank()) { + throw new IllegalArgumentException("pageId is required"); + } + String normalizedBlockType = normalizeBlockType(blockType); + Map blockPayload = new LinkedHashMap<>(); + blockPayload.put("object", "block"); + blockPayload.put("type", normalizedBlockType); + blockPayload.put(normalizedBlockType, buildMediaPayload( + fileUploadId, + externalUrl, + fileName, + caption, + supportsFileName(normalizedBlockType))); + JsonNode response = sendJson("PATCH", "/v1/blocks/" + pageId + "/children", Map.of( + "children", List.of(blockPayload))); + JsonNode firstResult = response.path("results").isArray() && response.path("results").size() > 0 + ? response.path("results").get(0) + : objectMapper.createObjectNode(); + return new NotionPageSummary( + firstResult.path("id").asText(""), + fileName != null ? fileName : normalizedBlockType, + firstResult.path("url").asText("")); + } + 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"); + Request.Builder builder = baseRequestBuilder(config, path); try { if ("GET".equals(method)) { builder.get(); @@ -159,8 +524,26 @@ private JsonNode sendJson(String method, String path, Object body) { } catch (IOException e) { throw new NotionTransportException("Notion request serialization failed: " + e.getMessage(), e); } + return execute(builder.build(), config); + } - try (Response response = client(config).newCall(builder.build()).execute(); + private JsonNode sendMultipart(String method, String path, MultipartBody body) { + NotionPluginConfig config = requireConfiguredClient(); + Request.Builder builder = baseRequestBuilder(config, path) + .method(method, body); + return execute(builder.build(), config); + } + + private Request.Builder baseRequestBuilder(NotionPluginConfig config, String path) { + return new Request.Builder() + .url(stripTrailingSlash(config.getBaseUrl()) + path) + .header("Authorization", "Bearer " + config.getApiKey()) + .header("Notion-Version", config.getApiVersion()) + .header("Accept", "application/json"); + } + + private JsonNode execute(Request request, NotionPluginConfig config) { + try (Response response = client(config).newCall(request).execute(); ResponseBody responseBody = response.body()) { String rawBody = responseBody == null ? "" : responseBody.string(); if (!response.isSuccessful()) { @@ -197,10 +580,48 @@ private NotionPageSummary toPageSummary(JsonNode page) { page.path("url").asText("")); } + private NotionDatabaseSummary toDatabaseSummary(JsonNode database) { + List> dataSources = new ArrayList<>(); + for (JsonNode dataSource : database.path("data_sources")) { + Map item = new LinkedHashMap<>(); + item.put("id", dataSource.path("id").asText("")); + item.put("name", dataSource.path("name").asText("")); + dataSources.add(item); + } + return new NotionDatabaseSummary( + database.path("id").asText(), + richTextPlainText(database.path("title")), + database.path("url").asText(""), + List.copyOf(dataSources)); + } + + private NotionDataSourceSummary toDataSourceSummary(JsonNode dataSource) { + return new NotionDataSourceSummary( + dataSource.path("id").asText(), + richTextPlainText(dataSource.path("title")), + dataSource.path("url").asText(""), + extractDataSourceProperties(dataSource.path("properties"))); + } + + private NotionFileUploadSummary toFileUploadSummary(JsonNode fileUpload) { + JsonNode contentLengthNode = fileUpload.path("content_length"); + Long contentLength = contentLengthNode.isNumber() + ? Long.valueOf(contentLengthNode.longValue()) + : parseLong(contentLengthNode.asText("")); + return new NotionFileUploadSummary( + fileUpload.path("id").asText(), + fileUpload.path("status").asText(""), + fileUpload.path("filename").asText(""), + fileUpload.path("content_type").asText(""), + contentLength, + fileUpload.path("upload_url").asText(""), + fileUpload.path("expiry_time").asText("")); + } + private String extractPageTitle(JsonNode page) { JsonNode properties = page.path("properties"); if (properties.isObject()) { - var fields = properties.fields(); + java.util.Iterator> fields = properties.fields(); while (fields.hasNext()) { JsonNode property = fields.next().getValue(); if (!"title".equals(property.path("type").asText())) { @@ -213,7 +634,278 @@ private String extractPageTitle(JsonNode page) { } } } - return ""; + return richTextPlainText(page.path("title")); + } + + private Map extractDataSourceProperties(JsonNode properties) { + Map result = new LinkedHashMap<>(); + if (!properties.isObject()) { + return result; + } + java.util.Iterator> fields = properties.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode property = field.getValue(); + Map propertySummary = new LinkedHashMap<>(); + propertySummary.put("id", property.path("id").asText("")); + propertySummary.put("type", property.path("type").asText("")); + JsonNode typePayload = property.path(property.path("type").asText("")); + if (typePayload.isObject() && typePayload.has("format")) { + propertySummary.put("format", typePayload.path("format").asText("")); + } + result.put(field.getKey(), propertySummary); + } + return result; + } + + private List extractPageFiles(JsonNode page) { + List files = new ArrayList<>(); + JsonNode properties = page.path("properties"); + if (!properties.isObject()) { + return List.copyOf(files); + } + java.util.Iterator> fields = properties.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode property = field.getValue(); + if (!"files".equals(property.path("type").asText())) { + continue; + } + for (JsonNode fileNode : property.path("files")) { + files.add(toFileAttachment(fileNode, "property", field.getKey())); + } + } + return List.copyOf(files); + } + + private NotionFileAttachmentSummary toFileAttachment(JsonNode fileNode, String sourceKind, String sourceName) { + String type = fileNode.path("type").asText(""); + JsonNode payload = fileNode.path(type); + return new NotionFileAttachmentSummary( + fileNode.path("name").asText(""), + type, + payload.path("url").asText(""), + payload.path("expiry_time").asText(""), + sourceKind, + sourceName); + } + + private Map extractPropertiesSummary(JsonNode properties) { + Map result = new LinkedHashMap<>(); + if (!properties.isObject()) { + return result; + } + java.util.Iterator> fields = properties.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + result.put(field.getKey(), summarizePropertyValue(field.getValue())); + } + return result; + } + + private Map summarizePropertyValue(JsonNode property) { + Map summary = new LinkedHashMap<>(); + String type = property.path("type").asText(""); + summary.put("type", type); + if ("title".equals(type)) { + summary.put("value", richTextPlainText(property.path("title"))); + } else if ("rich_text".equals(type)) { + summary.put("value", richTextPlainText(property.path("rich_text"))); + } else if ("number".equals(type)) { + summary.put("value", property.path("number").isNumber() ? property.path("number").numberValue() : null); + } else if ("checkbox".equals(type)) { + summary.put("value", property.path("checkbox").asBoolean(false)); + } else if ("url".equals(type) || "email".equals(type) || "phone_number".equals(type)) { + summary.put("value", property.path(type).asText("")); + } else if ("date".equals(type)) { + summary.put("value", jsonNodeToJava(property.path("date"))); + } else if ("select".equals(type) || "status".equals(type)) { + JsonNode selected = property.path(type); + summary.put("value", selected.path("name").asText(selected.path("id").asText(""))); + } else if ("multi_select".equals(type)) { + List values = new ArrayList<>(); + for (JsonNode item : property.path("multi_select")) { + values.add(item.path("name").asText(item.path("id").asText(""))); + } + summary.put("value", values); + } else if ("files".equals(type)) { + List> values = new ArrayList<>(); + for (JsonNode item : property.path("files")) { + Map fileSummary = new LinkedHashMap<>(); + fileSummary.put("name", item.path("name").asText("")); + fileSummary.put("type", item.path("type").asText("")); + JsonNode typePayload = item.path(item.path("type").asText("")); + fileSummary.put("url", typePayload.path("url").asText("")); + fileSummary.put("expiry_time", typePayload.path("expiry_time").asText("")); + values.add(fileSummary); + } + summary.put("value", values); + } else { + summary.put("value", jsonNodeToJava(property.path(type))); + } + return summary; + } + + private Map pageSummaryMap(JsonNode page) { + Map result = new LinkedHashMap<>(); + result.put("id", page.path("id").asText("")); + result.put("title", extractPageTitle(page)); + result.put("url", page.path("url").asText("")); + result.put("properties", extractPropertiesSummary(page.path("properties"))); + return result; + } + + private List> richTextArray(String text) { + if (text == null || text.isBlank()) { + return List.of(); + } + return List.of(Map.of("text", Map.of("content", text))); + } + + private String richTextPlainText(JsonNode richTextNode) { + if (richTextNode == null || !richTextNode.isArray()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (JsonNode item : richTextNode) { + String plainText = item.path("plain_text").asText(""); + if (!plainText.isBlank()) { + builder.append(plainText); + continue; + } + builder.append(item.path("text").path("content").asText("")); + } + return builder.toString(); + } + + private Map parseJsonObject(String rawJson, String fieldName) { + if (rawJson == null || rawJson.isBlank()) { + return Map.of(); + } + try { + JsonNode node = objectMapper.readTree(rawJson); + if (!node.isObject()) { + throw new IllegalArgumentException(fieldName + " must be a JSON object"); + } + return objectMapper.convertValue( + node, + objectMapper.getTypeFactory().constructMapType(LinkedHashMap.class, String.class, Object.class)); + } catch (IOException ex) { + throw new IllegalArgumentException(fieldName + " must be valid JSON: " + ex.getMessage(), ex); + } + } + + private List parseJsonArray(String rawJson, String fieldName) { + if (rawJson == null || rawJson.isBlank()) { + return List.of(); + } + try { + JsonNode node = objectMapper.readTree(rawJson); + if (!node.isArray()) { + throw new IllegalArgumentException(fieldName + " must be a JSON array"); + } + return objectMapper.convertValue( + node, + objectMapper.getTypeFactory().constructCollectionType(List.class, Object.class)); + } catch (IOException ex) { + throw new IllegalArgumentException(fieldName + " must be valid JSON: " + ex.getMessage(), ex); + } + } + + private void applyContentPayload(Map payload, String markdown, String contentJson) { + boolean hasMarkdown = markdown != null; + boolean hasContent = contentJson != null && !contentJson.isBlank(); + if (hasMarkdown && hasContent) { + throw new IllegalArgumentException("markdown and contentJson are mutually exclusive"); + } + if (hasMarkdown) { + payload.put("markdown", markdown); + return; + } + if (hasContent) { + payload.put("children", parseJsonArray(contentJson, "contentJson")); + } + } + + private Map buildMediaPayload( + String fileUploadId, + String externalUrl, + String fileName, + String caption, + boolean supportsName) { + if ((fileUploadId == null || fileUploadId.isBlank()) && (externalUrl == null || externalUrl.isBlank())) { + throw new IllegalArgumentException("fileUploadId or externalUrl is required"); + } + if (fileUploadId != null && !fileUploadId.isBlank() && externalUrl != null && !externalUrl.isBlank()) { + throw new IllegalArgumentException("Provide either fileUploadId or externalUrl, not both"); + } + Map payload = new LinkedHashMap<>(); + if (caption != null && !caption.isBlank()) { + payload.put("caption", richTextArray(caption)); + } + if (fileUploadId != null && !fileUploadId.isBlank()) { + payload.put("type", "file_upload"); + payload.put("file_upload", Map.of("id", fileUploadId)); + } else { + payload.put("type", "external"); + payload.put("external", Map.of("url", externalUrl)); + } + if (supportsName && fileName != null && !fileName.isBlank()) { + payload.put("name", fileName); + } + return payload; + } + + private String normalizeBlockType(String blockType) { + if (blockType == null || blockType.isBlank()) { + return "file"; + } + String normalized = blockType.trim().toLowerCase(Locale.ROOT); + if (List.of("file", "image", "pdf", "audio", "video").contains(normalized)) { + return normalized; + } + throw new IllegalArgumentException("Unsupported file block type: " + blockType); + } + + private boolean supportsFileName(String blockType) { + return "file".equals(blockType); + } + + private Object jsonNodeToJava(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + if (node.isTextual()) { + return node.asText(); + } + if (node.isBoolean()) { + return node.asBoolean(); + } + if (node.isIntegralNumber()) { + return node.longValue(); + } + if (node.isFloatingPointNumber()) { + return node.doubleValue(); + } + if (node.isBinary()) { + try { + return Base64.getEncoder().encodeToString(node.binaryValue()); + } catch (IOException ex) { + return node.asText(""); + } + } + return objectMapper.convertValue(node, Object.class); + } + + private Long parseLong(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return null; + } } private String errorMessage(String rawBody, String fallback) { diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionChildSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionChildSummary.java new file mode 100644 index 0000000..b0fd541 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionChildSummary.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public record NotionChildSummary(String id,String title,String url,String kind){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDataSourceQueryResult.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDataSourceQueryResult.java new file mode 100644 index 0000000..a890b20 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDataSourceQueryResult.java @@ -0,0 +1,6 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import java.util.List; +import java.util.Map; + +public record NotionDataSourceQueryResult(String dataSourceId,int count,boolean hasMore,String nextCursor,List>results){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDataSourceSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDataSourceSummary.java new file mode 100644 index 0000000..f65c98a --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDataSourceSummary.java @@ -0,0 +1,5 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import java.util.Map; + +public record NotionDataSourceSummary(String id,String title,String url,Mapproperties){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDatabaseSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDatabaseSummary.java new file mode 100644 index 0000000..503409e --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionDatabaseSummary.java @@ -0,0 +1,6 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import java.util.List; +import java.util.Map; + +public record NotionDatabaseSummary(String id,String title,String url,List>dataSources){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionFileAttachmentSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionFileAttachmentSummary.java new file mode 100644 index 0000000..4496107 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionFileAttachmentSummary.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public record NotionFileAttachmentSummary(String name,String type,String url,String expiryTime,String sourceKind,String sourceName){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionFileUploadSummary.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionFileUploadSummary.java new file mode 100644 index 0000000..876ee91 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionFileUploadSummary.java @@ -0,0 +1,3 @@ +package me.golemcore.plugins.golemcore.notion.support; + +public record NotionFileUploadSummary(String id,String status,String filename,String contentType,Long contentLength,String uploadUrl,String expiryTime){} diff --git a/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPageDetails.java b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPageDetails.java new file mode 100644 index 0000000..793d017 --- /dev/null +++ b/golemcore/notion/src/main/java/me/golemcore/plugins/golemcore/notion/support/NotionPageDetails.java @@ -0,0 +1,6 @@ +package me.golemcore.plugins.golemcore.notion.support; + +import java.util.List; +import java.util.Map; + +public record NotionPageDetails(String id,String title,String url,Listfiles,MaprawProperties){} 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 index fa72459..c5505e5 100644 --- 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 @@ -5,6 +5,7 @@ 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.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; @@ -15,13 +16,16 @@ 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.NotionChildSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionDatabaseSummary; +import me.golemcore.plugins.golemcore.notion.support.NotionFileUploadSummary; import me.golemcore.plugins.golemcore.notion.support.NotionLocalIndexService; -import me.golemcore.plugins.golemcore.notion.support.NotionSearchHit; +import me.golemcore.plugins.golemcore.notion.support.NotionPageDetails; import me.golemcore.plugins.golemcore.notion.support.NotionPageSummary; import me.golemcore.plugins.golemcore.notion.support.NotionRagSyncService; +import me.golemcore.plugins.golemcore.notion.support.NotionSearchHit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.mockito.Mockito.verify; class NotionVaultServiceTest { @@ -60,6 +64,12 @@ void shouldListRootChildrenUsingPseudoPaths() { void shouldReadRootPageWhenPathIsBlankAndRespectMaxChars() { when(configService.getConfig()).thenReturn(config(true, true, true, true, 5)); apiClient.pageMarkdown.put("root-page", "123456789"); + apiClient.pageDetailsById.put("root-page", new NotionPageDetails( + "root-page", + "Root", + "https://notion.so/root-page", + List.of(), + Map.of())); ToolResult result = service.readNote(""); @@ -159,6 +169,82 @@ void shouldMoveAndRenamePageWhenTargetPathChangesParentAndLeaf() { "https://notion.so/todo-page"); } + @Test + void shouldCreateDatabaseUnderResolvedParentPage() { + apiClient.addChild("root-page", "projects-page", "Projects"); + apiClient.createdDatabase = new NotionDatabaseSummary( + "db-1", + "Roadmap", + "https://notion.so/db-1", + List.of(Map.of("id", "ds-1", "name", "Roadmap"))); + + ToolResult result = service.createDatabase("Projects", "Roadmap", "desc", "{}", true, "🗺️", null); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + Map database = assertInstanceOf(Map.class, data.get("database")); + assertEquals("db-1", database.get("id")); + assertEquals("Projects", data.get("parent_path")); + assertEquals("Roadmap", apiClient.lastCreateDatabaseTitle); + assertEquals("projects-page", apiClient.lastCreateDatabaseParentId); + } + + @Test + void shouldQueryDatabaseUsingSingleAvailableDataSource() { + apiClient.databaseById.put("db-1", new NotionDatabaseSummary( + "db-1", + "Roadmap", + "https://notion.so/db-1", + List.of(Map.of("id", "ds-1", "name", "Roadmap")))); + apiClient.queryResultsByDataSource.put("ds-1", List.of(Map.of( + "id", "page-1", + "title", "Item 1", + "url", "https://notion.so/page-1", + "properties", Map.of()))); + + ToolResult result = service.queryDatabase("db-1", null, null, null, 10, null); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("ds-1", data.get("data_source_id")); + assertEquals(1, data.get("count")); + } + + @Test + void shouldAttachFileToPageUsingUploadId() { + apiClient.appendedBlock = new NotionPageSummary("block-1", "spec.pdf", "https://notion.so/block-1"); + + ToolResult result = service.attachFileToPage("page-1", "upload-1", null, "spec.pdf", "spec", "file"); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals("block-1", data.get("block_id")); + assertEquals("page-1", data.get("page_id")); + assertEquals("upload-1", apiClient.lastAppendFileUploadId); + } + + @Test + void shouldListPageFiles() { + apiClient.pageDetailsById.put("page-1", new NotionPageDetails( + "page-1", + "Entry", + "https://notion.so/page-1", + List.of(new me.golemcore.plugins.golemcore.notion.support.NotionFileAttachmentSummary( + "spec.pdf", + "file", + "https://cdn.example/spec.pdf", + "2026-04-04T11:00:00Z", + "property", + "Files")), + Map.of())); + + ToolResult result = service.listPageFiles("page-1"); + + assertTrue(result.isSuccess()); + Map data = assertInstanceOf(Map.class, result.getData()); + assertEquals(1, data.get("count")); + } + @Test void shouldRejectDeletingConfiguredRootPage() { ToolResult result = service.deleteNote(""); @@ -211,12 +297,20 @@ 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 Map databaseById = new LinkedHashMap<>(); + private final Map pageDetailsById = new LinkedHashMap<>(); + private final Map>> queryResultsByDataSource = 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 String lastCreateDatabaseParentId; + private String lastCreateDatabaseTitle; + private String lastAppendFileUploadId; + private NotionDatabaseSummary createdDatabase; + private NotionPageSummary appendedBlock; private StubNotionApiClient(NotionPluginConfigService configService) { super(configService); @@ -234,6 +328,14 @@ public List listChildPages(String parentPageId) { return childrenByParent.getOrDefault(parentPageId, List.of()); } + @Override + public List listChildItems(String parentPageId) { + listChildCalls.add(parentPageId); + return childrenByParent.getOrDefault(parentPageId, List.of()).stream() + .map(page -> new NotionChildSummary(page.id(), page.title(), page.url(), "page")) + .toList(); + } + @Override public String retrievePageMarkdown(String pageId) { readMarkdownCalls.add(pageId); @@ -261,6 +363,72 @@ public void renamePage(String pageId, String title) { renameCalls.add(new RenameCall(pageId, title)); } + @Override + public NotionDatabaseSummary createDatabase( + String parentPageId, + String title, + String description, + Map properties, + boolean inline, + String iconEmoji, + String coverExternalUrl) { + lastCreateDatabaseParentId = parentPageId; + lastCreateDatabaseTitle = title; + return createdDatabase != null + ? createdDatabase + : new NotionDatabaseSummary("db-1", title, "https://notion.so/db-1", List.of()); + } + + @Override + public NotionDatabaseSummary retrieveDatabase(String databaseId) { + return databaseById.get(databaseId); + } + + @Override + public me.golemcore.plugins.golemcore.notion.support.NotionDataSourceQueryResult queryDataSource( + String dataSourceId, + String filterJson, + String sortsJson, + Integer limit, + String cursor) { + List> results = queryResultsByDataSource.getOrDefault(dataSourceId, List.of()); + return new me.golemcore.plugins.golemcore.notion.support.NotionDataSourceQueryResult( + dataSourceId, + results.size(), + false, + "", + results); + } + + @Override + public NotionPageDetails retrievePageDetails(String pageId) { + return pageDetailsById.get(pageId); + } + + @Override + public NotionPageSummary appendFileBlock( + String pageId, + String fileUploadId, + String externalUrl, + String fileName, + String caption, + String blockType) { + lastAppendFileUploadId = fileUploadId; + return appendedBlock != null + ? appendedBlock + : new NotionPageSummary("block-1", fileName, "https://notion.so/block-1"); + } + + @Override + public NotionFileUploadSummary createFileUpload( + String mode, + String filename, + String contentType, + Integer numberOfParts, + String externalUrl) { + return new NotionFileUploadSummary("upload-1", "pending", filename, contentType, null, "", ""); + } + 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/NotionVaultToolProviderTest.java b/golemcore/notion/src/test/java/me/golemcore/plugins/golemcore/notion/NotionVaultToolProviderTest.java index 74f52bd..a3f02a1 100644 --- 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 @@ -44,7 +44,24 @@ void shouldExposeSupportedVaultOperations() { "update_note", "delete_note", "move_note", - "rename_note"), operation.get("enum")); + "rename_note", + "create_database", + "read_database", + "update_database", + "create_data_source", + "read_data_source", + "update_data_source", + "query_database", + "create_database_entry", + "read_database_entry", + "update_database_entry", + "list_page_files", + "create_file_upload", + "upload_file_content", + "complete_file_upload", + "read_file_upload", + "list_file_uploads", + "attach_file_to_page"), operation.get("enum")); } @Test @@ -156,6 +173,57 @@ void shouldDispatchRenameToVaultService() { verify(service).renameNote("Projects/Todo", "Done"); } + @Test + void shouldDispatchCreateDatabaseToVaultService() { + when(service.createDatabase("Projects", "Roadmap", "desc", "{}", true, "🗺️", null)) + .thenReturn(ToolResult.success("created-db")); + + ToolResult result = provider.execute(Map.of( + "operation", "create_database", + "parent_path", "Projects", + "title", "Roadmap", + "description", "desc", + "properties_json", "{}", + "inline", true, + "icon_emoji", "🗺️")).join(); + + assertTrue(result.isSuccess()); + verify(service).createDatabase("Projects", "Roadmap", "desc", "{}", true, "🗺️", null); + } + + @Test + void shouldDefaultMissingInlineFlagToFalseWhenCreatingDatabase() { + when(service.createDatabase("Projects", "Roadmap", "desc", "{}", false, null, null)) + .thenReturn(ToolResult.success("created-db")); + + ToolResult result = provider.execute(Map.of( + "operation", "create_database", + "parent_path", "Projects", + "title", "Roadmap", + "description", "desc", + "properties_json", "{}")).join(); + + assertTrue(result.isSuccess()); + verify(service).createDatabase("Projects", "Roadmap", "desc", "{}", false, null, null); + } + + @Test + void shouldDispatchAttachFileToPageToVaultService() { + when(service.attachFileToPage("page-1", "upload-1", null, "spec.pdf", "spec", "file")) + .thenReturn(ToolResult.success("attached")); + + ToolResult result = provider.execute(Map.of( + "operation", "attach_file_to_page", + "page_id", "page-1", + "file_upload_id", "upload-1", + "file_name", "spec.pdf", + "caption", "spec", + "block_type", "file")).join(); + + assertTrue(result.isSuccess()); + verify(service).attachFileToPage("page-1", "upload-1", null, "spec.pdf", "spec", "file"); + } + @Test void shouldRejectUnsupportedOperation() { ToolResult result = provider.execute(Map.of("operation", "unknown")).join(); 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 index 1bbf632..8f30cec 100644 --- 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 @@ -8,6 +8,8 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; @@ -231,6 +233,55 @@ void shouldArchiveMoveAndRenamePagesUsingOfficialEndpoints() throws Exception { .path("properties").path("title").path("title").get(0).path("text").path("content").asText()); } + @Test + void shouldRetrieveFileUploadAndParseStringContentLength() { + respondJson(200, """ + { + "id": "upload-1", + "status": "uploaded", + "filename": "spec.pdf", + "content_type": "application/pdf", + "content_length": "42", + "upload_url": "https://uploads.example/upload-1", + "expiry_time": "2026-04-04T12:00:00Z" + } + """); + + NotionFileUploadSummary upload = client.retrieveFileUpload("upload-1"); + + assertEquals("upload-1", upload.id()); + assertEquals(42L, upload.contentLength()); + assertEquals("/v1/file_uploads/upload-1", requests.getFirst().path()); + } + + @Test + void shouldUploadFileContentUsingMultipartEndpoint() throws IOException { + Path tempFile = Files.createTempFile("notion-upload-", ".txt"); + try { + Files.writeString(tempFile, "hello notion", StandardCharsets.UTF_8); + respondJson(200, """ + { + "id": "upload-1", + "status": "uploaded", + "filename": "notion-upload.txt", + "content_type": "text/plain", + "content_length": 12 + } + """); + + NotionFileUploadSummary upload = client.uploadFileContent("upload-1", tempFile, "text/plain"); + + assertEquals("upload-1", upload.id()); + CapturedRequest request = requests.getFirst(); + assertEquals("POST", request.method()); + assertEquals("/v1/file_uploads/upload-1/send", request.path()); + assertTrue(request.body().contains("filename=\"" + tempFile.getFileName() + "\"")); + assertTrue(request.body().contains("hello notion")); + } finally { + Files.deleteIfExists(tempFile); + } + } + private void respondJson(int status, String body) { responses.add(new StubResponse(status, body)); } diff --git a/registry/golemcore/notion/index.yaml b/registry/golemcore/notion/index.yaml index 821b232..253e3db 100644 --- a/registry/golemcore/notion/index.yaml +++ b/registry/golemcore/notion/index.yaml @@ -1,8 +1,9 @@ id: golemcore/notion owner: golemcore name: notion -latest: 1.0.1 +latest: 1.1.0 versions: - 1.0.0 - 1.0.1 + - 1.1.0 source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/notion" diff --git a/registry/golemcore/notion/versions/1.1.0.yaml b/registry/golemcore/notion/versions/1.1.0.yaml new file mode 100644 index 0000000..84d83f9 --- /dev/null +++ b/registry/golemcore/notion/versions/1.1.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/notion +version: 1.1.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/notion/1.1.0/golemcore-notion-plugin-1.1.0.jar" +publishedAt: "2026-04-05T16:35:51Z" +sourceCommit: "761ce554140305805d10406c224bfae5ceebdeb4" +entrypoint: me.golemcore.plugins.golemcore.notion.NotionPluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/notion" +license: "Apache-2.0" +maintainers: + - alexk-dev diff --git a/scripts/plugins_repo.py b/scripts/plugins_repo.py index 959a09e..c2c1224 100644 --- a/scripts/plugins_repo.py +++ b/scripts/plugins_repo.py @@ -874,10 +874,9 @@ def run_release(plugin_id: str, bump: str, version_override: str | None, github_ raise SystemExit(f"Expected artifact was not built: {artifact_path}") version_path = next_spec.versions_dir / f"{new_version}.yaml" - if new_version != spec.version: - published_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - source_commit = run_command("git", "rev-parse", "HEAD") - write_text(version_path, render_registry_version(next_spec, new_version, published_at, source_commit)) + published_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + source_commit = run_command("git", "rev-parse", "HEAD") + write_text(version_path, render_registry_version(next_spec, new_version, published_at, source_commit)) write_github_output( github_output,