diff --git a/core/kanban/group.go b/core/kanban/group.go index 2dfef4f4f..653ddf2f6 100644 --- a/core/kanban/group.go +++ b/core/kanban/group.go @@ -1,9 +1,8 @@ package kanban - type GroupSlice []Group -func(gs GroupSlice) Len() int { +func (gs GroupSlice) Len() int { return len(gs) } @@ -15,13 +14,30 @@ func (gs GroupSlice) Swap(i, j int) { gs[i], gs[j] = gs[j], gs[i] } - type Group struct { - Id string + Id string Data GroupData } - type GroupData struct { Ids []string } + +type GroupCounts []*GroupCount + +func (gc GroupCounts) Len() int { + return len(gc) +} + +func (gc GroupCounts) Less(i, j int) bool { + return gc[i].Count > gc[j].Count +} + +func (gc GroupCounts) Swap(i, j int) { + gc[i], gc[j] = gc[j], gc[i] +} + +type GroupCount struct { + Group + Count int +} diff --git a/core/kanban/group_object.go b/core/kanban/group_object.go new file mode 100644 index 000000000..37773e792 --- /dev/null +++ b/core/kanban/group_object.go @@ -0,0 +1,218 @@ +package kanban + +import ( + "fmt" + "sort" + "strings" + + "github.com/gogo/protobuf/types" + + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +const ( + defaultGroup = "empty" + columnLimit = 100 +) + +type GroupObject struct { + key string + store objectstore.ObjectStore + objectsWithGivenType []database.Record + objectsWithRelation []database.Record +} + +func (t *GroupObject) InitGroups(spaceId string, f *database.Filters) error { + relation, err := t.retrieveRelationFromStore(spaceId) + if err != nil { + return err + } + + t.objectsWithGivenType, err = t.retrieveObjectsWithGivenType(spaceId, relation) + if err != nil { + return fmt.Errorf("failed to init kanban by object relation: %w", err) + } + + t.objectsWithRelation, err = t.retrieveObjectsWithGivenRelation(f, spaceId) + if err != nil { + return fmt.Errorf("failed to init kanban by object relation: %w", err) + } + return nil +} + +func (t *GroupObject) retrieveObjectsWithGivenRelation(f *database.Filters, spaceID string) ([]database.Record, error) { + spaceFilter := database.FilterEq{ + Key: bundle.RelationKeySpaceId.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(spaceID), + } + + filterEmptyRelation := database.FiltersAnd{ + database.FilterNot{Filter: database.FilterEmpty{Key: t.key}}, + spaceFilter, + } + + if f == nil { + f = &database.Filters{FilterObj: filterEmptyRelation} + } else { + f.FilterObj = database.FiltersAnd{f.FilterObj, filterEmptyRelation} + } + + return t.store.QueryRaw(f, 0, 0) +} + +func (t *GroupObject) retrieveObjectsWithGivenType(spaceID string, relation database.Record) ([]database.Record, error) { + objectTypes := pbtypes.GetValueList(relation.Details, bundle.RelationKeyRelationFormatObjectTypes.String()) + filterObjectTypes := database.FilterIn{ + Key: bundle.RelationKeyType.String(), + Value: &types.ListValue{Values: objectTypes}, + } + spaceFilter := database.FilterEq{ + Key: bundle.RelationKeySpaceId.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(spaceID), + } + filter := &database.Filters{FilterObj: database.FiltersAnd{spaceFilter, filterObjectTypes}} + if len(objectTypes) == 0 { + filter = t.makeFilterForEmptyObjectTypesList(spaceFilter) + } + return t.store.QueryRaw(filter, 0, 0) +} + +func (t *GroupObject) makeFilterForEmptyObjectTypesList(spaceFilter database.FilterEq) *database.Filters { + list := pbtypes.GetList(pbtypes.IntList([]int{int(model.ObjectType_relationOption), int(model.ObjectType_space), int(model.ObjectType_spaceView)}...)) + filterLayouts := database.FilterNot{ + Filter: database.FilterIn{ + Key: bundle.RelationKeyLayout.String(), + Value: &types.ListValue{Values: list}, + }, + } + filterRelation := database.FilterNot{Filter: database.FiltersAnd{ + database.FilterEq{ + Key: bundle.RelationKeyRelationKey.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(t.key), + }, + database.FilterEq{ + Key: bundle.RelationKeyLayout.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.Int64(int64(model.ObjectType_relation)), + }, + }} + return &database.Filters{FilterObj: database.FiltersAnd{spaceFilter, filterLayouts, filterRelation}} +} + +func (t *GroupObject) retrieveRelationFromStore(spaceID string) (database.Record, error) { + relationFilter := database.FiltersAnd{ + database.FilterEq{ + Key: bundle.RelationKeySpaceId.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(spaceID), + }, + database.FilterEq{ + Key: bundle.RelationKeyRelationKey.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String(t.key), + }, + database.FilterEq{ + Key: bundle.RelationKeyLayout.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.Int64(int64(model.ObjectType_relation)), + }, + } + + relations, err := t.store.QueryRaw(&database.Filters{FilterObj: relationFilter}, 0, 0) + if err != nil { + return database.Record{}, err + } + + if len(relations) == 0 { + return database.Record{}, fmt.Errorf("no such relations") + } + return relations[0], nil +} + +func (t *GroupObject) MakeGroups() (GroupCounts, error) { + uniqMap := make(map[string]*GroupCount) + for _, v := range t.objectsWithGivenType { + t.makeGroupsFromObjectsWithGivenType(v, uniqMap) + } + for _, v := range t.objectsWithRelation { + t.makeGroupsFromObjectsWithRelation(v, uniqMap) + } + + var groups GroupCounts = make([]*GroupCount, 0, len(uniqMap)) + for _, group := range uniqMap { + groups = append(groups, group) + } + sort.Sort(groups) + if groups.Len() > columnLimit { + groups = groups[:columnLimit] + } + return groups, nil +} + +func (t *GroupObject) makeGroupsFromObjectsWithGivenType(v database.Record, uniqMap map[string]*GroupCount) { + if objectId := pbtypes.GetString(v.Details, bundle.RelationKeyId.String()); objectId != "" { + uniqMap[objectId] = &GroupCount{ + Group: Group{ + Id: objectId, + Data: GroupData{Ids: []string{objectId}}, + }, + } + } +} + +func (t *GroupObject) makeGroupsFromObjectsWithRelation(v database.Record, uniqMap map[string]*GroupCount) { + if objectIds := pbtypes.GetStringList(v.Details, t.key); len(objectIds) > 1 { + sort.Strings(objectIds) + hash := strings.Join(objectIds, "") + if groups, ok := uniqMap[hash]; !ok { + uniqMap[hash] = &GroupCount{ + Group: Group{ + Id: hash, + Data: GroupData{Ids: objectIds}, + }, + Count: 1, + } + } else { + groups.Count++ + } + } + if objectIds := pbtypes.GetStringList(v.Details, t.key); len(objectIds) == 1 { + if groups, ok := uniqMap[objectIds[0]]; ok { + groups.Count++ + } + } +} + +func (t *GroupObject) MakeDataViewGroups() ([]*model.BlockContentDataviewGroup, error) { + groups, err := t.MakeGroups() + if err != nil { + return nil, err + } + result := make([]*model.BlockContentDataviewGroup, 0, len(groups)) + for _, g := range groups { + result = append(result, &model.BlockContentDataviewGroup{ + Id: Hash(g.Id), + Value: &model.BlockContentDataviewGroupValueOfTag{ + Tag: &model.BlockContentDataviewTag{ + Ids: g.Data.Ids, + }}, + }) + } + + result = append([]*model.BlockContentDataviewGroup{{ + Id: defaultGroup, + Value: &model.BlockContentDataviewGroupValueOfTag{ + Tag: &model.BlockContentDataviewTag{ + Ids: make([]string, 0), + }}, + }}, result...) + + return result, nil +} diff --git a/core/kanban/group_object_test.go b/core/kanban/group_object_test.go new file mode 100644 index 000000000..fd68b4a0a --- /dev/null +++ b/core/kanban/group_object_test.go @@ -0,0 +1,265 @@ +package kanban + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/database" + "github.com/anyproto/anytype-heart/pkg/lib/localstore/objectstore" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +func TestGroupObject_InitGroups(t *testing.T) { + t.Run("no relations - return error", func(t *testing.T) { + // given + storeFixture := objectstore.NewStoreFixture(t) + groupObject := GroupObject{ + key: "test", + store: storeFixture, + } + // when + err := groupObject.InitGroups("spaceId", nil) + + // then + assert.NotNil(t, err) + }) + t.Run("no objects with type from relation - only empty group", func(t *testing.T) { + // given + storeFixture := objectstore.NewStoreFixture(t) + groupObject := GroupObject{ + key: "test", + store: storeFixture, + } + storeFixture.AddObjects(t, []objectstore.TestObject{ + { + bundle.RelationKeyId: pbtypes.String("test"), + bundle.RelationKeyRelationKey: pbtypes.String("test"), + bundle.RelationKeyLayout: pbtypes.Int64(int64(model.ObjectType_relation)), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + }, + }) + + // when + err := groupObject.InitGroups("spaceId", nil) + assert.Nil(t, err) + + groups, err := groupObject.MakeDataViewGroups() + assert.Nil(t, err) + + // then + assert.Len(t, groups, 1) + assert.Equal(t, "empty", groups[0].Id) + }) + t.Run("objects with relation exists - create groups based on these objects", func(t *testing.T) { + // given + storeFixture := objectstore.NewStoreFixture(t) + groupObject := GroupObject{ + key: "test", + store: storeFixture, + } + storeFixture.AddObjects(t, []objectstore.TestObject{ + { + bundle.RelationKeyId: pbtypes.String("test"), + bundle.RelationKeyRelationKey: pbtypes.String("test"), + bundle.RelationKeyLayout: pbtypes.Int64(int64(model.ObjectType_relation)), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyRelationFormatObjectTypes: pbtypes.StringList([]string{"typeId"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object1"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object2"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + }) + + // when + err := groupObject.InitGroups("spaceId", nil) + assert.Nil(t, err) + + groups, err := groupObject.MakeDataViewGroups() + assert.Nil(t, err) + + // then + assert.Len(t, groups, 3) // empty, object1, object2 + }) + t.Run("objects with types exists - create groups based on these objects", func(t *testing.T) { + // given + storeFixture := objectstore.NewStoreFixture(t) + groupObject := GroupObject{ + key: "test", + store: storeFixture, + } + storeFixture.AddObjects(t, []objectstore.TestObject{ + { + bundle.RelationKeyId: pbtypes.String("test"), + bundle.RelationKeyRelationKey: pbtypes.String("test"), + bundle.RelationKeyLayout: pbtypes.Int64(int64(model.ObjectType_relation)), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyRelationFormatObjectTypes: pbtypes.StringList([]string{"typeId"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object1"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object2"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object3"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId3"), + }, + { + bundle.RelationKeyId: pbtypes.String("object3"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object1"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object4"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object2"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object5"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object1", "object2"}), + }, + }) + + // when + err := groupObject.InitGroups("spaceId", nil) + assert.Nil(t, err) + + groups, err := groupObject.MakeDataViewGroups() + assert.Nil(t, err) + + // then + assert.Len(t, groups, 4) // empty, object1, object2, object1 object 2 + }) + t.Run("objects with types exists, but we also have additional filter - create groups based on these objects", func(t *testing.T) { + // given + storeFixture := objectstore.NewStoreFixture(t) + groupObject := GroupObject{ + key: "test", + store: storeFixture, + } + storeFixture.AddObjects(t, []objectstore.TestObject{ + { + bundle.RelationKeyId: pbtypes.String("test"), + bundle.RelationKeyRelationKey: pbtypes.String("test"), + bundle.RelationKeyLayout: pbtypes.Int64(int64(model.ObjectType_relation)), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyRelationFormatObjectTypes: pbtypes.StringList([]string{"typeId"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object1"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object2"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object3"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object1"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object4"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object2"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object5"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object1", "object2"}), + }, + }) + + // when + err := groupObject.InitGroups( + "spaceId", + &database.Filters{FilterObj: database.FilterNot{Filter: database.FilterEq{ + Key: bundle.RelationKeyId.String(), + Cond: model.BlockContentDataviewFilter_Equal, + Value: pbtypes.String("object5"), + }}}, + ) + assert.Nil(t, err) + + groups, err := groupObject.MakeDataViewGroups() + assert.Nil(t, err) + + // then + assert.Len(t, groups, 3) // empty, object1, object2 + }) + t.Run("relation without type", func(t *testing.T) { + // given + storeFixture := objectstore.NewStoreFixture(t) + groupObject := GroupObject{ + key: "test", + store: storeFixture, + } + storeFixture.AddObjects(t, []objectstore.TestObject{ + { + bundle.RelationKeyId: pbtypes.String("test"), + bundle.RelationKeyRelationKey: pbtypes.String("test"), + bundle.RelationKeyLayout: pbtypes.Int64(int64(model.ObjectType_relation)), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object1"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId"), + }, + { + bundle.RelationKeyId: pbtypes.String("object2"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId2"), + }, + { + bundle.RelationKeyId: pbtypes.String("object3"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + bundle.RelationKeyType: pbtypes.String("typeId3"), + }, + { + bundle.RelationKeyId: pbtypes.String("object3"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object1"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object4"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object2"}), + }, + { + bundle.RelationKeyId: pbtypes.String("object5"), + bundle.RelationKeySpaceId: pbtypes.String("spaceId"), + "test": pbtypes.StringList([]string{"object1", "object2"}), + }, + }) + + // when + err := groupObject.InitGroups("spaceId", nil) + assert.Nil(t, err) + + groups, err := groupObject.MakeDataViewGroups() + assert.Nil(t, err) + + // then + assert.Len(t, groups, 7) // empty, object1, object2, object1 object 2 + }) +} diff --git a/core/kanban/service.go b/core/kanban/service.go index e46845914..fa6527f07 100644 --- a/core/kanban/service.go +++ b/core/kanban/service.go @@ -25,7 +25,6 @@ type Service interface { type Grouper interface { InitGroups(spaceID string, f *database.Filters) error - MakeGroups() (GroupSlice, error) MakeDataViewGroups() ([]*model.BlockContentDataviewGroup, error) } @@ -50,6 +49,9 @@ func (s *service) Init(a *app.App) (err error) { s.groupColumns[model.RelationFormat_checkbox] = func(key string) Grouper { return &GroupCheckBox{} } + s.groupColumns[model.RelationFormat_object] = func(key string) Grouper { + return &GroupObject{key: key, store: s.objectStore} + } return nil }