Skip to content
Snippets Groups Projects
Select Git revision
  • ecb2f118a17116ba660ef412df3fc66eec6401cc
  • master default protected
  • feature/PRXS-3383-CollectionsRankSortAPI
  • fix/PRXS-3401-ValidateValidationOpts
  • feature/3149-LocaleCodeAsID-Feature
  • feature/PRXS-3383-CollectionsSort
  • feature/3109-SerializeFeature
  • release/0.33
  • feature/3109-RecoverySchema
  • feature/3109-feature
  • fix/PRXS-3369-ValidateFields
  • refactor/PRXS-3306-MovePkgGroup1
  • refactor/6-pkg-refactor-expr
  • fix/PRXS-3360-TemplateBuilderPatch
  • feature/3293-MongoV2
  • feature/3272-GoVersionUp
  • feature/PRXS-3218-HideTemplateActions
  • feature/PRXS-3234-PruneIdents
  • feature/3146-UpdateItemStorageInterface
  • feature/3274-ObjectIndexesFixes
  • feature/PRXS-3143-3235-ReferenceOptions
  • v0.33.1
  • v0.32.0
  • v0.31.1
  • v0.31.0
  • v0.30.0
  • v0.29.0
  • v0.28.0
  • v0.27.0-alpha.1+16
  • v0.27.0-alpha.1+15
  • v0.27.0-alpha.1+14
  • v0.27.0-alpha.1+13
  • v0.27.0-alpha.1+12
  • v0.27.0-alpha.1+11
  • v0.27.0-alpha.1+10
  • v0.27.0-alpha.1+9
  • v0.27.0-alpha.1+8
  • v0.27.0-alpha.1+7
  • v0.27.0-alpha.1+6
  • v0.27.0-alpha.1+5
  • v0.27.0-alpha.1+4
41 results

item.go

Blame
  • item.go 22.49 KiB
    package items
    
    import (
    	"context"
    	"fmt"
    	"time"
    
    	"git.perx.ru/perxis/perxis-go/pkg/data"
    	"git.perx.ru/perxis/perxis-go/pkg/errors"
    	"git.perx.ru/perxis/perxis-go/pkg/locales"
    	"git.perx.ru/perxis/perxis-go/pkg/schema"
    	"git.perx.ru/perxis/perxis-go/pkg/schema/field"
    	"git.perx.ru/perxis/perxis-go/pkg/schema/localizer"
    	"git.perx.ru/perxis/perxis-go/pkg/schema/walk"
    	pb "git.perx.ru/perxis/perxis-go/proto/items"
    	"github.com/mitchellh/mapstructure"
    	"google.golang.org/protobuf/types/known/structpb"
    	"google.golang.org/protobuf/types/known/timestamppb"
    )
    
    var (
    	ErrNotSystemField = errors.New("not a system field")
    	ErrIncorrectValue = errors.New("incorrect value")
    	ErrIncorrectField = errors.New("incorrect field")
    	ErrReservedField  = errors.New("cannot use reserved field name")
    )
    
    type State int
    
    func (s State) String() string {
    	switch s {
    	case StateDraft:
    		return "Draft"
    	case StateArchived:
    		return "Archived"
    	case StateChanged:
    		return "Changed"
    	case StatePublished:
    		return "Published"
    	}
    	return "Unknown"
    }
    
    const (
    	StateDraft State = iota
    	StatePublished
    	StateChanged
    	StateArchived
    
    	StateMax = StateArchived
    
    	SoftDeleteSeparator = "___"
    )
    
    var PermissionsAllowAny = &Permissions{
    	Edit:       true,
    	Archive:    true,
    	Publish:    true,
    	SoftDelete: true,
    	HardDelete: true,
    }
    
    // SystemFields - системные поля Item
    var SystemFields = []string{
    	"id",
    	"space_id",
    	"env_id",
    	"collection_id",
    	"state",
    	"created_rev_at",
    	"created_by",
    	"created_at",
    	"updated_at",
    	"updated_by",
    	"revision_id",
    	"revision_description",
    	"data",
    	"translations",
    	"translations_ids",
    	"locale_id",
    	"deleted",
    	"hidden",
    	"template",
    	"search_score",
    }
    
    type Permissions struct {
    	Edit       bool
    	Archive    bool
    	Publish    bool
    	SoftDelete bool
    	HardDelete bool
    }
    
    type Item struct {
    	ID           string                 `json:"id" bson:"_id"` // ID - Идентификатор записи. Автоматически генерируется системой при сохранении первой ревизии.
    	SpaceID      string                 `json:"spaceId" bson:"-"`
    	EnvID        string                 `json:"envId" bson:"-"`
    	CollectionID string                 `json:"collectionId" bson:"-"`
    	State        State                  `json:"state" bson:"state"`
    	CreatedRevAt time.Time              `json:"createdRevAt,omitempty" bson:"created_rev_at,omitempty"`
    	CreatedBy    string                 `json:"createdBy,omitempty" bson:"created_by,omitempty"`
    	CreatedAt    time.Time              `json:"createdAt,omitempty" bson:"created_at,omitempty"`
    	UpdatedAt    time.Time              `json:"updatedAt,omitempty" bson:"updated_at,omitempty"`
    	UpdatedBy    string                 `json:"updatedBy,omitempty" bson:"updated_by,omitempty"`
    	Data         map[string]interface{} `json:"data" bson:"data"`
    
    	// При создании или обновлении идентификатор локали в котором создается запись, опционально.
    	// Если указан, то создается перевод для указанного языка, поле translations игнорируется
    	LocaleID string `json:"localeId,omitempty" bson:"-"`
    
    	// Позволяет одновременно установить/получить несколько переводов и производить манипуляции с переводами
    	// Ключами является идентификатор локали, значениями - данные переводы
    	// При обновлении не происходит валидация или модификация каждого из переводов в соответствие со схемой,
    	// поэтому обновление через поле `translations` стоит выполнять с аккуратностью
    	// Для удаления переводов реализована следующая логика:
    	// - {"lang":nil|{}} - сброс перевода для языка
    	// - {"lang":map{...}} - установка перевода для языка
    	// - {"lang":map{...}, "*":nil} - установка перевода для языка, сброс остальных переводов
    	// - {"*":nil} - сброс всех переводов
    	Translations map[string]map[string]interface{} `json:"translations,omitempty" bson:"translations,omitempty"`
    
    	// Список идентификаторов локалей, для которых есть переводы.
    	// Соответствует ключам в translations
    	TranslationsIDs []string `json:"translationsIds,omitempty" bson:"translations_ids,omitempty"`
    
    	RevisionID          string       `json:"revId,omitempty" bson:"revision_id"`
    	RevisionDescription string       `json:"revDescription,omitempty" bson:"revision_description"`
    	Permissions         *Permissions `json:"permissions,omitempty" bson:"-"`
    
    	// Релеватность элемента при полнотекстовом поиске
    	SearchScore float64 `json:"searchScore,omitempty" bson:"search_score,omitempty"`
    
    	Deleted  bool `json:"deleted,omitempty"  bson:"deleted,omitempty"`
    	Hidden   bool `json:"hidden,omitempty"   bson:"hidden,omitempty"`
    	Template bool `json:"template,omitempty" bson:"template,omitempty"`
    }
    
    func NewItem(spaceID, envID, collID, id string, data map[string]interface{}, translations map[string]map[string]interface{}) *Item {
    	return &Item{
    		ID:           id,
    		SpaceID:      spaceID,
    		EnvID:        envID,
    		CollectionID: collID,
    		Data:         data,
    		Translations: translations,
    	}
    }
    
    // GetID возвращает идентификатор записи
    func (i *Item) GetID() string {
    	return i.ID
    }
    
    func (i *Item) GetSpaceID() string {
    	return i.SpaceID
    }
    
    func (i *Item) Clone() *Item {
    	itm := *i
    	itm.Data = data.CloneMap(i.Data)
    
    	if i.Translations != nil {
    		itm.Translations = make(map[string]map[string]interface{}, len(i.Translations))
    		for t, m := range i.Translations {
    			itm.Translations[t] = data.CloneMap(m)
    		}
    	}
    
    	return &itm
    }
    
    // ToMap конвертирует текущий элемент в map[string]any.
    // DEPRECATED, используйте ToMap.
    func (i *Item) ToMap() map[string]interface{} {
    	return map[string]interface{}{
    		"id":                   i.ID,
    		"space_id":             i.SpaceID,
    		"env_id":               i.EnvID,
    		"collection_id":        i.CollectionID,
    		"state":                i.State,
    		"created_rev_at":       i.CreatedRevAt,
    		"created_by":           i.CreatedBy,
    		"created_at":           i.CreatedAt,
    		"updated_at":           i.UpdatedAt,
    		"updated_by":           i.UpdatedBy,
    		"revision_id":          i.RevisionID,
    		"revision_description": i.RevisionDescription,
    		"data":                 i.Data,
    		"locale_id":            i.LocaleID,
    		"translations":         i.Translations,
    		"translations_ids":     i.TranslationsIDs,
    		"deleted":              i.Deleted,
    		"hidden":               i.Hidden,
    		"template":             i.Template,
    		"search_score":         i.SearchScore,
    	}
    }
    
    // ToMap конвертирует переданный Item в map[string]any, кодируя данные согласно схеме.
    // Вычисляемые поля удаляются из результата.
    func ToMap(item *Item, sch *schema.Schema) (map[string]any, error) {
    	if item == nil {
    		return nil, errors.New("item must not be nil")
    	}
    	if sch == nil {
    		return nil, errors.New("schema must not be nil")
    	}
    
    	output := make(map[string]any)
    
    	decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
    		TagName: "json",
    		Result:  &output,
    	})
    	if err != nil {
    		return nil, errors.Wrap(err, "failed to create decoder")
    	}
    
    	item, err = item.Encode(context.Background(), sch)
    	if err != nil {
    		return nil, errors.Wrap(err, "failed to encode item by schema")
    	}
    
    	err = decoder.Decode(item)
    	if err != nil {
    		return nil, errors.Wrap(err, "failed to encode item")
    	}
    
    	// Кодируем системные поля со временем
    	output["createdRevAt"] = item.CreatedRevAt.Format(time.RFC3339)
    	output["createdAt"] = item.CreatedAt.Format(time.RFC3339)
    	output["updatedAt"] = item.UpdatedAt.Format(time.RFC3339)
    
    	// Удаляем вычисляемые поля
    	delete(output, "permissions")
    	delete(output, "searchScore")
    
    	return output, nil
    }
    
    // FromMap конвертирует переданный map[string]any в Item, декодируя данные согласно схеме.
    // Вычисляемые поля игнорируются при декодировании.
    func FromMap(input map[string]any, sch *schema.Schema) (*Item, error) {
    	if len(input) == 0 {
    		return nil, errors.New("input map must not be empty or nil")
    	}
    	if sch == nil {
    		return nil, errors.New("schema must not be nil")
    	}
    
    	item := &Item{}
    
    	decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
    		DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339),
    		TagName:    "json",
    		Result:     item,
    	})
    	if err != nil {
    		return nil, errors.Wrap(err, "failed to create decoder")
    	}
    
    	// Удаляем вычисляемые поля
    	delete(input, "permissions")
    	delete(input, "searchScore")
    
    	err = decoder.Decode(input)
    	if err != nil {
    		return nil, errors.Wrap(err, "failed to decode")
    	}
    
    	item, err = item.Decode(context.Background(), sch)
    	if err != nil {
    		return nil, errors.Wrap(err, "failed to decode item by schema")
    	}
    
    	return item, nil
    }
    
    // SetData устанавливает перевод в нужное поле записи
    func (i *Item) SetData(dt map[string]interface{}, localeID string) {
    	if localeID == "" || localeID == locales.DefaultID {
    		i.Data = dt
    		return
    	}
    	if i.Translations == nil {
    		i.Translations = map[string]map[string]interface{}{localeID: dt}
    		if !data.Contains(localeID, i.TranslationsIDs) {
    			i.TranslationsIDs = append(i.TranslationsIDs, localeID)
    		}
    	}
    	return
    }
    
    // GetData возвращает полные локализованные данные записи
    func (i *Item) GetData(localeID string) map[string]interface{} {
    	if localeID == "" || localeID == locales.DefaultID {
    		return i.Data
    	}
    	if i.Translations != nil {
    		return i.Translations[localeID]
    	}
    	return nil
    }
    
    func (i *Item) AddTranslations(translations map[string]map[string]interface{}) {
    	if i.Translations == nil {
    		i.Translations = make(map[string]map[string]interface{}, len(translations))
    	}
    	for l, t := range translations {
    		i.Translations[l] = t
    	}
    	for k := range translations {
    		if !data.Contains(k, i.TranslationsIDs) {
    			i.TranslationsIDs = append(i.TranslationsIDs, k)
    		}
    	}
    }
    
    func (i *Item) Localize(localizer *localizer.Localizer) (err error) {
    	if localizer == nil {
    		return nil
    	}
    	i.Data, err = localizer.Localize(i.Data, i.Translations)
    	if err != nil {
    		return err
    	}
    	i.LocaleID = localizer.LocaleID()
    	i.Translations = nil
    	return nil
    }
    
    // // UnsetTranslation устанавливает значение перевода в nil, для сброса перевода для языка
    // // "localeID":map{...}, "*":nil} - установка перевода для языка, сброс остальных переводов
    // // {"*":nil} -сброс всех переводов
    // func (i *Item) UnsetTranslation(localeID string) {
    // 	if i.Translations == nil {
    // 		i.Translations = make(map[string]map[string]interface{})
    // 	}
    //
    // 	i.Translations[localeID] = nil
    // }
    //
    // // SetTranslation устанавливает перевод для языка
    // func (i *Item) SetTranslation(dt map[string]interface{}, localeID string) {
    // 	if i.Translations == nil {
    // 		i.Translations = make(map[string]map[string]interface{})
    // 	}
    //
    // 	i.Translations[localeID] = dt
    // }
    
    func (i *Item) Encode(ctx context.Context, s *schema.Schema) (*Item, error) {
    	res := *i
    	if i.Data != nil {
    		dt, err := schema.Encode(ctx, s, i.Data)
    		if err != nil {
    			// return errors.WithField(err, "data")
    			return nil, err
    		}
    		res.Data = dt.(map[string]interface{})
    	}
    	if len(i.Translations) > 0 {
    		res.Translations = make(map[string]map[string]interface{}, len(i.Translations))
    		for l, v := range i.Translations {
    			if len(v) == 0 {
    				res.Translations[l] = v
    				continue
    			}
    			dt, err := schema.Encode(ctx, s, v)
    			if err != nil {
    				return nil, err
    			}
    			res.Translations[l] = dt.(map[string]interface{})
    		}
    	}
    	return &res, nil
    }
    
    func (i *Item) Decode(ctx context.Context, s *schema.Schema) (*Item, error) {
    	res := *i
    	var err error
    	if i.Data != nil {
    		res.Data, err = s.Decode(ctx, i.Data)
    		if err != nil {
    			return nil, err
    		}
    	}
    	if len(i.Translations) > 0 {
    		res.Translations = make(map[string]map[string]interface{}, len(i.Translations))
    		for l, v := range i.Translations {
    			if len(v) == 0 {
    				res.Translations[l] = v
    				continue
    			}
    			dt, err := schema.Decode(ctx, s, v)
    			if err != nil {
    				return nil, err
    			}
    			res.Translations[l] = dt.(map[string]interface{})
    		}
    	}
    	return &res, nil
    }
    
    type ProcessDataFunc func(ctx context.Context, sch *schema.Schema, data map[string]interface{}) (map[string]interface{}, error)
    
    func (i *Item) ProcessData(ctx context.Context, sch *schema.Schema, fn ProcessDataFunc, locales ...*locales.Locale) (*Item, error) {
    	res := *i
    	if i.Data != nil {
    		dt, err := fn(ctx, sch, res.Data)
    		if err != nil {
    			return nil, errors.WithField(err, "data")
    		}
    		res.Data = dt
    	}
    
    	tr := make(map[string]map[string]interface{})
    	for _, l := range locales {
    		itm := *i
    		err := itm.Localize(localizer.NewLocalizer(localizer.Config{Schema: sch, Locales: locales, LocaleID: l.ID}))
    		if err != nil {
    			return nil, errors.WithField(err, fmt.Sprintf("translations.%s", l.ID))
    		}
    
    		dt, err := fn(ctx, sch, itm.Data)
    		if err != nil {
    			return nil, errors.WithField(err, fmt.Sprintf("translations.%s", l.ID))
    		}
    		tr[l.ID] = dt
    	}
    
    	res.Translations = nil
    	if len(tr) > 0 {
    		res.Translations = tr
    	}
    
    	return &res, nil
    }
    
    // IsSystemField возвращает являться ли поле системным
    func IsSystemField(field string) bool {
    	return data.Contains(field, SystemFields)
    }
    
    // SetSystemField устанавливает значение системного поля
    func (i *Item) SetSystemField(field string, value interface{}) error {
    	var ok bool
    	switch field {
    	case "id":
    		i.ID, ok = value.(string)
    	case "space_id":
    		i.SpaceID, ok = value.(string)
    	case "env_id":
    		i.EnvID, ok = value.(string)
    	case "collection_id":
    		i.CollectionID, ok = value.(string)
    	case "created_rev_at":
    		i.CreatedRevAt, ok = value.(time.Time)
    	case "created_by":
    		i.CreatedBy, ok = value.(string)
    	case "created_at":
    		i.CreatedAt, ok = value.(time.Time)
    	case "updated_by":
    		i.UpdatedBy, ok = value.(string)
    	case "updated_at":
    		i.UpdatedAt, ok = value.(time.Time)
    	case "revision_id":
    		i.RevisionID, ok = value.(string)
    	case "revision_description":
    		i.RevisionDescription, ok = value.(string)
    	case "hidden":
    		i.Hidden, ok = value.(bool)
    	case "deleted":
    		i.Deleted, ok = value.(bool)
    	case "template":
    		i.Template, ok = value.(bool)
    	case "search_score":
    		i.SearchScore, ok = value.(float64)
    	case "locale_id":
    		i.LocaleID, ok = value.(string)
    	default:
    		return ErrNotSystemField
    	}
    
    	if !ok {
    		return ErrIncorrectValue
    	}
    
    	return nil
    }
    
    // GetSystem возвращает значение системного поля
    func (i *Item) GetSystem(field string) (any, error) {
    	switch field {
    	case "id":
    		return i.ID, nil
    	case "space_id":
    		return i.SpaceID, nil
    	case "env_id":
    		return i.EnvID, nil
    	case "collection_id":
    		return i.CollectionID, nil
    	case "created_rev_at":
    		return i.CreatedRevAt, nil
    	case "created_by":
    		return i.CreatedBy, nil
    	case "created_at":
    		return i.CreatedAt, nil
    	case "updated_by":
    		return i.UpdatedBy, nil
    	case "updated_at":
    		return i.UpdatedAt, nil
    	case "revision_id":
    		return i.RevisionID, nil
    	case "revision_description":
    		return i.RevisionDescription, nil
    	case "hidden":
    		return i.Hidden, nil
    	case "deleted":
    		return i.Deleted, nil
    	case "template":
    		return i.Template, nil
    	case "search_score":
    		return i.SearchScore, nil
    	case "state":
    		return i.State, nil
    	case "locale_id":
    		return i.LocaleID, nil
    
    	}
    
    	return nil, ErrNotSystemField
    }
    
    func (i *Item) setItemData(field string, value interface{}) error {
    	if i.Data == nil {
    		i.Data = make(map[string]any)
    	}
    
    	return data.Set(field, i.Data, value)
    }
    
    func (i *Item) getItemData(field string) (any, error) {
    	if i.Data != nil {
    		if v, ok := data.Get(field, i.Data); ok {
    			return v, nil
    		}
    	}
    
    	return nil, ErrIncorrectField
    }
    
    // Set устанавливает значение поля
    func (i *Item) Set(field string, value interface{}) error {
    	if err := i.SetSystemField(field, value); !errors.Is(err, ErrNotSystemField) {
    		return errors.Wrapf(err, "fail to set system field '%s' value", field)
    	}
    
    	return i.setItemData(field, value)
    }
    
    // Get возвращает значение поля
    func (i *Item) Get(field string) (any, error) {
    	if v, err := i.GetSystem(field); err == nil {
    		return v, err
    	}
    
    	return i.getItemData(field)
    }
    
    // Delete удаляет значение поля Data
    func (i *Item) Delete(field string) error {
    	// Если data == nil, то нет необходимости выполнять удаление
    	if i.Data == nil {
    		return nil
    	}
    
    	return data.Delete(field, i.Data)
    }
    
    // GetSystemField возвращает описание поля для системных аттрибутов Item
    func GetSystemField(fld string) (*field.Field, error) {
    	switch fld {
    	case "id", "space_id", "env_id", "collection_id", "revision_id", "revision_description", "locale_id":
    		return field.String(), nil
    	case "created_rev_at", "created_at", "updated_at", "published_at":
    		return field.Time(), nil
    	case "created_by", "updated_by", "published_by":
    		return field.String(), nil
    	case "hidden", "deleted", "template":
    		return field.Bool(), nil
    	case "search_score":
    		return field.Number(field.NumberFormatFloat), nil
    	case "state":
    		return field.Number(field.NumberFormatInt), nil
    	}
    
    	return nil, ErrNotSystemField
    }
    
    // GetField возвращает значение поля
    func GetField(field string, sch *schema.Schema) (*field.Field, error) {
    	if f, err := GetSystemField(field); err == nil {
    		return f, err
    	}
    
    	f := sch.GetField(field)
    	if f == nil {
    		return nil, ErrIncorrectField
    	}
    
    	return f, nil
    }
    
    // GetSystemNamedFields возвращает описание всех системных полей Item
    func GetSystemNamedFields() []field.NamedField {
    	fields := make([]field.NamedField, 0, len(SystemFields))
    	for _, n := range SystemFields {
    		f := field.NamedField{Name: n}
    		f.Field, _ = GetSystemField(n)
    		fields = append(fields, f)
    	}
    
    	return fields
    }
    
    func ItemToProto(item *Item) *pb.Item {
    	if item == nil {
    		return nil
    	}
    
    	protoItem := &pb.Item{
    		Id:                  item.ID,
    		SpaceId:             item.SpaceID,
    		EnvId:               item.EnvID,
    		CollectionId:        item.CollectionID,
    		State:               pb.Item_State(item.State),
    		CreatedBy:           item.CreatedBy,
    		UpdatedBy:           item.UpdatedBy,
    		RevisionId:          item.RevisionID,
    		RevisionDescription: item.RevisionDescription,
    		LocaleId:            item.LocaleID,
    		TranslationsIds:     item.TranslationsIDs,
    		Hidden:              item.Hidden,
    		Template:            item.Template,
    		Deleted:             item.Deleted,
    		SearchScore:         item.SearchScore,
    	}
    
    	if item.Data != nil {
    		protoItem.Data, _ = structpb.NewStruct(item.Data)
    	}
    	if item.Translations != nil {
    		protoItem.Translations = make(map[string]*structpb.Struct, len(item.Translations))
    		for k, v := range item.Translations {
    			var t *structpb.Struct
    			if v != nil {
    				t, _ = structpb.NewStruct(v)
    			}
    			protoItem.Translations[k] = t
    		}
    	}
    
    	protoItem.CreatedRevAt = timestamppb.New(item.CreatedRevAt)
    	protoItem.CreatedAt = timestamppb.New(item.CreatedAt)
    	protoItem.UpdatedAt = timestamppb.New(item.UpdatedAt)
    
    	if item.Permissions != nil {
    		protoItem.Permissions = &pb.Permissions{
    			Edit:       item.Permissions.Edit,
    			Archive:    item.Permissions.Archive,
    			Publish:    item.Permissions.Publish,
    			SoftDelete: item.Permissions.SoftDelete,
    			HardDelete: item.Permissions.HardDelete,
    		}
    	}
    
    	return protoItem
    }
    
    func ItemFromProto(protoItem *pb.Item) *Item {
    
    	if protoItem == nil {
    		return nil
    	}
    
    	item := &Item{
    		ID:                  protoItem.Id,
    		SpaceID:             protoItem.SpaceId,
    		EnvID:               protoItem.EnvId,
    		CollectionID:        protoItem.CollectionId,
    		State:               State(protoItem.State),
    		CreatedBy:           protoItem.CreatedBy,
    		UpdatedBy:           protoItem.UpdatedBy,
    		RevisionID:          protoItem.RevisionId,
    		RevisionDescription: protoItem.RevisionDescription,
    		LocaleID:            protoItem.LocaleId,
    		TranslationsIDs:     protoItem.TranslationsIds,
    		Hidden:              protoItem.Hidden,
    		Template:            protoItem.Template,
    		Deleted:             protoItem.Deleted,
    		SearchScore:         protoItem.SearchScore,
    	}
    
    	if protoItem.Data != nil {
    		item.Data = protoItem.Data.AsMap()
    	}
    
    	if protoItem.Translations != nil {
    		item.Translations = make(map[string]map[string]interface{}, len(protoItem.Translations))
    	}
    	if protoItem.Translations != nil {
    		for k, v := range protoItem.Translations {
    			// При proto.Marshal/Unmarshal `nil`-значение превращается в `Struct{}`, поэтому логика
    			// много смысла не несет, но хотя бы здесь сохраним различие пустого и nil значений
    			var t map[string]interface{}
    			if v != nil {
    				t = v.AsMap()
    			}
    			item.Translations[k] = t
    		}
    	}
    
    	if protoItem.Permissions != nil {
    		item.Permissions = &Permissions{
    			Edit:       protoItem.Permissions.Edit,
    			Archive:    protoItem.Permissions.Archive,
    			Publish:    protoItem.Permissions.Publish,
    			SoftDelete: protoItem.Permissions.SoftDelete,
    			HardDelete: protoItem.Permissions.HardDelete,
    		}
    	}
    
    	item.CreatedRevAt = protoItem.CreatedRevAt.AsTime()
    	item.CreatedAt = protoItem.CreatedAt.AsTime()
    	item.UpdatedAt = protoItem.UpdatedAt.AsTime()
    
    	return item
    }
    
    func GetItemIDs(arr []*Item) []string {
    	res := make([]string, len(arr))
    	for i, e := range arr {
    		res[i] = e.ID
    	}
    	return res
    }
    
    func MergeItemData(ctx context.Context, sch *schema.Schema, origData, updData map[string]any) (map[string]any, error) {
    	if origData == nil {
    		return updData, nil
    	}
    
    	w := walk.NewWalker(sch, &walk.WalkConfig{})
    	w.DefaultFn = func(c *walk.WalkContext) error {
    		if c.Src == nil || c.Dst != nil {
    			return nil
    		}
    		c.Dst = c.Src
    		c.Changed = true
    		return nil
    	}
    
    	res, _, err := w.DataWalk(ctx, updData, origData)
    	if err != nil {
    		return nil, err
    	}
    
    	v, ok := res.(map[string]any)
    	if !ok {
    		return nil, fmt.Errorf("expected map[string]interface{}, got %[1]T, %[1]v", res)
    	}
    
    	return v, nil
    }