1
0
Fork 0
mirror of https://github.com/anyproto/anytype-heart.git synced 2025-06-10 01:51:07 +09:00
anytype-heart/cmd/api/handlers.go
2024-12-29 17:52:17 +01:00

1015 lines
36 KiB
Go

package api
import (
"crypto/rand"
"math/big"
"net/http"
"sort"
"github.com/gin-gonic/gin"
"github.com/gogo/protobuf/types"
"github.com/anyproto/anytype-heart/pb"
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
"github.com/anyproto/anytype-heart/util/pbtypes"
)
type CreateSpaceRequest struct {
Name string `json:"name"`
}
type CreateObjectRequest struct {
Name string `json:"name"`
Icon string `json:"icon"`
TemplateId string `json:"template_id"`
ObjectTypeUniqueKey string `json:"object_type_unique_key"`
WithChat bool `json:"with_chat"`
}
type AddMessageRequest struct {
Text string `json:"text"`
Style string `json:"style"`
}
// authDisplayCodeHandler generates a new challenge and returns the challenge ID
//
// @Summary Open a modal window with a code in Anytype Desktop app
// @Tags auth
// @Accept json
// @Produce json
// @Success 200 {object} AuthDisplayCodeResponse "Challenge ID"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /auth/displayCode [post]
func (a *ApiServer) authDisplayCodeHandler(c *gin.Context) {
resp := a.mw.AccountLocalLinkNewChallenge(c.Request.Context(), &pb.RpcAccountLocalLinkNewChallengeRequest{AppName: "api-test"})
if resp.Error.Code != pb.RpcAccountLocalLinkNewChallengeResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate a new challenge."})
}
c.JSON(http.StatusOK, AuthDisplayCodeResponse{ChallengeId: resp.ChallengeId})
}
// authTokenHandler retrieves an authentication token using a code and challenge ID
//
// @Summary Retrieve an authentication token using a code
// @Tags auth
// @Accept json
// @Produce json
// @Param code query string true "The code retrieved from Anytype Desktop app"
// @Param challenge_id query string true "The challenge ID"
// @Success 200 {object} AuthTokenResponse "Authentication token"
// @Failure 400 {object} ValidationError "Invalid input"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /auth/token [get]
func (a *ApiServer) authTokenHandler(c *gin.Context) {
if c.Query("challenge_id") == "" || c.Query("code") == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid input"})
return
}
// Call AccountLocalLinkSolveChallenge to retrieve session token and app key
resp := a.mw.AccountLocalLinkSolveChallenge(c.Request.Context(), &pb.RpcAccountLocalLinkSolveChallengeRequest{
ChallengeId: c.Query("challenge_id"),
Answer: c.Query("code"),
})
if resp.Error.Code != pb.RpcAccountLocalLinkSolveChallengeResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to authenticate user."})
return
}
c.JSON(http.StatusOK, AuthTokenResponse{
SessionToken: resp.SessionToken,
AppKey: resp.AppKey,
})
}
// getSpacesHandler retrieves a list of spaces
//
// @Summary Retrieve a list of spaces
// @Tags spaces
// @Accept json
// @Produce json
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} PaginatedResponse[Space] "List of spaces"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces [get]
func (a *ApiServer) getSpacesHandler(c *gin.Context) {
offset := c.GetInt("offset")
limit := c.GetInt("limit")
// Call ObjectSearch for all objects of type spaceView
resp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: a.accountInfo.TechSpaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyLayout.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.ObjectType_spaceView)),
},
{
RelationKey: bundle.RelationKeySpaceLocalStatus.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
},
{
RelationKey: bundle.RelationKeySpaceRemoteStatus.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
},
},
Sorts: []*model.BlockContentDataviewSort{
{
RelationKey: "name",
Type: model.BlockContentDataviewSort_Asc,
},
},
Keys: []string{"targetSpaceId", "name", "iconEmoji", "iconImage"},
})
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve list of spaces."})
return
}
if len(resp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No spaces found."})
return
}
paginatedSpaces, hasMore := paginate(resp.Records, offset, limit)
spaces := make([]Space, 0, len(paginatedSpaces))
for _, record := range paginatedSpaces {
workspace, statusCode, errorMessage := a.getWorkspaceInfo(record.Fields["targetSpaceId"].GetStringValue())
if statusCode != http.StatusOK {
c.JSON(statusCode, gin.H{"message": errorMessage})
return
}
workspace.Name = record.Fields["name"].GetStringValue()
workspace.Icon = a.getIconFromEmojiOrImage(record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
spaces = append(spaces, workspace)
}
respondWithPagination(c, http.StatusOK, spaces, len(resp.Records), offset, limit, hasMore)
}
// createSpaceHandler creates a new space
//
// @Summary Create a new Space
// @Tags spaces
// @Accept json
// @Produce json
// @Param name body string true "Space Name"
// @Success 200 {object} CreateSpaceResponse "Space created successfully"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces [post]
func (a *ApiServer) createSpaceHandler(c *gin.Context) {
nameRequest := CreateSpaceRequest{}
if err := c.BindJSON(&nameRequest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid JSON"})
return
}
name := nameRequest.Name
iconOption, err := rand.Int(rand.Reader, big.NewInt(13))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate random icon."})
return
}
// Create new workspace with a random icon and import default use case
resp := a.mw.WorkspaceCreate(c.Request.Context(), &pb.RpcWorkspaceCreateRequest{
Details: &types.Struct{
Fields: map[string]*types.Value{
"iconOption": {Kind: &types.Value_NumberValue{NumberValue: float64(iconOption.Int64())}},
"name": {Kind: &types.Value_StringValue{StringValue: name}},
"spaceDashboardId": {Kind: &types.Value_StringValue{
StringValue: "lastOpened",
}},
},
},
UseCase: pb.RpcObjectImportUseCaseRequest_GET_STARTED,
WithChat: true,
})
if resp.Error.Code != pb.RpcWorkspaceCreateResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to create a new space."})
return
}
c.JSON(http.StatusOK, CreateSpaceResponse{SpaceId: resp.SpaceId, Name: name})
}
// getMembersHandler retrieves a list of members for the specified space
//
// @Summary Retrieve a list of members for the specified Space
// @Tags spaces
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} PaginatedResponse[Member] "List of members"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/members [get]
func (a *ApiServer) getMembersHandler(c *gin.Context) {
spaceId := c.Param("space_id")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
// Call ObjectSearch for all objects of type participant
resp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyLayout.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.ObjectType_participant)),
},
{
RelationKey: bundle.RelationKeyParticipantStatus.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.ParticipantStatus_Active)),
},
},
Sorts: []*model.BlockContentDataviewSort{
{
RelationKey: "name",
Type: model.BlockContentDataviewSort_Asc,
},
},
Keys: []string{"id", "name", "iconEmoji", "iconImage", "identity", "globalName", "participantPermissions"},
})
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve list of members."})
return
}
if len(resp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No members found."})
return
}
paginatedMembers, hasMore := paginate(resp.Records, offset, limit)
members := make([]Member, 0, len(paginatedMembers))
for _, record := range paginatedMembers {
icon := a.getIconFromEmojiOrImage(record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
member := Member{
Type: "space_member",
Id: record.Fields["id"].GetStringValue(),
Name: record.Fields["name"].GetStringValue(),
Icon: icon,
Identity: record.Fields["identity"].GetStringValue(),
GlobalName: record.Fields["globalName"].GetStringValue(),
Role: model.ParticipantPermissions_name[int32(record.Fields["participantPermissions"].GetNumberValue())],
}
members = append(members, member)
}
respondWithPagination(c, http.StatusOK, members, len(resp.Records), offset, limit, hasMore)
}
// getObjectsHandler retrieves objects in a specific space
//
// @Summary Retrieve objects in a specific space
// @Tags space_objects
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} map[string][]Object "List of objects"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/objects [get]
func (a *ApiServer) getObjectsHandler(c *gin.Context) {
spaceId := c.Param("space_id")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
resp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyLayout.String(),
Condition: model.BlockContentDataviewFilter_In,
Value: pbtypes.IntList([]int{
int(model.ObjectType_basic),
int(model.ObjectType_profile),
int(model.ObjectType_todo),
int(model.ObjectType_note),
int(model.ObjectType_bookmark),
int(model.ObjectType_set),
int(model.ObjectType_collection),
int(model.ObjectType_participant),
}...),
},
},
Sorts: []*model.BlockContentDataviewSort{{
RelationKey: bundle.RelationKeyLastModifiedDate.String(),
Type: model.BlockContentDataviewSort_Desc,
Format: model.RelationFormat_longtext,
IncludeTime: true,
EmptyPlacement: model.BlockContentDataviewSort_NotSpecified,
}},
Keys: []string{"id", "name", "type", "layout", "iconEmoji", "iconImage"},
})
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve list of objects."})
return
}
if len(resp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No objects found."})
return
}
paginatedObjects, hasMore := paginate(resp.Records, offset, limit)
objects := make([]Object, 0, len(paginatedObjects))
for _, record := range paginatedObjects {
icon := a.getIconFromEmojiOrImage(record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
objectTypeName, statusCode, errorMessage := a.resolveTypeToName(spaceId, record.Fields["type"].GetStringValue())
if statusCode != http.StatusOK {
c.JSON(statusCode, gin.H{"message": errorMessage})
return
}
objectShowResp := a.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: record.Fields["id"].GetStringValue(),
})
object := Object{
// TODO fix type inconsistency
Type: model.ObjectTypeLayout_name[int32(record.Fields["layout"].GetNumberValue())],
Id: record.Fields["id"].GetStringValue(),
Name: record.Fields["name"].GetStringValue(),
Icon: icon,
ObjectType: objectTypeName,
SpaceId: spaceId,
RootId: objectShowResp.ObjectView.RootId,
Blocks: a.getBlocks(objectShowResp),
Details: a.getDetails(objectShowResp),
}
objects = append(objects, object)
}
respondWithPagination(c, http.StatusOK, objects, len(resp.Records), offset, limit, hasMore)
}
// getObjectHandler retrieves a specific object in a space
//
// @Summary Retrieve a specific object in a space
// @Tags space_objects
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param object_id path string true "The ID of the object"
// @Success 200 {object} Object "The requested object"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/objects/{object_id} [get]
func (a *ApiServer) getObjectHandler(c *gin.Context) {
spaceId := c.Param("space_id")
objectId := c.Param("object_id")
resp := a.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: objectId,
})
if resp.Error.Code != pb.RpcObjectShowResponseError_NULL {
if resp.Error.Code == pb.RpcObjectShowResponseError_NOT_FOUND {
c.JSON(http.StatusNotFound, gin.H{"message": "Object not found", "space_id": spaceId, "object_id": objectId})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve object."})
return
}
objectTypeName, statusCode, errorMessage := a.resolveTypeToName(spaceId, resp.ObjectView.Details[0].Details.Fields["type"].GetStringValue())
if statusCode != http.StatusOK {
c.JSON(statusCode, gin.H{"message": errorMessage})
return
}
object := Object{
Type: "object",
Id: objectId,
Name: resp.ObjectView.Details[0].Details.Fields["name"].GetStringValue(),
Icon: resp.ObjectView.Details[0].Details.Fields["iconEmoji"].GetStringValue(),
ObjectType: objectTypeName,
RootId: resp.ObjectView.RootId,
Blocks: a.getBlocks(resp),
Details: a.getDetails(resp),
}
c.JSON(http.StatusOK, gin.H{"object": object})
}
// createObjectHandler creates a new object in a specific space
//
// @Summary Create a new object in a specific space
// @Tags space_objects
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param object body map[string]string true "Object details (e.g., name)"
// @Success 200 {object} Object "The created object"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/objects [post]
func (a *ApiServer) createObjectHandler(c *gin.Context) {
spaceId := c.Param("space_id")
request := CreateObjectRequest{}
if err := c.BindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid JSON"})
return
}
resp := a.mw.ObjectCreate(c.Request.Context(), &pb.RpcObjectCreateRequest{
Details: &types.Struct{
Fields: map[string]*types.Value{
"name": {Kind: &types.Value_StringValue{StringValue: request.Name}},
"iconEmoji": {Kind: &types.Value_StringValue{StringValue: request.Icon}},
},
},
TemplateId: request.TemplateId,
SpaceId: spaceId,
ObjectTypeUniqueKey: request.ObjectTypeUniqueKey,
WithChat: request.WithChat,
})
if resp.Error.Code != pb.RpcObjectCreateResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to create a new object."})
return
}
object := Object{
Type: "object",
Id: resp.ObjectId,
Name: resp.Details.Fields["name"].GetStringValue(),
Icon: resp.Details.Fields["iconEmoji"].GetStringValue(),
ObjectType: request.ObjectTypeUniqueKey,
SpaceId: resp.Details.Fields["spaceId"].GetStringValue(),
// TODO populate other fields
// RootId: resp.RootId,
// Blocks: []Block{},
// Details: []Detail{},
}
c.JSON(http.StatusOK, gin.H{"object": object})
}
// updateObjectHandler updates an existing object in a specific space
//
// @Summary Update an existing object in a specific space
// @Tags space_objects
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param object_id path string true "The ID of the object"
// @Param object body Object true "The updated object details"
// @Success 200 {object} Object "The updated object"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/objects/{object_id} [put]
func (a *ApiServer) updateObjectHandler(c *gin.Context) {
spaceId := c.Param("space_id")
objectId := c.Param("object_id")
// TODO: Implement logic to update an existing object
c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented yet", "space_id": spaceId, "object_id": objectId})
}
// getObjectTypesHandler retrieves object types in a specific space
//
// @Summary Retrieve object types in a specific space
// @Tags types_and_templates
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} map[string]ObjectType "List of object types"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/objectTypes [get]
func (a *ApiServer) getObjectTypesHandler(c *gin.Context) {
spaceId := c.Param("space_id")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
resp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyLayout.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.ObjectType_objectType)),
},
{
RelationKey: bundle.RelationKeyIsHidden.String(),
Condition: model.BlockContentDataviewFilter_NotEqual,
Value: pbtypes.Bool(true),
},
},
Sorts: []*model.BlockContentDataviewSort{
{
RelationKey: "name",
Type: model.BlockContentDataviewSort_Asc,
},
},
Keys: []string{"id", "uniqueKey", "name", "iconEmoji"},
})
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve object types."})
return
}
if len(resp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No object types found."})
return
}
paginatedTypes, hasMore := paginate(resp.Records, offset, limit)
objectTypes := make([]ObjectType, 0, len(paginatedTypes))
for _, record := range paginatedTypes {
objectTypes = append(objectTypes, ObjectType{
Type: "object_type",
Id: record.Fields["id"].GetStringValue(),
UniqueKey: record.Fields["uniqueKey"].GetStringValue(),
Name: record.Fields["name"].GetStringValue(),
Icon: record.Fields["iconEmoji"].GetStringValue(),
})
}
respondWithPagination(c, http.StatusOK, objectTypes, len(resp.Records), offset, limit, hasMore)
}
// getObjectTypeTemplatesHandler retrieves a list of templates for a specific object type in a space
//
// @Summary Retrieve a list of templates for a specific object type in a space
// @Tags types_and_templates
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param typeId path string true "The ID of the object type"
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} map[string][]ObjectTemplate "List of templates"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 404 {object} NotFoundError "Resource not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /spaces/{space_id}/objectTypes/{typeId}/templates [get]
func (a *ApiServer) getObjectTypeTemplatesHandler(c *gin.Context) {
spaceId := c.Param("space_id")
typeId := c.Param("typeId")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
// First, determine the type ID of "ot-template" in the space
templateTypeIdResp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyUniqueKey.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.String("ot-template"),
},
},
Keys: []string{"id"},
})
if templateTypeIdResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve template type."})
return
}
if len(templateTypeIdResp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "Template type not found."})
return
}
templateTypeId := templateTypeIdResp.Records[0].Fields["id"].GetStringValue()
// Then, search all objects of the template type and filter by the target object type
templateObjectsResp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyType.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.String(templateTypeId),
},
},
Keys: []string{"id", "targetObjectType", "name", "iconEmoji"},
})
if templateObjectsResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve template objects."})
return
}
if len(templateObjectsResp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No templates found."})
return
}
templateIds := make([]string, 0)
for _, record := range templateObjectsResp.Records {
if record.Fields["targetObjectType"].GetStringValue() == typeId {
templateIds = append(templateIds, record.Fields["id"].GetStringValue())
}
}
// Finally, open each template and populate the response
paginatedTemplates, hasMore := paginate(templateIds, offset, limit)
templates := make([]ObjectTemplate, 0, len(paginatedTemplates))
for _, templateId := range paginatedTemplates {
templateResp := a.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: templateId,
})
if templateResp.Error.Code != pb.RpcObjectShowResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve template."})
return
}
templates = append(templates, ObjectTemplate{
Type: "object_template",
Id: templateId,
Name: templateResp.ObjectView.Details[0].Details.Fields["name"].GetStringValue(),
Icon: templateResp.ObjectView.Details[0].Details.Fields["iconEmoji"].GetStringValue(),
})
}
respondWithPagination(c, http.StatusOK, templates, len(templateIds), offset, limit, hasMore)
}
// searchHandler searches and retrieves objects across all the spaces
//
// @Summary Search and retrieve objects across all the spaces
// @Tags search
// @Accept json
// @Produce json
// @Param query query string false "The search term to filter objects by name"
// @Param object_type query string false "Specify object type for search"
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} map[string][]Object "List of objects"
// @Failure 403 {object} UnauthorizedError "Unauthorized"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /search [get]
func (a *ApiServer) searchHandler(c *gin.Context) {
searchQuery := c.Query("query")
objectType := c.Query("object_type")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
// First, call ObjectSearch for all objects of type spaceView
spaceResp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: a.accountInfo.TechSpaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyLayout.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.ObjectType_spaceView)),
},
{
RelationKey: bundle.RelationKeySpaceLocalStatus.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
},
{
RelationKey: bundle.RelationKeySpaceRemoteStatus.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
},
},
Keys: []string{"targetSpaceId"},
})
if spaceResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve list of spaces."})
return
}
if len(spaceResp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No spaces found."})
return
}
// Then, get objects from each space that match the search parameters
var filters = []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyLayout.String(),
Condition: model.BlockContentDataviewFilter_In,
Value: pbtypes.IntList([]int{
int(model.ObjectType_basic),
int(model.ObjectType_profile),
int(model.ObjectType_todo),
int(model.ObjectType_note),
int(model.ObjectType_bookmark),
int(model.ObjectType_set),
int(model.ObjectType_collection),
int(model.ObjectType_participant),
}...),
},
{
RelationKey: bundle.RelationKeyIsHidden.String(),
Condition: model.BlockContentDataviewFilter_NotEqual,
Value: pbtypes.Bool(true),
},
}
if searchQuery != "" {
// TODO also include snippet for notes
filters = append(filters, &model.BlockContentDataviewFilter{
RelationKey: bundle.RelationKeyName.String(),
Condition: model.BlockContentDataviewFilter_Like,
Value: pbtypes.String(searchQuery),
})
}
if objectType != "" {
filters = append(filters, &model.BlockContentDataviewFilter{
RelationKey: bundle.RelationKeyType.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.String(objectType),
})
}
searchResults := make([]Object, 0)
for _, spaceRecord := range spaceResp.Records {
spaceId := spaceRecord.Fields["targetSpaceId"].GetStringValue()
objectResp := a.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: filters,
Sorts: []*model.BlockContentDataviewSort{{
RelationKey: bundle.RelationKeyLastModifiedDate.String(),
Type: model.BlockContentDataviewSort_Desc,
Format: model.RelationFormat_longtext,
IncludeTime: true,
EmptyPlacement: model.BlockContentDataviewSort_NotSpecified,
}},
Keys: []string{"id", "name", "type", "layout", "iconEmoji", "iconImage"},
// TODO split limit between spaces
// Limit: paginationLimitPerSpace,
// FullText: searchTerm,
})
if objectResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve list of objects."})
return
}
if len(objectResp.Records) == 0 {
continue
}
for _, record := range objectResp.Records {
icon := a.getIconFromEmojiOrImage(record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
objectTypeName, statusCode, errorMessage := a.resolveTypeToName(spaceId, record.Fields["type"].GetStringValue())
if statusCode != http.StatusOK {
c.JSON(statusCode, gin.H{"message": errorMessage})
return
}
objectShowResp := a.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: record.Fields["id"].GetStringValue(),
})
searchResults = append(searchResults, Object{
Type: model.ObjectTypeLayout_name[int32(record.Fields["layout"].GetNumberValue())],
Id: record.Fields["id"].GetStringValue(),
Name: record.Fields["name"].GetStringValue(),
Icon: icon,
ObjectType: objectTypeName,
SpaceId: spaceId,
RootId: objectShowResp.ObjectView.RootId,
Blocks: a.getBlocks(objectShowResp),
Details: a.getDetails(objectShowResp),
})
}
}
// sort after lastModifiedDate to achieve descending sort order across all spaces
sort.Slice(searchResults, func(i, j int) bool {
return searchResults[i].Details[0].Details["lastModifiedDate"].(float64) > searchResults[j].Details[0].Details["lastModifiedDate"].(float64)
})
// TODO: solve global pagination vs per space pagination
paginatedResults, hasMore := paginate(searchResults, offset, limit)
respondWithPagination(c, http.StatusOK, paginatedResults, len(searchResults), offset, limit, hasMore)
}
// getChatMessagesHandler retrieves last chat messages
//
// @Summary Retrieve last chat messages
// @Tags chat
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param offset query int false "The number of items to skip before starting to collect the result set"
// @Param limit query int false "The number of items to return" default(100)
// @Success 200 {object} map[string][]ChatMessage "List of chat messages"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /v1/spaces/{space_id}/chat/messages [get]
func (a *ApiServer) getChatMessagesHandler(c *gin.Context) {
spaceId := c.Param("space_id")
// TODO: implement offset
// offset := c.GetInt("offset")
limit := c.GetInt("limit")
chatId, statusCode, errorMessage := a.getChatIdForSpace(spaceId)
if statusCode != http.StatusOK {
c.JSON(statusCode, gin.H{"message": errorMessage})
return
}
lastMessages := a.mw.ChatSubscribeLastMessages(c.Request.Context(), &pb.RpcChatSubscribeLastMessagesRequest{
ChatObjectId: chatId,
Limit: int32(limit),
})
if lastMessages.Error.Code != pb.RpcChatSubscribeLastMessagesResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve last messages."})
}
messages := make([]ChatMessage, 0, len(lastMessages.Messages))
for _, message := range lastMessages.Messages {
attachments := make([]Attachment, 0, len(message.Attachments))
for _, attachment := range message.Attachments {
target := attachment.Target
if attachment.Type != model.ChatMessageAttachment_LINK {
target = a.getGatewayURLForMedia(attachment.Target, false)
}
attachments = append(attachments, Attachment{
Target: target,
Type: model.ChatMessageAttachmentAttachmentType_name[int32(attachment.Type)],
})
}
messages = append(messages, ChatMessage{
Type: "chat_message",
Id: message.Id,
Creator: message.Creator,
CreatedAt: message.CreatedAt,
ReplyToMessageId: message.ReplyToMessageId,
Message: MessageContent{
Text: message.Message.Text,
// TODO: params
// Style: nil,
// Marks: nil,
},
Attachments: attachments,
Reactions: Reactions{
ReactionsMap: func() map[string]IdentityList {
reactionsMap := make(map[string]IdentityList)
for emoji, ids := range message.Reactions.Reactions {
reactionsMap[emoji] = IdentityList{Ids: ids.Ids}
}
return reactionsMap
}(),
},
})
}
c.JSON(http.StatusOK, gin.H{"chatId": chatId, "messages": messages})
}
// getChatMessageHandler retrieves a specific chat message by message_id
//
// @Summary Retrieve a specific chat message
// @Tags chat
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param message_id path string true "Message ID"
// @Success 200 {object} ChatMessage "Chat message"
// @Failure 404 {object} NotFoundError "Message not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /v1/spaces/{space_id}/chat/messages/{message_id} [get]
func (a *ApiServer) getChatMessageHandler(c *gin.Context) {
// TODO: Implement logic to retrieve a specific chat message by message_id
c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented yet"})
}
// addChatMessageHandler adds a new chat message to chat
//
// @Summary Add a new chat message
// @Tags chat
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param message body ChatMessage true "Chat message"
// @Success 201 {object} ChatMessage "Created chat message"
// @Failure 400 {object} ValidationError "Invalid input"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /v1/spaces/{space_id}/chat/messages [post]
func (a *ApiServer) addChatMessageHandler(c *gin.Context) {
spaceId := c.Param("space_id")
request := AddMessageRequest{}
if err := c.BindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid JSON"})
return
}
chatId, statusCode, errorMessage := a.getChatIdForSpace(spaceId)
if statusCode != http.StatusOK {
c.JSON(statusCode, gin.H{"message": errorMessage})
return
}
resp := a.mw.ChatAddMessage(c.Request.Context(), &pb.RpcChatAddMessageRequest{
ChatObjectId: chatId,
Message: &model.ChatMessage{
Id: "",
OrderId: "",
Creator: "",
CreatedAt: 0,
ModifiedAt: 0,
ReplyToMessageId: "",
Message: &model.ChatMessageMessageContent{
Text: request.Text,
// TODO: param
// Style: request.Style,
},
},
})
if resp.Error.Code != pb.RpcChatAddMessageResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to create message."})
}
c.JSON(http.StatusOK, gin.H{"messageId": resp.MessageId})
}
// updateChatMessageHandler updates an existing chat message by message_id
//
// @Summary Update an existing chat message
// @Tags chat
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param message_id path string true "Message ID"
// @Param message body ChatMessage true "Chat message"
// @Success 200 {object} ChatMessage "Updated chat message"
// @Failure 400 {object} ValidationError "Invalid input"
// @Failure 404 {object} NotFoundError "Message not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /v1/spaces/{space_id}/chat/messages/{message_id} [put]
func (a *ApiServer) updateChatMessageHandler(c *gin.Context) {
// TODO: Implement logic to update an existing chat message by message_id
c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented yet"})
}
// deleteChatMessageHandler deletes a chat message by message_id
//
// @Summary Delete a chat message
// @Tags chat
// @Accept json
// @Produce json
// @Param space_id path string true "The ID of the space"
// @Param message_id path string true "Message ID"
// @Success 204 "Message deleted successfully"
// @Failure 404 {object} NotFoundError "Message not found"
// @Failure 502 {object} ServerError "Internal server error"
// @Router /v1/spaces/{space_id}/chat/messages/{message_id} [delete]
func (a *ApiServer) deleteChatMessageHandler(c *gin.Context) {
// TODO: Implement logic to delete a chat message by message_id
c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented yet"})
}