Compare commits
No commits in common. "main" and "v0.2.7" have entirely different histories.
@ -311,79 +311,6 @@ message DatasourceResult {
|
|||||||
bytes meta_json = 3;
|
bytes meta_json = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- datatables.* (datatables.DataTables) ---
|
|
||||||
|
|
||||||
message DatatablesGetTableByKeyRequest {
|
|
||||||
string table_key = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesGetTableByKeyResponse {
|
|
||||||
DataTableInfo table = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesGetRowRequest {
|
|
||||||
string row_id = 1; // UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesGetRowResponse {
|
|
||||||
DataTableRowInfo row = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesListRowsRequest {
|
|
||||||
string table_id = 1; // UUID
|
|
||||||
int32 limit = 2;
|
|
||||||
int32 offset = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesListRowsResponse {
|
|
||||||
repeated DataTableRowInfo rows = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesCreateRowRequest {
|
|
||||||
string table_id = 1; // UUID
|
|
||||||
bytes data_json = 2; // JSON object
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesCreateRowResponse {
|
|
||||||
DataTableRowInfo row = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesUpdateRowDataRequest {
|
|
||||||
string row_id = 1; // UUID
|
|
||||||
bytes data_json = 2; // JSON object
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesUpdateRowDataResponse {
|
|
||||||
DataTableRowInfo row = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesFindRowByFieldRequest {
|
|
||||||
string table_id = 1; // UUID
|
|
||||||
string field = 2;
|
|
||||||
string value = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DatatablesFindRowByFieldResponse {
|
|
||||||
// Unset when no row matches (the guest maps that to nil, nil).
|
|
||||||
DataTableRowInfo row = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataTableInfo mirrors datatables.Table.
|
|
||||||
message DataTableInfo {
|
|
||||||
string id = 1; // UUID
|
|
||||||
string table_key = 2;
|
|
||||||
string name = 3;
|
|
||||||
int32 row_count = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataTableRowInfo mirrors datatables.Row (data column as JSON bytes).
|
|
||||||
message DataTableRowInfo {
|
|
||||||
string id = 1; // UUID
|
|
||||||
string table_id = 2; // UUID
|
|
||||||
bytes data_json = 3; // JSON object
|
|
||||||
int32 sort_order = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- users.* (auth.PublicUsers) ---
|
// --- users.* (auth.PublicUsers) ---
|
||||||
|
|
||||||
message UsersGetByUsernameRequest {
|
message UsersGetByUsernameRequest {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,46 +0,0 @@
|
|||||||
// Package datatables gives plugins typed access to site data tables (the Data
|
|
||||||
// Platform's data_tables/data_table_rows), which the per-plugin Postgres role
|
|
||||||
// deliberately cannot reach with raw SQL. The CMS implements this interface
|
|
||||||
// host-side and wires it into CoreServices; row payloads cross the ABI as JSON.
|
|
||||||
package datatables
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DataTables provides read/write access to site data tables for plugins.
|
|
||||||
type DataTables interface {
|
|
||||||
// GetTableByKey resolves a data table by its stable key (e.g. "playgrounds").
|
|
||||||
GetTableByKey(ctx context.Context, tableKey string) (*Table, error)
|
|
||||||
// GetRow fetches a single row by ID.
|
|
||||||
GetRow(ctx context.Context, rowID uuid.UUID) (*Row, error)
|
|
||||||
// ListRows pages through a table's rows in sort order. limit <= 0 applies
|
|
||||||
// the host default.
|
|
||||||
ListRows(ctx context.Context, tableID uuid.UUID, limit, offset int32) ([]Row, error)
|
|
||||||
// CreateRow appends a row with the given JSON object payload.
|
|
||||||
CreateRow(ctx context.Context, tableID uuid.UUID, dataJSON []byte) (*Row, error)
|
|
||||||
// UpdateRowData replaces a row's JSON payload, preserving its sort order.
|
|
||||||
UpdateRowData(ctx context.Context, rowID uuid.UUID, dataJSON []byte) (*Row, error)
|
|
||||||
// FindRowByField returns the first row whose data->>field equals value, or
|
|
||||||
// (nil, nil) when no row matches.
|
|
||||||
FindRowByField(ctx context.Context, tableID uuid.UUID, field, value string) (*Row, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table is a plugin-facing view of a data table.
|
|
||||||
type Table struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
TableKey string
|
|
||||||
Name string
|
|
||||||
RowCount int32
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row is a plugin-facing view of a data table row. DataJSON is the row's data
|
|
||||||
// column verbatim (a JSON object).
|
|
||||||
type Row struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
TableID uuid.UUID
|
|
||||||
DataJSON []byte
|
|
||||||
SortOrder int32
|
|
||||||
}
|
|
||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/datasources"
|
"git.dev.alexdunmow.com/block/pluginsdk/datasources"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/datatables"
|
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/gating"
|
"git.dev.alexdunmow.com/block/pluginsdk/gating"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/menus"
|
"git.dev.alexdunmow.com/block/pluginsdk/menus"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
||||||
@ -28,7 +27,6 @@ type CoreServices struct {
|
|||||||
Crypto crypto.Crypto
|
Crypto crypto.Crypto
|
||||||
Menus menus.Menus
|
Menus menus.Menus
|
||||||
Datasources datasources.Datasources
|
Datasources datasources.Datasources
|
||||||
DataTables datatables.DataTables
|
|
||||||
PublicUsers auth.PublicUsers
|
PublicUsers auth.PublicUsers
|
||||||
Subscriptions subscriptions.Subscriptions
|
Subscriptions subscriptions.Subscriptions
|
||||||
|
|
||||||
|
|||||||
@ -405,90 +405,6 @@ func TestCapabilityRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// --- datatables ---
|
|
||||||
{
|
|
||||||
name: "datatables_get_table_by_key", wantMethod: "datatables.get_table_by_key",
|
|
||||||
resp: &abiv1.DatatablesGetTableByKeyResponse{Table: &abiv1.DataTableInfo{
|
|
||||||
Id: idTable.String(), TableKey: "playgrounds", Name: "Playgrounds", RowCount: 678,
|
|
||||||
}},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.GetTableByKey(ctx, "playgrounds")
|
|
||||||
if err != nil || got.ID != idTable || got.TableKey != "playgrounds" || got.RowCount != 678 {
|
|
||||||
t.Errorf("table = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "datatables_get_row", wantMethod: "datatables.get_row",
|
|
||||||
resp: &abiv1.DatatablesGetRowResponse{Row: &abiv1.DataTableRowInfo{
|
|
||||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Whiteman Park"}`), SortOrder: 3,
|
|
||||||
}},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.GetRow(ctx, idRow)
|
|
||||||
if err != nil || got.ID != idRow || got.TableID != idTable || got.SortOrder != 3 {
|
|
||||||
t.Errorf("row = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "datatables_list_rows", wantMethod: "datatables.list_rows",
|
|
||||||
resp: &abiv1.DatatablesListRowsResponse{Rows: []*abiv1.DataTableRowInfo{
|
|
||||||
{Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"A"}`)},
|
|
||||||
{Id: idItem.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"B"}`), SortOrder: 1},
|
|
||||||
}},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.ListRows(ctx, idTable, 100, 0)
|
|
||||||
if err != nil || len(got) != 2 || got[0].ID != idRow || got[1].SortOrder != 1 {
|
|
||||||
t.Errorf("rows = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "datatables_create_row", wantMethod: "datatables.create_row",
|
|
||||||
resp: &abiv1.DatatablesCreateRowResponse{Row: &abiv1.DataTableRowInfo{
|
|
||||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"New"}`),
|
|
||||||
}},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.CreateRow(ctx, idTable, []byte(`{"name":"New"}`))
|
|
||||||
if err != nil || got.ID != idRow || string(got.DataJSON) != `{"name":"New"}` {
|
|
||||||
t.Errorf("row = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "datatables_update_row_data", wantMethod: "datatables.update_row_data",
|
|
||||||
resp: &abiv1.DatatablesUpdateRowDataResponse{Row: &abiv1.DataTableRowInfo{
|
|
||||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Updated"}`), SortOrder: 7,
|
|
||||||
}},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.UpdateRowData(ctx, idRow, []byte(`{"name":"Updated"}`))
|
|
||||||
if err != nil || got.SortOrder != 7 || string(got.DataJSON) != `{"name":"Updated"}` {
|
|
||||||
t.Errorf("row = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "datatables_find_row_by_field", wantMethod: "datatables.find_row_by_field",
|
|
||||||
resp: &abiv1.DatatablesFindRowByFieldResponse{Row: &abiv1.DataTableRowInfo{
|
|
||||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"google_place_id":"abc"}`),
|
|
||||||
}},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "abc")
|
|
||||||
if err != nil || got == nil || got.ID != idRow {
|
|
||||||
t.Errorf("row = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "datatables_find_row_by_field_miss", wantMethod: "datatables.find_row_by_field",
|
|
||||||
resp: &abiv1.DatatablesFindRowByFieldResponse{},
|
|
||||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
|
||||||
got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "missing")
|
|
||||||
if err != nil || got != nil {
|
|
||||||
t.Errorf("want nil, nil on miss; row = %+v err = %v", got, err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// --- users ---
|
// --- users ---
|
||||||
{
|
{
|
||||||
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
||||||
|
|||||||
@ -34,7 +34,6 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
|||||||
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
||||||
Menus: &menusStub{base{family: "menus", call: call}},
|
Menus: &menusStub{base{family: "menus", call: call}},
|
||||||
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
||||||
DataTables: &datatablesStub{base{family: "datatables", call: call}},
|
|
||||||
PublicUsers: &usersStub{base{family: "users", call: call}},
|
PublicUsers: &usersStub{base{family: "users", call: call}},
|
||||||
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
||||||
Media: &mediaStub{base{family: "media", call: call}},
|
Media: &mediaStub{base{family: "media", call: call}},
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
package caps
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/datatables"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// datatablesStub implements datatables.DataTables over datatables.* capability
|
|
||||||
// calls. Row payloads cross as JSON bytes.
|
|
||||||
type datatablesStub struct{ base }
|
|
||||||
|
|
||||||
var _ datatables.DataTables = (*datatablesStub)(nil)
|
|
||||||
|
|
||||||
func (s *datatablesStub) GetTableByKey(ctx context.Context, tableKey string) (*datatables.Table, error) {
|
|
||||||
resp := &abiv1.DatatablesGetTableByKeyResponse{}
|
|
||||||
req := &abiv1.DatatablesGetTableByKeyRequest{TableKey: tableKey}
|
|
||||||
if err := s.invoke(ctx, "get_table_by_key", req, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dataTableFromProto(resp.GetTable()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *datatablesStub) GetRow(ctx context.Context, rowID uuid.UUID) (*datatables.Row, error) {
|
|
||||||
resp := &abiv1.DatatablesGetRowResponse{}
|
|
||||||
req := &abiv1.DatatablesGetRowRequest{RowId: rowID.String()}
|
|
||||||
if err := s.invoke(ctx, "get_row", req, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dataTableRowFromProto(resp.GetRow()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *datatablesStub) ListRows(ctx context.Context, tableID uuid.UUID, limit, offset int32) ([]datatables.Row, error) {
|
|
||||||
resp := &abiv1.DatatablesListRowsResponse{}
|
|
||||||
req := &abiv1.DatatablesListRowsRequest{TableId: tableID.String(), Limit: limit, Offset: offset}
|
|
||||||
if err := s.invoke(ctx, "list_rows", req, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rows := make([]datatables.Row, 0, len(resp.GetRows()))
|
|
||||||
for _, r := range resp.GetRows() {
|
|
||||||
if row := dataTableRowFromProto(r); row != nil {
|
|
||||||
rows = append(rows, *row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *datatablesStub) CreateRow(ctx context.Context, tableID uuid.UUID, dataJSON []byte) (*datatables.Row, error) {
|
|
||||||
resp := &abiv1.DatatablesCreateRowResponse{}
|
|
||||||
req := &abiv1.DatatablesCreateRowRequest{TableId: tableID.String(), DataJson: dataJSON}
|
|
||||||
if err := s.invoke(ctx, "create_row", req, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dataTableRowFromProto(resp.GetRow()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *datatablesStub) UpdateRowData(ctx context.Context, rowID uuid.UUID, dataJSON []byte) (*datatables.Row, error) {
|
|
||||||
resp := &abiv1.DatatablesUpdateRowDataResponse{}
|
|
||||||
req := &abiv1.DatatablesUpdateRowDataRequest{RowId: rowID.String(), DataJson: dataJSON}
|
|
||||||
if err := s.invoke(ctx, "update_row_data", req, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dataTableRowFromProto(resp.GetRow()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *datatablesStub) FindRowByField(ctx context.Context, tableID uuid.UUID, field, value string) (*datatables.Row, error) {
|
|
||||||
resp := &abiv1.DatatablesFindRowByFieldResponse{}
|
|
||||||
req := &abiv1.DatatablesFindRowByFieldRequest{TableId: tableID.String(), Field: field, Value: value}
|
|
||||||
if err := s.invoke(ctx, "find_row_by_field", req, resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dataTableRowFromProto(resp.GetRow()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func dataTableFromProto(t *abiv1.DataTableInfo) *datatables.Table {
|
|
||||||
if t == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
id, _ := uuid.Parse(t.GetId())
|
|
||||||
return &datatables.Table{
|
|
||||||
ID: id,
|
|
||||||
TableKey: t.GetTableKey(),
|
|
||||||
Name: t.GetName(),
|
|
||||||
RowCount: t.GetRowCount(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func dataTableRowFromProto(r *abiv1.DataTableRowInfo) *datatables.Row {
|
|
||||||
if r == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
id, _ := uuid.Parse(r.GetId())
|
|
||||||
tableID, _ := uuid.Parse(r.GetTableId())
|
|
||||||
return &datatables.Row{
|
|
||||||
ID: id,
|
|
||||||
TableID: tableID,
|
|
||||||
DataJSON: r.GetDataJson(),
|
|
||||||
SortOrder: r.GetSortOrder(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
\
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idmissing
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idabc
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
g
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"google_place_id":"abc"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
h
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Whiteman Park"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
playgrounds
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
C
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaaplaygroundsPlaygrounds ¦
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaad
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
|
|
||||||
Z
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"A"}
|
|
||||||
\
|
|
||||||
$44444444-4444-4444-4444-444444444444$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"B"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb{"name":"Updated"}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
b
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Updated"}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user