mirror of
https://github.com/anyproto/anytype-heart.git
synced 2025-06-09 17:44:59 +09:00
WIP block service and object creation
This commit is contained in:
parent
8e229ee968
commit
33fdf3eb4c
9 changed files with 255 additions and 239 deletions
|
@ -643,7 +643,7 @@ func (s *Service) ModifyLocalDetails(
|
|||
// we set pending details if object is not in cache
|
||||
// we do this under lock to prevent races if the object is created in parallel
|
||||
// because in that case we can lose changes
|
||||
err = s.cache.DoLockedIfNotExists(objectId, func() error {
|
||||
err = s.cache.ObjectCache().DoLockedIfNotExists(objectId, func() error {
|
||||
objectDetails, err := s.objectStore.GetPendingLocalDetails(objectId)
|
||||
if err != nil && err != ds.ErrNotFound {
|
||||
return err
|
||||
|
|
|
@ -231,9 +231,11 @@ func (sb *smartBlock) Init(ctx *InitContext) (err error) {
|
|||
}
|
||||
|
||||
sb.source = ctx.Source
|
||||
// TODO: [MR] rewrite this so it would be obvious
|
||||
// we are doing this because we expecting cache to have objectTrees inside for smartblocks
|
||||
sb.ObjectTree = sb.source.(objecttree.ObjectTree)
|
||||
if _, ok := sb.source.(objecttree.ObjectTree); ok {
|
||||
// TODO: [MR] rewrite this so it would be obvious
|
||||
// we are doing this because we expecting cache to have objectTrees inside for smartblocks
|
||||
sb.ObjectTree = sb.source.(objecttree.ObjectTree)
|
||||
}
|
||||
sb.undo = undo.NewHistory(0)
|
||||
sb.restrictions = ctx.App.MustComponent(restriction.CName).(restriction.Service).RestrictionsByObj(sb)
|
||||
sb.relationService = ctx.App.MustComponent(relation2.CName).(relation2.Service)
|
||||
|
|
|
@ -3,6 +3,7 @@ package object
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/anytypeio/go-anytype-middleware/space/clientcache"
|
||||
"time"
|
||||
|
||||
"github.com/gogo/protobuf/types"
|
||||
|
@ -21,7 +22,6 @@ import (
|
|||
"github.com/anytypeio/go-anytype-middleware/pkg/lib/bundle"
|
||||
"github.com/anytypeio/go-anytype-middleware/pkg/lib/core"
|
||||
coresb "github.com/anytypeio/go-anytype-middleware/pkg/lib/core/smartblock"
|
||||
"github.com/anytypeio/go-anytype-middleware/pkg/lib/localstore/addr"
|
||||
"github.com/anytypeio/go-anytype-middleware/pkg/lib/localstore/objectstore"
|
||||
"github.com/anytypeio/go-anytype-middleware/pkg/lib/logging"
|
||||
"github.com/anytypeio/go-anytype-middleware/pkg/lib/pb/model"
|
||||
|
@ -74,9 +74,10 @@ func (c *Creator) Name() (name string) {
|
|||
// TODO Temporarily
|
||||
type BlockService interface {
|
||||
StateFromTemplate(templateID, name string) (st *state.State, err error)
|
||||
Cache() clientcache.Cache
|
||||
}
|
||||
|
||||
func (c *Creator) CreateSmartBlockFromTemplate(ctx context.Context, sbType coresb.SmartBlockType, details *types.Struct, templateID string) (id string, newDetails *types.Struct, err error) {
|
||||
func (c *Creator) CreateSmartBlockFromTemplate(ctx context.Context, sbType coresb.SmartBlockType, details *types.Struct, relationIds []string, templateID string) (id string, newDetails *types.Struct, err error) {
|
||||
var createState *state.State
|
||||
if templateID != "" {
|
||||
if createState, err = c.blockService.StateFromTemplate(templateID, pbtypes.GetString(details, bundle.RelationKeyName.String())); err != nil {
|
||||
|
@ -85,12 +86,10 @@ func (c *Creator) CreateSmartBlockFromTemplate(ctx context.Context, sbType cores
|
|||
} else {
|
||||
createState = state.NewDoc("", nil).NewState()
|
||||
}
|
||||
return c.CreateSmartBlockFromState(ctx, sbType, details, createState)
|
||||
return c.CreateSmartBlockFromState(ctx, sbType, details, relationIds, createState)
|
||||
}
|
||||
|
||||
// CreateSmartBlockFromState create new object from the provided `createState` and `details`. If you pass `details` into the function, it will automatically add missing relationLinks and override the details from the `createState`
|
||||
// It will return error if some of the relation keys in `details` not installed in the workspace.
|
||||
func (c *Creator) CreateSmartBlockFromState(ctx context.Context, sbType coresb.SmartBlockType, details *types.Struct, createState *state.State) (id string, newDetails *types.Struct, err error) {
|
||||
func (c *Creator) CreateSmartBlockFromState(ctx context.Context, sbType coresb.SmartBlockType, details *types.Struct, relationIds []string, createState *state.State) (id string, newDetails *types.Struct, err error) {
|
||||
if createState == nil {
|
||||
createState = state.NewDoc("", nil).(*state.State)
|
||||
}
|
||||
|
@ -110,18 +109,11 @@ func (c *Creator) CreateSmartBlockFromState(ctx context.Context, sbType coresb.S
|
|||
}
|
||||
}
|
||||
|
||||
var relationKeys []string
|
||||
var workspaceID string
|
||||
if details != nil && details.Fields != nil {
|
||||
for k, v := range details.Fields {
|
||||
relId := addr.RelationKeyToIdPrefix + k
|
||||
if _, err2 := c.objectStore.GetRelationById(relId); err != nil {
|
||||
// check if installed
|
||||
err = fmt.Errorf("failed to get installed relation %s: %w", relId, err2)
|
||||
return
|
||||
}
|
||||
relationKeys = append(relationKeys, k)
|
||||
createState.SetDetail(k, v)
|
||||
// TODO: add relations to relationIds
|
||||
}
|
||||
|
||||
detailsWorkspaceID := details.Fields[bundle.RelationKeyWorkspaceId.String()]
|
||||
|
@ -132,6 +124,7 @@ func (c *Creator) CreateSmartBlockFromState(ctx context.Context, sbType coresb.S
|
|||
|
||||
// if we don't have anything in details then check the object store
|
||||
if workspaceID == "" {
|
||||
// TODO: [MR] think about predefined ids, how should we create them in current circumstances
|
||||
workspaceID = c.anytype.PredefinedBlocks().Account
|
||||
}
|
||||
|
||||
|
@ -141,48 +134,44 @@ func (c *Creator) CreateSmartBlockFromState(ctx context.Context, sbType coresb.S
|
|||
createState.SetDetailAndBundledRelation(bundle.RelationKeyCreatedDate, pbtypes.Int64(time.Now().Unix()))
|
||||
createState.SetDetailAndBundledRelation(bundle.RelationKeyCreator, pbtypes.String(c.anytype.ProfileID()))
|
||||
|
||||
var tid = thread.Undef
|
||||
id = pbtypes.GetString(createState.CombinedDetails(), bundle.RelationKeyId.String())
|
||||
sbt, _ := coresb.SmartBlockTypeFromID(id)
|
||||
if sbt == coresb.SmartBlockTypeSubObject {
|
||||
return c.CreateSubObjectInWorkspace(createState.CombinedDetails(), workspaceID)
|
||||
} else if id != "" {
|
||||
tid, err = thread.Decode(id)
|
||||
if err != nil {
|
||||
log.Errorf("failed to decode thread id from the state: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
ev := &metrics.CreateObjectEvent{
|
||||
SetDetailsMs: time.Since(startTime).Milliseconds(),
|
||||
}
|
||||
ctx = context.WithValue(ctx, eventCreate, ev)
|
||||
// TODO: [MR] what should we do here with our current object creation?
|
||||
// in which situation we call this condition
|
||||
//if raw := pbtypes.GetString(createState.CombinedDetails(), bundle.RelationKeyId.String()); raw != "" {
|
||||
// tid, err = thread.Decode(raw)
|
||||
// if err != nil {
|
||||
// log.Errorf("failed to decode thread id from the state: %s", err.Error())
|
||||
// }
|
||||
//}
|
||||
//csm, err := c.CreateObjectInWorkspace(ctx, workspaceID, tid, sbType)
|
||||
//if err != nil {
|
||||
// err = fmt.Errorf("anytype.CreateBlock error: %v", err)
|
||||
// return
|
||||
//}
|
||||
cache := c.blockService.Cache()
|
||||
sb, release, err := cache.CreateTreeObject(sbType, func(id string) *smartblock.InitContext {
|
||||
createState.SetRootId(id)
|
||||
createState.SetObjectTypes(objectTypes)
|
||||
createState.InjectDerivedDetails()
|
||||
|
||||
csm, err := c.CreateObjectInWorkspace(ctx, workspaceID, tid, sbType)
|
||||
return &smartblock.InitContext{
|
||||
ObjectTypeUrls: objectTypes,
|
||||
State: createState,
|
||||
RelationIds: relationIds,
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("anytype.CreateBlock error: %v", err)
|
||||
return
|
||||
}
|
||||
id = csm.ID()
|
||||
createState.SetRootId(id)
|
||||
createState.SetObjectTypes(objectTypes)
|
||||
createState.InjectDerivedDetails()
|
||||
|
||||
initCtx := &smartblock.InitContext{
|
||||
ObjectTypeUrls: objectTypes,
|
||||
State: createState,
|
||||
RelationKeys: relationKeys,
|
||||
}
|
||||
var sb smartblock.SmartBlock
|
||||
|
||||
if sb, err = c.objectFactory.InitObject(id, initCtx); err != nil {
|
||||
return id, nil, err
|
||||
}
|
||||
defer release()
|
||||
ev.SmartblockCreateMs = time.Since(startTime).Milliseconds() - ev.SetDetailsMs - ev.WorkspaceCreateMs - ev.GetWorkspaceBlockWaitMs
|
||||
ev.SmartblockType = int(sbType)
|
||||
ev.ObjectId = id
|
||||
metrics.SharedClient.RecordEvent(*ev)
|
||||
return id, sb.CombinedDetails(), sb.Close()
|
||||
return id, sb.CombinedDetails(), nil
|
||||
}
|
||||
|
||||
// todo: rewrite with options
|
||||
|
@ -231,29 +220,26 @@ func (c *Creator) CreateSet(req *pb.RpcObjectCreateSetRequest) (setID string, ne
|
|||
|
||||
var dvContent model.BlockContentOfDataview
|
||||
var dvSchema schema.Schema
|
||||
|
||||
// TODO remove it, when schema will be refactored
|
||||
source := req.Source
|
||||
if len(req.Source) > 0 {
|
||||
req.Details.Fields[bundle.RelationKeySetOf.String()] = pbtypes.StringList(req.Source)
|
||||
}
|
||||
if len(source) == 0 {
|
||||
source = []string{bundle.TypeKeyPage.URL()}
|
||||
}
|
||||
if dvContent, dvSchema, err = dataview.DataviewBlockBySource(c.objectStore, source); err != nil {
|
||||
return
|
||||
if len(req.Source) != 0 {
|
||||
if dvContent, dvSchema, err = dataview.DataviewBlockBySource(c.objectStore, req.Source); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newState := state.NewDoc("", nil).NewState()
|
||||
|
||||
name := pbtypes.GetString(req.Details, bundle.RelationKeyName.String())
|
||||
icon := pbtypes.GetString(req.Details, bundle.RelationKeyIconEmoji.String())
|
||||
|
||||
tmpls := []template.StateTransformer{
|
||||
template.WithForcedDetail(bundle.RelationKeyName, pbtypes.String(name)),
|
||||
template.WithForcedDetail(bundle.RelationKeyIconEmoji, pbtypes.String(icon)),
|
||||
template.WithRequiredRelations(),
|
||||
}
|
||||
var blockContent *model.BlockContentOfDataview
|
||||
if dvSchema != nil {
|
||||
blockContent = &dvContent
|
||||
}
|
||||
|
||||
if blockContent != nil {
|
||||
for i, view := range blockContent.Dataview.Views {
|
||||
if view.Relations == nil {
|
||||
|
@ -261,6 +247,7 @@ func (c *Creator) CreateSet(req *pb.RpcObjectCreateSetRequest) (setID string, ne
|
|||
}
|
||||
}
|
||||
tmpls = append(tmpls,
|
||||
template.WithForcedDetail(bundle.RelationKeySetOf, pbtypes.StringList(blockContent.Dataview.Source)),
|
||||
template.WithDataview(*blockContent, false),
|
||||
)
|
||||
}
|
||||
|
@ -270,7 +257,7 @@ func (c *Creator) CreateSet(req *pb.RpcObjectCreateSetRequest) (setID string, ne
|
|||
}
|
||||
|
||||
// TODO: here can be a deadlock if this is somehow created from workspace (as set)
|
||||
return c.CreateSmartBlockFromState(context.TODO(), coresb.SmartBlockTypeSet, req.Details, newState)
|
||||
return c.CreateSmartBlockFromState(context.TODO(), coresb.SmartBlockTypeSet, nil, nil, newState)
|
||||
}
|
||||
|
||||
// TODO: it must be in another component
|
||||
|
@ -299,19 +286,11 @@ func (c *Creator) CreateSubObjectsInWorkspace(details []*types.Struct) (ids []st
|
|||
|
||||
// ObjectCreateBookmark creates a new Bookmark object for provided URL or returns id of existing one
|
||||
func (c *Creator) ObjectCreateBookmark(req *pb.RpcObjectCreateBookmarkRequest) (objectID string, newDetails *types.Struct, err error) {
|
||||
source := pbtypes.GetString(req.Details, bundle.RelationKeySource.String())
|
||||
var res bookmark.ContentFuture
|
||||
if source != "" {
|
||||
u, err := uri.NormalizeURI(source)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("process uri: %w", err)
|
||||
}
|
||||
res = c.bookmark.FetchBookmarkContent(u)
|
||||
} else {
|
||||
res = func() *model.BlockContentBookmark {
|
||||
return nil
|
||||
}
|
||||
u, err := uri.ProcessURI(pbtypes.GetString(req.Details, bundle.RelationKeySource.String()))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("process uri: %w", err)
|
||||
}
|
||||
res := c.bookmark.FetchBookmarkContent(u)
|
||||
return c.bookmark.CreateBookmarkObject(req.Details, res)
|
||||
}
|
||||
|
||||
|
@ -327,48 +306,46 @@ func (c *Creator) CreateObject(req block.DetailsGetter, forcedType bundle.TypeKe
|
|||
details = internalflag.PutToDetails(details, internalFlags)
|
||||
}
|
||||
|
||||
var templateID string
|
||||
if v, ok := req.(block.TemplateIDGetter); ok {
|
||||
templateID = v.GetTemplateId()
|
||||
objectType, err := bundle.TypeKeyFromUrl(pbtypes.GetString(details, bundle.RelationKeyType.String()))
|
||||
if err != nil && forcedType == "" {
|
||||
return "", nil, fmt.Errorf("invalid type in details: %w", err)
|
||||
}
|
||||
|
||||
var objectType string
|
||||
if forcedType != "" {
|
||||
objectType = forcedType.URL()
|
||||
} else if objectType = pbtypes.GetString(details, bundle.RelationKeyType.String()); objectType == "" {
|
||||
return "", nil, fmt.Errorf("missing type in details or in forcedType")
|
||||
objectType = forcedType
|
||||
details.Fields[bundle.RelationKeyType.String()] = pbtypes.String(objectType.URL())
|
||||
}
|
||||
|
||||
details.Fields[bundle.RelationKeyType.String()] = pbtypes.String(objectType)
|
||||
var sbType = coresb.SmartBlockTypePage
|
||||
|
||||
switch objectType {
|
||||
case bundle.TypeKeyBookmark.URL():
|
||||
case bundle.TypeKeyBookmark:
|
||||
return c.ObjectCreateBookmark(&pb.RpcObjectCreateBookmarkRequest{
|
||||
Details: details,
|
||||
})
|
||||
case bundle.TypeKeySet.URL():
|
||||
details.Fields[bundle.RelationKeyLayout.String()] = pbtypes.Float64(float64(model.ObjectType_set))
|
||||
case bundle.TypeKeySet:
|
||||
return c.CreateSet(&pb.RpcObjectCreateSetRequest{
|
||||
Details: details,
|
||||
InternalFlags: internalFlags,
|
||||
Source: pbtypes.GetStringList(details, bundle.RelationKeySetOf.String()),
|
||||
})
|
||||
case bundle.TypeKeyObjectType.URL():
|
||||
case bundle.TypeKeyObjectType:
|
||||
details.Fields[bundle.RelationKeyLayout.String()] = pbtypes.Float64(float64(model.ObjectType_objectType))
|
||||
return c.CreateSubObjectInWorkspace(details, c.anytype.PredefinedBlocks().Account)
|
||||
|
||||
case bundle.TypeKeyRelation.URL():
|
||||
case bundle.TypeKeyRelation:
|
||||
details.Fields[bundle.RelationKeyLayout.String()] = pbtypes.Float64(float64(model.ObjectType_relation))
|
||||
return c.CreateSubObjectInWorkspace(details, c.anytype.PredefinedBlocks().Account)
|
||||
|
||||
case bundle.TypeKeyRelationOption.URL():
|
||||
case bundle.TypeKeyRelationOption:
|
||||
details.Fields[bundle.RelationKeyLayout.String()] = pbtypes.Float64(float64(model.ObjectType_relationOption))
|
||||
return c.CreateSubObjectInWorkspace(details, c.anytype.PredefinedBlocks().Account)
|
||||
|
||||
case bundle.TypeKeyTemplate.URL():
|
||||
case bundle.TypeKeyTemplate:
|
||||
sbType = coresb.SmartBlockTypeTemplate
|
||||
}
|
||||
|
||||
return c.CreateSmartBlockFromTemplate(context.TODO(), sbType, details, templateID)
|
||||
var templateID string
|
||||
if v, ok := req.(block.TemplateIDGetter); ok {
|
||||
templateID = v.GetTemplateId()
|
||||
}
|
||||
return c.CreateSmartBlockFromTemplate(context.TODO(), sbType, details, nil, templateID)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import (
|
|||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/commonspace/object/treegetter"
|
||||
"github.com/anytypeio/go-anytype-middleware/space/clientcache"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -13,7 +15,6 @@ import (
|
|||
"github.com/gogo/protobuf/types"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/ipfs/go-datastore/query"
|
||||
"github.com/textileio/go-threads/core/thread"
|
||||
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/app"
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/app/ocache"
|
||||
|
@ -124,11 +125,11 @@ type Service struct {
|
|||
doc doc.Service
|
||||
app *app.App
|
||||
source source.Service
|
||||
cache ocache.OCache
|
||||
objectStore objectstore.ObjectStore
|
||||
restriction restriction.Service
|
||||
bookmark bookmarksvc.Service
|
||||
relationService relation.Service
|
||||
cache clientcache.Cache
|
||||
|
||||
objectCreator objectCreator
|
||||
objectFactory *editor.ObjectFactory
|
||||
|
@ -152,8 +153,8 @@ func (s *Service) Init(a *app.App) (err error) {
|
|||
s.relationService = a.MustComponent(relation.CName).(relation.Service)
|
||||
s.objectCreator = a.MustComponent("objectCreator").(objectCreator)
|
||||
s.objectFactory = app.MustComponent[*editor.ObjectFactory](a)
|
||||
s.cache = a.MustComponent(treegetter.CName).(clientcache.Cache)
|
||||
s.app = a
|
||||
s.cache = ocache.New(s.loadSmartblock)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -224,7 +225,7 @@ func (s *Service) OpenBlock(
|
|||
ctx *session.Context, id string, includeRelationsAsDependentObjects bool,
|
||||
) (obj *model.ObjectView, err error) {
|
||||
startTime := time.Now()
|
||||
ob, err := s.getSmartblock(context.WithValue(context.TODO(), metrics.CtxKeyRequest, "object_open"), id)
|
||||
ob, release, err := s.getSmartblock(context.WithValue(context.TODO(), metrics.CtxKeyRequest, "object_open"), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -232,11 +233,11 @@ func (s *Service) OpenBlock(
|
|||
ob.EnabledRelationAsDependentObjects()
|
||||
}
|
||||
afterSmartBlockTime := time.Now()
|
||||
defer s.cache.Release(id)
|
||||
defer release()
|
||||
ob.Lock()
|
||||
defer ob.Unlock()
|
||||
ob.SetEventFunc(s.sendEvent)
|
||||
if v, hasOpenListner := ob.SmartBlock.(smartblock.SmartObjectOpenListner); hasOpenListner {
|
||||
if v, hasOpenListner := ob.(smartblock.SmartObjectOpenListner); hasOpenListner {
|
||||
v.SmartObjectOpened(ctx)
|
||||
}
|
||||
afterDataviewTime := time.Now()
|
||||
|
@ -251,23 +252,24 @@ func (s *Service) OpenBlock(
|
|||
return
|
||||
}
|
||||
afterShowTime := time.Now()
|
||||
if tid := ob.threadId; tid != thread.Undef && s.status != nil {
|
||||
var (
|
||||
fList = func() []string {
|
||||
ob.Lock()
|
||||
defer ob.Unlock()
|
||||
bs := ob.NewState()
|
||||
return bs.GetAllFileHashes(ob.FileRelationKeys(bs))
|
||||
}
|
||||
)
|
||||
|
||||
if newWatcher := s.status.Watch(tid, fList); newWatcher {
|
||||
ob.AddHook(func(_ smartblock.ApplyInfo) error {
|
||||
s.status.Unwatch(tid)
|
||||
return nil
|
||||
}, smartblock.HookOnClose)
|
||||
}
|
||||
}
|
||||
// TODO: [MR] add status logic somewhere
|
||||
//if tid := ob.threadId; tid != thread.Undef && s.status != nil {
|
||||
// var (
|
||||
// fList = func() []string {
|
||||
// ob.Lock()
|
||||
// defer ob.Unlock()
|
||||
// bs := ob.NewState()
|
||||
// return bs.GetAllFileHashes(ob.FileRelationKeys(bs))
|
||||
// }
|
||||
// )
|
||||
//
|
||||
// if newWatcher := s.status.Watch(tid, fList); newWatcher {
|
||||
// ob.AddHook(func(_ smartblock.ApplyInfo) error {
|
||||
// s.status.Unwatch(tid)
|
||||
// return nil
|
||||
// }, smartblock.HookOnClose)
|
||||
// }
|
||||
//}
|
||||
afterHashesTime := time.Now()
|
||||
tp, _ := coresb.SmartBlockTypeFromID(id)
|
||||
metrics.SharedClient.RecordEvent(metrics.OpenBlockEvent{
|
||||
|
@ -307,17 +309,14 @@ func (s *Service) OpenBreadcrumbsBlock(ctx *session.Context) (obj *model.ObjectV
|
|||
}); err != nil {
|
||||
return
|
||||
}
|
||||
bs.Lock()
|
||||
defer bs.Unlock()
|
||||
bs.SetEventFunc(s.sendEvent)
|
||||
ob := newOpenedBlock(bs)
|
||||
s.cache.Add(bs.Id(), ob)
|
||||
|
||||
// workaround to increase ref counter
|
||||
if _, err = s.cache.Get(context.Background(), bs.Id()); err != nil {
|
||||
_, _, err = s.cache.PutObject(bs.Id(), bs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bs.Lock()
|
||||
defer bs.Unlock()
|
||||
bs.SetEventFunc(s.sendEvent)
|
||||
obj, err = bs.Show(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -326,15 +325,12 @@ func (s *Service) OpenBreadcrumbsBlock(ctx *session.Context) (obj *model.ObjectV
|
|||
}
|
||||
|
||||
func (s *Service) CloseBlock(id string) error {
|
||||
var (
|
||||
isDraft bool
|
||||
workspaceId string
|
||||
)
|
||||
var isDraft bool
|
||||
err := s.Do(id, func(b smartblock.SmartBlock) error {
|
||||
b.ObjectClose()
|
||||
s := b.NewState()
|
||||
isDraft = internalflag.NewFromState(s).Has(model.InternalFlag_editorDeleteEmpty)
|
||||
workspaceId = pbtypes.GetString(s.LocalDetails(), bundle.RelationKeyWorkspaceId.String())
|
||||
//workspaceId = pbtypes.GetString(s.LocalDetails(), bundle.RelationKeyWorkspaceId.String())
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -342,23 +338,30 @@ func (s *Service) CloseBlock(id string) error {
|
|||
}
|
||||
|
||||
if isDraft {
|
||||
_, _ = s.cache.Remove(id)
|
||||
if err = s.DeleteObjectFromWorkspace(workspaceId, id); err != nil {
|
||||
if err = s.cache.DeleteObject(id); err != nil {
|
||||
log.Errorf("error while block delete: %v", err)
|
||||
} else {
|
||||
s.sendOnRemoveEvent(id)
|
||||
}
|
||||
//
|
||||
//_, _ = s.cache.Remove(id)
|
||||
//if err = s.DeleteObjectFromWorkspace(workspaceId, id); err != nil {
|
||||
// log.Errorf("error while block delete: %v", err)
|
||||
//} else {
|
||||
// s.sendOnRemoveEvent(id)
|
||||
//}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CloseBlocks() {
|
||||
s.cache.ForEach(func(v ocache.Object) (isContinue bool) {
|
||||
ob := v.(*openedBlock)
|
||||
// TODO: [MR] provide maybe new logic in clientcache to call ObjectClose()
|
||||
s.cache.ObjectCache().ForEach(func(v ocache.Object) (isContinue bool) {
|
||||
ob := v.(smartblock.SmartBlock)
|
||||
ob.Lock()
|
||||
ob.ObjectClose()
|
||||
ob.Unlock()
|
||||
s.cache.Reset(ob.Id())
|
||||
s.cache.ObjectCache().Reset(ob.Id())
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
@ -718,38 +721,43 @@ func (s *Service) AddCreatorInfoIfNeeded(workspaceId string) error {
|
|||
}
|
||||
|
||||
func (s *Service) DeleteObject(id string) (err error) {
|
||||
var (
|
||||
fileHashes []string
|
||||
workspaceId string
|
||||
isFavorite bool
|
||||
)
|
||||
// TODO: [MR] Fix deletion logic
|
||||
|
||||
err = s.Do(id, func(b smartblock.SmartBlock) error {
|
||||
if err = b.Restrictions().Object.Check(model.Restrictions_Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
b.ObjectClose()
|
||||
st := b.NewState()
|
||||
fileHashes = st.GetAllFileHashes(b.FileRelationKeys(st))
|
||||
workspaceId, err = s.anytype.GetWorkspaceIdForObject(id)
|
||||
if workspaceId == "" {
|
||||
workspaceId = s.anytype.PredefinedBlocks().Account
|
||||
}
|
||||
isFavorite = pbtypes.GetBool(st.LocalDetails(), bundle.RelationKeyIsFavorite.String())
|
||||
if isFavorite {
|
||||
_ = s.SetPageIsFavorite(pb.RpcObjectSetIsFavoriteRequest{IsFavorite: false, ContextId: id})
|
||||
}
|
||||
if err = s.DeleteObjectFromWorkspace(workspaceId, id); err != nil {
|
||||
return err
|
||||
}
|
||||
b.SetIsDeleted()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return s.cache.DeleteObject(id)
|
||||
}
|
||||
|
||||
if err != nil && err != ErrBlockNotFound {
|
||||
func (s *Service) OnDelete(b smartblock.SmartBlock) (err error) {
|
||||
var (
|
||||
fileHashes []string
|
||||
workspaceId string
|
||||
isFavorite bool
|
||||
id = b.Id()
|
||||
)
|
||||
|
||||
b.ObjectClose()
|
||||
st := b.NewState()
|
||||
fileHashes = st.GetAllFileHashes(b.FileRelationKeys(st))
|
||||
workspaceId, err = s.anytype.GetWorkspaceIdForObject(id)
|
||||
if workspaceId == "" {
|
||||
workspaceId = s.anytype.PredefinedBlocks().Account
|
||||
}
|
||||
isFavorite = pbtypes.GetBool(st.LocalDetails(), bundle.RelationKeyIsFavorite.String())
|
||||
if isFavorite {
|
||||
_ = s.SetPageIsFavorite(pb.RpcObjectSetIsFavoriteRequest{IsFavorite: false, ContextId: id})
|
||||
}
|
||||
if err = s.DeleteObjectFromWorkspace(workspaceId, id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.cache.Remove(id)
|
||||
b.SetIsDeleted()
|
||||
|
||||
for _, fileHash := range fileHashes {
|
||||
inboundLinks, err := s.Anytype().ObjectStore().GetOutboundLinksById(fileHash)
|
||||
|
@ -856,33 +864,17 @@ func (s *Service) ProcessCancel(id string) (err error) {
|
|||
}
|
||||
|
||||
func (s *Service) Close(ctx context.Context) (err error) {
|
||||
return s.cache.Close()
|
||||
//return s.cache.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// PickBlock returns opened smartBlock or opens smartBlock in silent mode
|
||||
func (s *Service) PickBlock(ctx context.Context, id string) (sb smartblock.SmartBlock, release func(), err error) {
|
||||
ob, err := s.getSmartblock(ctx, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return ob.SmartBlock, func() {
|
||||
s.cache.Release(id)
|
||||
}, nil
|
||||
return s.getSmartblock(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) getSmartblock(ctx context.Context, id string) (ob *openedBlock, err error) {
|
||||
val, err := s.cache.Get(ctx, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
ob, ok = val.(*openedBlock)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("got unexpected object from cache: %t", val)
|
||||
} else if ob == nil {
|
||||
return nil, fmt.Errorf("got nil object from cache")
|
||||
}
|
||||
return ob, nil
|
||||
func (s *Service) getSmartblock(ctx context.Context, id string) (sb smartblock.SmartBlock, release func(), err error) {
|
||||
return s.cache.GetObject(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) StateFromTemplate(templateID string, name string) (st *state.State, err error) {
|
||||
|
@ -1223,38 +1215,38 @@ func (s *Service) ObjectToBookmark(id string, url string) (objectId string, err
|
|||
}
|
||||
|
||||
func (s *Service) loadSmartblock(ctx context.Context, id string) (value ocache.Object, err error) {
|
||||
sbt, _ := coresb.SmartBlockTypeFromID(id)
|
||||
if sbt == coresb.SmartBlockTypeSubObject {
|
||||
workspaceId := s.anytype.PredefinedBlocks().Account
|
||||
if value, err = s.cache.Get(ctx, workspaceId); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
var ob *openedBlock
|
||||
if ob, ok = value.(*openedBlock); !ok {
|
||||
return nil, fmt.Errorf("invalid id path '%s': '%s' not implement openedBlock", id, workspaceId)
|
||||
}
|
||||
|
||||
var sbOpener SmartblockOpener
|
||||
if sbOpener, ok = ob.SmartBlock.(SmartblockOpener); !ok {
|
||||
return nil, fmt.Errorf("invalid id path '%s': '%s' not implement SmartblockOpener", id, workspaceId)
|
||||
}
|
||||
|
||||
var sb smartblock.SmartBlock
|
||||
if sb, err = sbOpener.Open(id); err != nil {
|
||||
return
|
||||
}
|
||||
return newOpenedBlock(sb), nil
|
||||
}
|
||||
|
||||
sb, err := s.objectFactory.InitObject(id, &smartblock.InitContext{
|
||||
Ctx: ctx,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
value = newOpenedBlock(sb)
|
||||
//sbt, _ := coresb.SmartBlockTypeFromID(id)
|
||||
//if sbt == coresb.SmartBlockTypeSubObject {
|
||||
// workspaceId := s.anytype.PredefinedBlocks().Account
|
||||
// if value, err = s.cache.Get(ctx, workspaceId); err != nil {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// var ok bool
|
||||
// var ob *openedBlock
|
||||
// if ob, ok = value.(*openedBlock); !ok {
|
||||
// return nil, fmt.Errorf("invalid id path '%s': '%s' not implement openedBlock", id, workspaceId)
|
||||
// }
|
||||
//
|
||||
// var sbOpener SmartblockOpener
|
||||
// if sbOpener, ok = ob.SmartBlock.(SmartblockOpener); !ok {
|
||||
// return nil, fmt.Errorf("invalid id path '%s': '%s' not implement SmartblockOpener", id, workspaceId)
|
||||
// }
|
||||
//
|
||||
// var sb smartblock.SmartBlock
|
||||
// if sb, err = sbOpener.Open(id); err != nil {
|
||||
// return
|
||||
// }
|
||||
// return newOpenedBlock(sb), nil
|
||||
//}
|
||||
//
|
||||
//sb, err := s.objectFactory.InitObject(id, &smartblock.InitContext{
|
||||
// Ctx: ctx,
|
||||
//})
|
||||
//if err != nil {
|
||||
// return
|
||||
//}
|
||||
//value = newOpenedBlock(sb)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ func (mw *Middleware) doAccountService(f func(a account.Service) error) (err err
|
|||
// Stop stops the anytype node and HTTP gateway
|
||||
func (mw *Middleware) stop() error {
|
||||
if mw != nil && mw.app != nil {
|
||||
err := mw.app.Close()
|
||||
err := mw.app.Close(context.Background())
|
||||
if err != nil {
|
||||
log.Warnf("error while stop anytype: %v", err)
|
||||
}
|
||||
|
|
2
go.mod
2
go.mod
|
@ -102,7 +102,7 @@ require (
|
|||
github.com/alecthomas/jsonschema v0.0.0-20191017121752-4bb6e3fae4f2 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20210927113745-59d0afb8317a // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230104133926-9b59e6029ddc // indirect
|
||||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230104140756-02e92a0db0e4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/benbjohnson/clock v1.3.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -90,6 +90,8 @@ github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-2023010
|
|||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230103175543-ac84febce98d/go.mod h1:2vafONFWOYl9XTv9vW5KzOmuqdxhC6PAlRfkT7TseZA=
|
||||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230104133926-9b59e6029ddc h1:G5ojRhFsJ9Wi06yR2TNDa3K0m1MhPvsBlJKrpWbsl40=
|
||||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230104133926-9b59e6029ddc/go.mod h1:2vafONFWOYl9XTv9vW5KzOmuqdxhC6PAlRfkT7TseZA=
|
||||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230104140756-02e92a0db0e4 h1:AiohMyvPL8jlMTpHqwkbHOIXftsP1aA8Oe5Z46wCe1M=
|
||||
github.com/anytypeio/go-anytype-infrastructure-experiments/common v0.0.0-20230104140756-02e92a0db0e4/go.mod h1:2vafONFWOYl9XTv9vW5KzOmuqdxhC6PAlRfkT7TseZA=
|
||||
github.com/anytypeio/go-ds-badger3 v0.3.1-0.20221103102622-3233d4e13cb8 h1:LC9w0M0SbA5VuZeBtUdq+uR4mdjbJhxurNtovmRiOrU=
|
||||
github.com/anytypeio/go-ds-badger3 v0.3.1-0.20221103102622-3233d4e13cb8/go.mod h1:R5tqrpzflXnpE1v91BNZrz62AVox66+rWM51LXNoNik=
|
||||
github.com/anytypeio/go-gelf v0.0.0-20210418191311-774bd5b016e7 h1:YBmcug6mOdwjJcmzA/Fpjtl2oo78+fMlr5Xj0dzwg78=
|
||||
|
|
|
@ -12,8 +12,8 @@ import (
|
|||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/commonspace/object/treegetter"
|
||||
"github.com/anytypeio/go-anytype-middleware/core/block/editor"
|
||||
"github.com/anytypeio/go-anytype-middleware/core/block/editor/smartblock"
|
||||
coresb "github.com/anytypeio/go-anytype-middleware/pkg/lib/core/smartblock"
|
||||
"github.com/anytypeio/go-anytype-middleware/space"
|
||||
"go.uber.org/zap"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -27,51 +27,47 @@ const (
|
|||
treeCreateKey
|
||||
)
|
||||
|
||||
type treeCache struct {
|
||||
type cache struct {
|
||||
gcttl int
|
||||
cache ocache.OCache
|
||||
account accountservice.Service
|
||||
clientService space.Service
|
||||
objectFactory *editor.ObjectFactory
|
||||
objectDeleter ObjectDeleter
|
||||
}
|
||||
|
||||
type TreeCache interface {
|
||||
type Cache interface {
|
||||
treegetter.TreeGetter
|
||||
// TODO: remove this
|
||||
treegetter.TreePutter
|
||||
GetObject(ctx context.Context, id string) (sb smartblock.SmartBlock, release func(), err error)
|
||||
CreateTreeObject(tp coresb.SmartBlockType, initFunc InitFunc) (sb smartblock.SmartBlock, release func(), err error)
|
||||
PutObject(id string, obj smartblock.SmartBlock) (sb smartblock.SmartBlock, release func(), err error)
|
||||
DeleteObject(id string) (err error)
|
||||
ObjectCache() ocache.OCache
|
||||
}
|
||||
|
||||
type updateListener struct {
|
||||
type ObjectDeleter interface {
|
||||
OnDelete(b smartblock.SmartBlock) (err error)
|
||||
}
|
||||
|
||||
func (u *updateListener) Update(tree objecttree.ObjectTree) {
|
||||
log.With(
|
||||
zap.Strings("heads", tree.Heads()),
|
||||
zap.String("tree id", tree.Id())).
|
||||
Debug("updating tree")
|
||||
}
|
||||
type InitFunc func(id string) *smartblock.InitContext
|
||||
|
||||
func (u *updateListener) Rebuild(tree objecttree.ObjectTree) {
|
||||
log.With(
|
||||
zap.Strings("heads", tree.Heads()),
|
||||
zap.String("tree id", tree.Id())).
|
||||
Debug("rebuilding tree")
|
||||
}
|
||||
|
||||
func New(ttl int) TreeCache {
|
||||
return &treeCache{
|
||||
func New(ttl int) Cache {
|
||||
return &cache{
|
||||
gcttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *treeCache) Run(ctx context.Context) (err error) {
|
||||
func (c *cache) Run(ctx context.Context) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *treeCache) Close(ctx context.Context) (err error) {
|
||||
func (c *cache) Close(ctx context.Context) (err error) {
|
||||
return c.cache.Close()
|
||||
}
|
||||
|
||||
func (c *treeCache) Init(a *app.App) (err error) {
|
||||
func (c *cache) Init(a *app.App) (err error) {
|
||||
c.clientService = a.MustComponent(space.CName).(space.Service)
|
||||
c.account = a.MustComponent(accountservice.CName).(accountservice.Service)
|
||||
c.objectFactory = app.MustComponent[*editor.ObjectFactory](a)
|
||||
|
@ -85,7 +81,7 @@ func (c *treeCache) Init(a *app.App) (err error) {
|
|||
// creating tree if needed
|
||||
createPayload, exists := ctx.Value(treeCreateKey).(treestorage.TreeStorageCreatePayload)
|
||||
if exists {
|
||||
ot, err := spc.PutTree(ctx, createPayload, &updateListener{})
|
||||
ot, err := spc.PutTree(ctx, createPayload, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -96,17 +92,30 @@ func (c *treeCache) Init(a *app.App) (err error) {
|
|||
})
|
||||
},
|
||||
ocache.WithLogger(log.Sugar()),
|
||||
ocache.WithRefCounter(true),
|
||||
ocache.WithGCPeriod(time.Minute),
|
||||
ocache.WithTTL(time.Duration(c.gcttl)*time.Second),
|
||||
)
|
||||
c.objectDeleter = app.MustComponent[ObjectDeleter](a)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *treeCache) Name() (name string) {
|
||||
func (c *cache) Name() (name string) {
|
||||
return treegetter.CName
|
||||
}
|
||||
|
||||
func (c *treeCache) GetTree(ctx context.Context, spaceId, id string) (tr objecttree.ObjectTree, err error) {
|
||||
func (c *cache) GetObject(ctx context.Context, id string) (sb smartblock.SmartBlock, release func(), err error) {
|
||||
ctx = context.WithValue(ctx, spaceKey, c.account)
|
||||
v, err := c.cache.Get(ctx, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return v.(smartblock.SmartBlock), func() {
|
||||
c.cache.Release(id)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *cache) GetTree(ctx context.Context, spaceId, id string) (tr objecttree.ObjectTree, err error) {
|
||||
ctx = context.WithValue(ctx, spaceKey, spaceId)
|
||||
v, err := c.cache.Get(ctx, id)
|
||||
if err != nil {
|
||||
|
@ -115,7 +124,7 @@ func (c *treeCache) GetTree(ctx context.Context, spaceId, id string) (tr objectt
|
|||
return v.(objecttree.ObjectTree), nil
|
||||
}
|
||||
|
||||
func (c *treeCache) PutTree(ctx context.Context, spaceId string, payload treestorage.TreeStorageCreatePayload) (ot objecttree.ObjectTree, err error) {
|
||||
func (c *cache) PutTree(ctx context.Context, spaceId string, payload treestorage.TreeStorageCreatePayload) (ot objecttree.ObjectTree, err error) {
|
||||
ctx = context.WithValue(ctx, spaceKey, spaceId)
|
||||
ctx = context.WithValue(ctx, treeCreateKey, payload)
|
||||
v, err := c.cache.Get(ctx, payload.RootRawChange.Id)
|
||||
|
@ -125,15 +134,40 @@ func (c *treeCache) PutTree(ctx context.Context, spaceId string, payload treesto
|
|||
return v.(objecttree.ObjectTree), nil
|
||||
}
|
||||
|
||||
func (c *treeCache) DeleteTree(ctx context.Context, spaceId, treeId string) (err error) {
|
||||
tr, err := c.GetTree(ctx, spaceId, treeId)
|
||||
func (c *cache) DeleteTree(ctx context.Context, spaceId, treeId string) (err error) {
|
||||
tr, _, err := c.GetObject(ctx, treeId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = tr.Delete()
|
||||
err = c.objectDeleter.OnDelete(tr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = tr.(objecttree.ObjectTree).Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = c.cache.Remove(treeId)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cache) CreateTreeObject(tp coresb.SmartBlockType, initFunc InitFunc) (sb smartblock.SmartBlock, release func(), err error) {
|
||||
// create tree payload
|
||||
// put tree payload in context
|
||||
// call get method with tree payload
|
||||
// put
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (c *cache) PutObject(id string, obj smartblock.SmartBlock) (sb smartblock.SmartBlock, release func(), err error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (c *cache) DeleteObject(id string) (err error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (c *cache) ObjectCache() ocache.OCache {
|
||||
return c.cache
|
||||
}
|
|
@ -2,6 +2,7 @@ package space
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/accountservice"
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/app"
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/app/logger"
|
||||
"github.com/anytypeio/go-anytype-infrastructure-experiments/common/app/ocache"
|
||||
|
@ -32,6 +33,7 @@ type service struct {
|
|||
conf commonspace.Config
|
||||
spaceCache ocache.OCache
|
||||
commonSpace commonspace.SpaceService
|
||||
account accountservice.Service
|
||||
spaceStorageProvider spacestorage.SpaceStorageProvider
|
||||
accountId string
|
||||
}
|
||||
|
@ -39,14 +41,21 @@ type service struct {
|
|||
func (s *service) Init(a *app.App) (err error) {
|
||||
s.conf = a.MustComponent("config").(commonspace.ConfigGetter).GetSpace()
|
||||
s.commonSpace = a.MustComponent(commonspace.CName).(commonspace.SpaceService)
|
||||
s.account = a.MustComponent(accountservice.CName).(accountservice.Service)
|
||||
s.spaceStorageProvider = a.MustComponent(spacestorage.CName).(spacestorage.SpaceStorageProvider)
|
||||
// TODO: add account id
|
||||
s.spaceCache = ocache.New(
|
||||
s.loadSpace,
|
||||
ocache.WithLogger(log.Sugar()),
|
||||
ocache.WithGCPeriod(time.Minute),
|
||||
ocache.WithTTL(time.Duration(s.conf.GCTTL)*time.Second),
|
||||
)
|
||||
s.accountId, err = s.commonSpace.DeriveSpace(context.Background(), commonspace.SpaceDerivePayload{
|
||||
SigningKey: s.account.Account().SignKey,
|
||||
EncryptionKey: s.account.Account().EncKey,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return spacesyncproto.DRPCRegisterSpaceSync(a.MustComponent(server.CName).(server.DRPCServer), &rpcHandler{s})
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue