Browse Source

Merge branch 'babylon' into query-node/add-ci-job

Mokhtar Naamani 4 years ago
parent
commit
68aa455362
28 changed files with 467 additions and 134 deletions
  1. 45 8
      cli/src/base/ContentDirectoryCommandBase.ts
  2. 27 16
      cli/src/base/WorkingGroupsCommandBase.ts
  3. 1 1
      cli/src/commands/media/createChannel.ts
  4. 57 0
      cli/src/commands/media/curateContent.ts
  5. 27 7
      cli/src/commands/media/updateChannel.ts
  6. 25 5
      cli/src/commands/media/updateVideo.ts
  7. 1 0
      content-directory-schemas/.npmignore
  8. 1 1
      content-directory-schemas/examples/createChannel.ts
  9. 68 0
      content-directory-schemas/examples/createChannelWithoutTransaction.ts
  10. 47 0
      content-directory-schemas/examples/updateChannelTitleWithoutTransaction.ts
  11. 0 7
      content-directory-schemas/inputs/classes/CurationStatusClass.json
  12. 3 3
      content-directory-schemas/inputs/schemas/ChannelSchema.json
  13. 0 27
      content-directory-schemas/inputs/schemas/CurationStatusSchema.json
  14. 3 3
      content-directory-schemas/inputs/schemas/VideoSchema.json
  15. 3 1
      content-directory-schemas/package.json
  16. 12 1
      content-directory-schemas/scripts/schemasToTS.ts
  17. 83 43
      content-directory-schemas/src/helpers/InputParser.ts
  18. 32 8
      content-directory-schemas/src/helpers/extrinsics.ts
  19. 19 0
      content-directory-schemas/src/helpers/initialize.ts
  20. 1 0
      content-directory-schemas/src/index.ts
  21. 1 1
      content-directory-schemas/tsconfig.json
  22. 1 1
      types/augment-codec/all.ts
  23. 0 0
      types/augment-codec/augment-types.ts
  24. 1 0
      types/augment/all/defs.json
  25. 3 0
      types/augment/all/types.ts
  26. 0 0
      types/augment/augment-types.ts
  27. 3 0
      types/src/content-directory/index.ts
  28. 3 1
      types/src/index.ts

+ 45 - 8
cli/src/base/ContentDirectoryCommandBase.ts

@@ -1,28 +1,65 @@
 import ExitCodes from '../ExitCodes'
-import AccountsCommandBase from './AccountsCommandBase'
-import { WorkingGroups, NamedKeyringPair } from '../Types'
+import { WorkingGroups } from '../Types'
 import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
 import { FlattenRelations } from 'cd-schemas/types/utility'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
-import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
+import {
+  Class,
+  ClassId,
+  CuratorGroup,
+  CuratorGroupId,
+  Entity,
+  EntityId,
+  Actor,
+} from '@joystream/types/content-directory'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { Codec } from '@polkadot/types/types'
 import _ from 'lodash'
+import { RolesCommandBase } from './WorkingGroupsCommandBase'
+import { createType } from '@joystream/types'
 import chalk from 'chalk'
 
 /**
  * Abstract base class for commands related to content directory
  */
-export default abstract class ContentDirectoryCommandBase extends AccountsCommandBase {
+export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
+  group = WorkingGroups.Curators // override group for RolesCommandBase
+
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const lead = await this.getApi().groupLead(WorkingGroups.Curators)
+    await this.getRequiredLead()
+  }
+
+  async getCuratorContext(classNames: string[] = []): Promise<Actor> {
+    const curator = await this.getRequiredWorker()
+    const classes = await Promise.all(classNames.map(async (cName) => (await this.classEntryByNameOrId(cName))[1]))
+    const classMaintainers = classes.map(({ class_permissions: permissions }) => permissions.maintainers.toArray())
 
-    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Content Working Group Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+    const groups = await this.getApi().availableCuratorGroups()
+    const availableGroupIds = groups
+      .filter(
+        ([groupId, group]) =>
+          group.active.valueOf() &&
+          classMaintainers.every((maintainers) => maintainers.some((m) => m.eq(groupId))) &&
+          group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
+      )
+      .map(([id]) => id)
+
+    let groupId: number
+    if (!availableGroupIds.length) {
+      this.error(
+        'You do not have the required maintainer access to at least one of the following classes: ' +
+          classNames.join(', '),
+        { exit: ExitCodes.AccessDenied }
+      )
+    } else if (availableGroupIds.length === 1) {
+      groupId = availableGroupIds[0].toNumber()
+    } else {
+      groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
     }
+
+    return createType('Actor', { Curator: [groupId, curator.workerId.toNumber()] })
   }
 
   async promptForClass(message = 'Select a class'): Promise<Class> {

+ 27 - 16
cli/src/base/WorkingGroupsCommandBase.ts

@@ -24,29 +24,20 @@ const DEFAULT_GROUP = WorkingGroups.StorageProviders
 const DRAFTS_FOLDER = 'opening-drafts'
 
 /**
- * Abstract base class for commands related to working groups
+ * Abstract base class for commands that need to use gates based on user's roles
  */
-export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
+export abstract class RolesCommandBase extends AccountsCommandBase {
   group: WorkingGroups = DEFAULT_GROUP
 
-  static flags = {
-    group: flags.string({
-      char: 'g',
-      description:
-        'The working group context in which the command should be executed\n' +
-        `Available values are: ${AvailableGroups.join(', ')}.`,
-      required: true,
-      default: DEFAULT_GROUP,
-    }),
-  }
-
   // Use when lead access is required in given command
   async getRequiredLead(): Promise<GroupMember> {
     const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
     const lead = await this.getApi().groupLead(this.group)
 
     if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     }
 
     return lead
@@ -59,7 +50,9 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
 
     if (!groupMembersByAccount.length) {
-      this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     } else if (groupMembersByAccount.length === 1) {
       return groupMembersByAccount[0]
     } else {
@@ -88,7 +81,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
   async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
     const chosenWorkerIndex = await this.simplePrompt({
-      message: 'Choose the intended worker context:',
+      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
       type: 'list',
       choices: groupMembers.map((groupMember, index) => ({
         name: `Worker ID ${groupMember.workerId.toString()}`,
@@ -98,6 +91,24 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
     return groupMembers[chosenWorkerIndex]
   }
+}
+
+/**
+ * Abstract base class for commands directly related to working groups
+ */
+export default abstract class WorkingGroupsCommandBase extends RolesCommandBase {
+  group: WorkingGroups = DEFAULT_GROUP
+
+  static flags = {
+    group: flags.string({
+      char: 'g',
+      description:
+        'The working group context in which the command should be executed\n' +
+        `Available values are: ${AvailableGroups.join(', ')}.`,
+      required: true,
+      default: DEFAULT_GROUP,
+    }),
+  }
 
   async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
     const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)

+ 1 - 1
cli/src/commands/media/createChannel.ts

@@ -27,7 +27,7 @@ export default class CreateChannelCommand extends ContentDirectoryCommandBase {
     if (!inputJson) {
       const customPrompts: JsonSchemaCustomPrompts = [
         ['language', () => this.promptForEntityId('Choose channel language', 'Language', 'name')],
-        ['curationStatus', async () => undefined],
+        ['isCensored', async () => undefined],
       ]
 
       const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, undefined, customPrompts)

+ 57 - 0
cli/src/commands/media/curateContent.ts

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { InputParser } from 'cd-schemas'
+import { flags } from '@oclif/command'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+const CLASSES = ['Channel', 'Video'] as const
+const STATUSES = ['Accepted', 'Censored'] as const
+
+export default class CurateContentCommand extends ContentDirectoryCommandBase {
+  static description = `Set the curation status of given entity (${CLASSES.join('/')}). Requires Curator access.`
+  static flags = {
+    className: flags.enum({
+      options: [...CLASSES],
+      description: `Name of the class of the entity to curate (${CLASSES.join('/')})`,
+      char: 'c',
+      required: true,
+    }),
+    status: flags.enum({
+      description: `Specifies the curation status (${STATUSES.join('/')})`,
+      char: 's',
+      options: [...STATUSES],
+      required: true,
+    }),
+    id: flags.integer({
+      description: 'ID of the entity to curate',
+      required: true,
+    }),
+  }
+
+  async run() {
+    const { className, status, id } = this.parse(CurateContentCommand).flags
+
+    const account = await this.getRequiredSelectedAccount()
+    // Get curator actor with required maintainer access to $className (Video/Channel) class
+    const actor = await this.getCuratorContext([className])
+
+    await this.requestAccountDecoding(account)
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+
+    await this.getEntity(id, className) // Check if entity exists and is of given class
+
+    const entityUpdateInput: Partial<ChannelEntity & VideoEntity> = {
+      isCensored: status === 'Censored',
+    }
+
+    this.log(`Updating the ${className} with:`)
+    this.jsonPrettyPrint(JSON.stringify(entityUpdateInput))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      const operations = await inputParser.getEntityUpdateOperations(entityUpdateInput, className, id)
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations], true)
+    }
+  }
+}

+ 27 - 7
cli/src/commands/media/updateChannel.ts

@@ -5,12 +5,18 @@ import { InputParser } from 'cd-schemas'
 import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { Entity } from '@joystream/types/content-directory'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { flags } from '@oclif/command'
+import { createType } from '@joystream/types'
 
 export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
   static description = 'Update one of the owned channels on Joystream (requires a membership).'
   static flags = {
     ...IOFlags,
+    asCurator: flags.boolean({
+      description: 'Provide this flag in order to use Curator context for the update',
+      required: false,
+    }),
   }
 
   static args = [
@@ -22,13 +28,23 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
   ]
 
   async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateChannelCommand)
+
     const account = await this.getRequiredSelectedAccount()
-    const memberId = await this.getRequiredMemberId()
-    const actor = { Member: memberId }
 
-    await this.requestAccountDecoding(account)
+    let memberId: number | undefined, actor: Actor
+
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Channel'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
 
-    const { id } = this.parse(UpdateChannelCommand).args
+    await this.requestAccountDecoding(account)
 
     let channelEntity: Entity, channelId: number
     if (id) {
@@ -49,15 +65,19 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
 
     let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
     if (!inputJson) {
-      const customPrompts: JsonSchemaCustomPrompts = [
+      const customPrompts: JsonSchemaCustomPrompts<ChannelEntity> = [
         [
           'language',
           () =>
             this.promptForEntityId('Choose channel language', 'Language', 'name', undefined, currentValues.language),
         ],
-        ['curationStatus', async () => undefined],
       ]
 
+      if (!asCurator) {
+        // Skip isCensored is it's not updated by the curator
+        customPrompts.push(['isCensored', async () => undefined])
+      }
+
       const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, currentValues, customPrompts)
 
       inputJson = await prompter.promptAll()

+ 25 - 5
cli/src/commands/media/updateVideo.ts

@@ -5,12 +5,18 @@ import { LicenseEntity } from 'cd-schemas/types/entities/LicenseEntity'
 import { InputParser } from 'cd-schemas'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { Entity } from '@joystream/types/content-directory'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
 
 export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
   static description = 'Update existing video information (requires a membership).'
   static flags = {
     // TODO: ...IOFlags, - providing input as json
+    asCurator: flags.boolean({
+      description: 'Specify in order to update the video as curator',
+      required: false,
+    }),
   }
 
   static args = [
@@ -22,13 +28,23 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
   ]
 
   async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateVideoCommand)
+
     const account = await this.getRequiredSelectedAccount()
-    const memberId = await this.getRequiredMemberId()
-    const actor = { Member: memberId }
 
-    await this.requestAccountDecoding(account)
+    let memberId: number | undefined, actor: Actor
 
-    const { id } = this.parse(UpdateVideoCommand).args
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Video', 'License'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
 
     let videoEntity: Entity, videoId: number
     if (id) {
@@ -80,6 +96,10 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
       'hasMarketing',
     ])
 
+    if (asCurator) {
+      updatedProps.isCensored = await videoPrompter.promptSingleProp('isCensored')
+    }
+
     this.jsonPrettyPrint(JSON.stringify(updatedProps))
 
     // Parse inputs into operations and send final extrinsic

+ 1 - 0
content-directory-schemas/.npmignore

@@ -0,0 +1 @@
+operations.json

+ 1 - 1
content-directory-schemas/examples/createChannel.ts

@@ -3,7 +3,7 @@ import { types as joyTypes } from '@joystream/types'
 import { Keyring } from '@polkadot/keyring'
 // Import input parser and channel entity from cd-schemas (we use it as library here)
 import { InputParser } from 'cd-schemas'
-import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { ChannelEntity } from 'cd-schemas/types/entities'
 
 async function main() {
   // Initialize the api

+ 68 - 0
content-directory-schemas/examples/createChannelWithoutTransaction.ts

@@ -0,0 +1,68 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+import { EntityId } from '@joystream/types/content-directory'
+
+// Alternative way of creating a channel using separate extrinsics (instead of contentDirectory.transaction)
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // In this case we need to fetch some data first (like classId and language entity id)
+  const classId = await parser.getClassIdByName('Channel')
+  const languageEntityId = await parser.findEntityIdByUniqueQuery({ code: 'EN' }, 'Language')
+
+  // We use FlattenRelations to exlude { new } and { existing } (which are not allowed if we want to parse only a single entity)
+  const channel: FlattenRelations<ChannelEntity> = {
+    title: 'Example channel 2',
+    description: 'This is an example channel',
+    language: languageEntityId,
+    coverPhotoUrl: '',
+    avatarPhotoURL: '',
+    isPublic: true,
+  }
+
+  // In this case we use some basic callback to retrieve entityId from the extrinsc event
+  const entityId = await new Promise<EntityId>((resolve, reject) => {
+    api.tx.contentDirectory.createEntity(classId, { Member: 0 }).signAndSend(ALICE, {}, (res) => {
+      if (res.isError) {
+        reject(new Error(res.status.type))
+      }
+      res.events.forEach(({ event: e }) => {
+        if (e.method === 'EntityCreated') {
+          resolve(e.data[1] as EntityId)
+        }
+        if (e.method === 'ExtrinsicFailed') {
+          reject(new Error('Extrinsic failed'))
+        }
+      })
+    })
+  })
+
+  const inputPropertyValuesMap = await parser.parseToInputEntityValuesMap({ ...channel }, 'Channel')
+  // Having entityId we can create and send addSchemaSupport tx
+  await api.tx.contentDirectory
+    .addSchemaSupportToEntity(
+      { Member: 0 }, // Context (in this case we assume it's Alice's member id)
+      entityId,
+      0, // Schema (currently we have one schema per class, so it can be just 0)
+      inputPropertyValuesMap
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 47 - 0
content-directory-schemas/examples/updateChannelTitleWithoutTransaction.ts

@@ -0,0 +1,47 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+
+// Alternative way of update a channel using updateEntityPropertyValues extrinsic
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  // Create partial channel entity, only containing the fields we wish to update
+  const channelUpdateInput: Partial<FlattenRelations<ChannelEntity>> = {
+    title: 'Updated channel title 2',
+  }
+
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // We can reuse InputParser's `findEntityIdByUniqueQuery` method to find entityId of the channel we
+  // created in ./createChannelWithoutTransaction.ts example
+  // (normally we would probably use some other way to do it, ie.: query node)
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel 2' }, 'Channel')
+
+  // We use parser to create input property values map
+  const newPropertyValues = await parser.parseToInputEntityValuesMap(channelUpdateInput, 'Channel')
+
+  await api.tx.contentDirectory
+    .updateEntityPropertyValues(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      CHANNEL_ID,
+      newPropertyValues
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 0 - 7
content-directory-schemas/inputs/classes/CurationStatusClass.json

@@ -1,7 +0,0 @@
-{
-  "name": "CurationStatus",
-  "description": "Curation status of a related entity (ie. Video or Channel)",
-  "maximum_entities_count": 400,
-  "default_entity_creation_voucher_upper_bound": 50,
-  "class_permissions": { "any_member": false }
-}

+ 3 - 3
content-directory-schemas/inputs/schemas/ChannelSchema.json

@@ -33,11 +33,11 @@
       "property_type": { "Single": "Bool" }
     },
     {
-      "name": "curationStatus",
-      "description": "Channel curation status set by the Curator",
+      "name": "isCensored",
+      "description": "Channel censorship status set by the Curator.",
       "required": false,
       "unique": true,
-      "property_type": { "Single": { "Reference": { "className": "CurationStatus" } } },
+      "property_type": { "Single": "Bool" },
       "locking_policy": { "is_locked_from_controller": true }
     },
     {

+ 0 - 27
content-directory-schemas/inputs/schemas/CurationStatusSchema.json

@@ -1,27 +0,0 @@
-{
-  "className": "CurationStatus",
-  "newProperties": [
-    {
-      "name": "approved",
-      "description": "Indicates whether the content was approved by the Curator",
-      "required": false,
-      "property_type": { "Single": "Bool" },
-      "locking_policy": { "is_locked_from_controller": true }
-    },
-    {
-      "name": "comment",
-      "description": "Short, optional comment from the Curator",
-      "required": false,
-      "property_type": { "Single": { "Text": 256 } },
-      "locking_policy": { "is_locked_from_controller": true }
-    },
-    {
-      "name": "entityId",
-      "description": "ID of the curated entity. It's not a relation to prevent removal lock and allow different types of entities. Used to confirm the validity of Content => CurationStatus relation.",
-      "required": true,
-      "unique": true,
-      "property_type": { "Single": "Uint64" },
-      "locking_policy": { "is_locked_from_controller": true }
-    }
-  ]
-}

+ 3 - 3
content-directory-schemas/inputs/schemas/VideoSchema.json

@@ -90,11 +90,11 @@
       "property_type": { "Single": { "Reference": { "className": "License", "sameOwner": true } } }
     },
     {
-      "name": "curationStatus",
-      "description": "Video curation status set by the Curator",
+      "name": "isCensored",
+      "description": "Video censorship status set by the Curator.",
       "required": false,
       "unique": true,
-      "property_type": { "Single": { "Reference": { "className": "CurationStatus" } } },
+      "property_type": { "Single": "Bool" },
       "locking_policy": { "is_locked_from_controller": true }
     }
   ]

+ 3 - 1
content-directory-schemas/package.json

@@ -19,7 +19,9 @@
     "initialize:dev": "yarn initialize:alice-as-lead && yarn initialize:content-dir",
     "example:createChannel": "ts-node ./examples/createChannel.ts",
     "example:createVideo": "ts-node ./examples/createVideo.ts",
-    "example:updateChannelTitle": "ts-node ./examples/updateChannelTitle.ts"
+    "example:updateChannelTitle": "ts-node ./examples/updateChannelTitle.ts",
+    "example:createChannelWithoutTransaction": "ts-node ./examples/createChannelWithoutTransaction.ts",
+    "example:updateChannelTitlelWithoutTransaction": "ts-node ./examples/updateChannelTitleWithoutTransaction.ts"
   },
   "dependencies": {
     "ajv": "6.12.5",

+ 12 - 1
content-directory-schemas/scripts/schemasToTS.ts

@@ -10,15 +10,19 @@ const OUTPUT_TYPES_LOCATION = path.join(__dirname, '../types')
 const SUBDIRS_INCLUDED = ['extrinsics', 'entities'] as const
 
 async function main() {
+  // Create typescript files
   for (const subdirName of fs.readdirSync(SCHEMAS_LOCATION)) {
     if (!SUBDIRS_INCLUDED.includes(subdirName as any)) {
       console.log(`Subdir/filename not included: ${subdirName} - skipping...`)
       continue
     }
     const schemaSubdir = subdirName as typeof SUBDIRS_INCLUDED[number]
+    const indexExportedTypes: string[] = []
     for (const schemaFilename of fs.readdirSync(path.join(SCHEMAS_LOCATION, schemaSubdir))) {
       const schemaFilePath = path.join(SCHEMAS_LOCATION, schemaSubdir, schemaFilename)
-      const outputFilename = schemaFilename.replace('.schema.json', '.d.ts')
+      const mainTypeName = schemaFilename.replace('.schema.json', '')
+      const outputFilename = mainTypeName + '.d.ts'
+      indexExportedTypes.push(mainTypeName)
       const outputDir = path.join(OUTPUT_TYPES_LOCATION, schemaSubdir)
       if (!fs.existsSync(outputDir)) {
         fs.mkdirSync(outputDir)
@@ -37,6 +41,13 @@ async function main() {
         console.error(e)
       }
     }
+    // Generate main index.d.ts export file for entities
+    const indexFilePath = path.join(OUTPUT_TYPES_LOCATION, schemaSubdir, 'index.d.ts')
+    fs.writeFileSync(
+      indexFilePath,
+      indexExportedTypes.reduce((content, typeName) => (content += `export { ${typeName} } from './${typeName}'\n`), '')
+    )
+    console.log(`${indexFilePath} succesfully generated!`)
   }
 }
 

+ 83 - 43
content-directory-schemas/src/helpers/InputParser.ts

@@ -1,7 +1,7 @@
 import { AddClassSchema, Property } from '../../types/extrinsics/AddClassSchema'
 import { createType } from '@joystream/types'
-import { blake2AsHex } from '@polkadot/util-crypto'
 import {
+  InputEntityValuesMap,
   ClassId,
   OperationType,
   ParametrizedPropertyValue,
@@ -10,7 +10,9 @@ import {
   EntityId,
   Entity,
   ParametrizedClassPropertyValue,
+  InputPropertyValue,
 } from '@joystream/types/content-directory'
+import { blake2AsHex } from '@polkadot/util-crypto'
 import { isSingle, isReference } from './propertyType'
 import { ApiPromise } from '@polkadot/api'
 import { JoyBTreeSet } from '@joystream/types/common'
@@ -18,6 +20,10 @@ import { CreateClass } from '../../types/extrinsics/CreateClass'
 import { EntityBatch } from '../../types/EntityBatch'
 import { getInputs } from './inputs'
 
+type SimpleEntityValue = string | boolean | number | string[] | boolean[] | number[] | undefined
+// Input without "new" or "extising" keywords
+type SimpleEntityInput = { [K: string]: SimpleEntityValue }
+
 export class InputParser {
   private api: ApiPromise
   private classInputs: CreateClass[]
@@ -30,14 +36,21 @@ export class InputParser {
   private entityIdByUniqueQueryMap = new Map<string, number>()
   private entityByUniqueQueryCurrentIndex = 0
   private classIdByNameMap = new Map<string, number>()
-  private classMapInitialized = false
-  private entityIdByUniqueQueryMapInitialized = false
+
+  static createWithInitialInputs(api: ApiPromise): InputParser {
+    return new InputParser(
+      api,
+      getInputs<CreateClass>('classes').map(({ data }) => data),
+      getInputs<AddClassSchema>('schemas').map(({ data }) => data),
+      getInputs<EntityBatch>('entityBatches').map(({ data }) => data)
+    )
+  }
 
   static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]): InputParser {
     return new InputParser(
       api,
       [],
-      getInputs('schemas').map(({ data }) => data),
+      getInputs<AddClassSchema>('schemas').map(({ data }) => data),
       entityBatches
     )
   }
@@ -54,30 +67,28 @@ export class InputParser {
     this.batchInputs = batchInputs || []
   }
 
-  private async initializeClassMap() {
-    if (this.classMapInitialized) {
-      return
-    }
+  private async loadClassMap() {
+    this.classIdByNameMap = new Map<string, number>()
+
     const classEntries = await this.api.query.contentDirectory.classById.entries()
     classEntries.forEach(([key, aClass]) => {
       this.classIdByNameMap.set(aClass.name.toString(), (key.args[0] as ClassId).toNumber())
     })
-    this.classMapInitialized = true
   }
 
-  // Initialize entityIdByUniqueQueryMap with entities fetched from the chain
-  private async initializeEntityIdByUniqueQueryMap() {
-    if (this.entityIdByUniqueQueryMapInitialized) {
-      return
-    }
-
-    await this.initializeClassMap() // Initialize if not yet initialized
+  private async loadEntityIdByUniqueQueryMap() {
+    this.entityIdByUniqueQueryMap = new Map<string, number>()
 
     // Get entity entries
     const entityEntries: [EntityId, Entity][] = (
       await this.api.query.contentDirectory.entityById.entries()
     ).map(([storageKey, entity]) => [storageKey.args[0] as EntityId, entity])
 
+    // Since we use classMap directly we need to make sure it's loaded first
+    if (!this.classIdByNameMap.size) {
+      await this.loadClassMap()
+    }
+
     entityEntries.forEach(([entityId, entity]) => {
       const classId = entity.class_id.toNumber()
       const className = Array.from(this.classIdByNameMap.entries()).find(([, id]) => id === classId)?.[0]
@@ -111,8 +122,6 @@ export class InputParser {
         this.entityIdByUniqueQueryMap.set(hash, entityId.toNumber())
       })
     })
-
-    this.entityIdByUniqueQueryMapInitialized = true
   }
 
   private schemaByClassName(className: string) {
@@ -142,30 +151,40 @@ export class InputParser {
 
   // Seatch for entity by { [uniquePropName]: [uniquePropVal] } on chain
   async findEntityIdByUniqueQuery(uniquePropVal: Record<string, any>, className: string): Promise<number> {
-    await this.initializeEntityIdByUniqueQueryMap()
     const hash = this.getUniqueQueryHash(uniquePropVal, className)
-    const foundId = this.entityIdByUniqueQueryMap.get(hash)
+    let foundId = this.entityIdByUniqueQueryMap.get(hash)
     if (foundId === undefined) {
-      throw new Error(
-        `findEntityIdByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
-      )
+      // Try to re-load the map and find again
+      await this.loadEntityIdByUniqueQueryMap()
+      foundId = this.entityIdByUniqueQueryMap.get(hash)
+      if (foundId === undefined) {
+        // If still not found - throw
+        throw new Error(
+          `findEntityIdByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
+        )
+      }
     }
-
     return foundId
   }
 
-  private getClassIdByName(className: string): number {
-    const classId = this.classIdByNameMap.get(className)
+  async getClassIdByName(className: string): Promise<number> {
+    let classId = this.classIdByNameMap.get(className)
     if (classId === undefined) {
-      throw new Error(`Could not find class id by name: "${className}"!`)
+      // Try to re-load the map
+      await this.loadClassMap()
+      classId = this.classIdByNameMap.get(className)
+      if (classId === undefined) {
+        // If still not found - throw
+        throw new Error(`Could not find class id by name: "${className}"!`)
+      }
     }
     return classId
   }
 
-  private parsePropertyType(propertyType: Property['property_type']): PropertyType {
+  private async parsePropertyType(propertyType: Property['property_type']): Promise<PropertyType> {
     if (isSingle(propertyType) && isReference(propertyType.Single)) {
       const { className, sameOwner } = propertyType.Single.Reference
-      const classId = this.getClassIdByName(className)
+      const classId = await this.getClassIdByName(className)
       return createType('PropertyType', { Single: { Reference: [classId, sameOwner] } })
     }
     // Types other than reference are fully compatible
@@ -214,7 +233,9 @@ export class InputParser {
       let value = customHandler && (await customHandler(schemaProperty, propertyValue))
       if (value === undefined) {
         value = createType('ParametrizedPropertyValue', {
-          InputPropertyValue: this.parsePropertyType(schemaProperty.property_type).toInputPropertyValue(propertyValue),
+          InputPropertyValue: (await this.parsePropertyType(schemaProperty.property_type)).toInputPropertyValue(
+            propertyValue
+          ),
         })
       }
 
@@ -289,7 +310,7 @@ export class InputParser {
     } else {
       // Add operations (createEntity, AddSchemaSupportToEntity)
       const createEntityOperationIndex = this.createEntityOperations.length
-      const classId = this.getClassIdByName(schema.className)
+      const classId = await this.getClassIdByName(schema.className)
       this.createEntityOperations.push(createType('OperationType', { CreateEntity: { class_id: classId } }))
       this.addSchemaToEntityOprations.push(
         createType('OperationType', {
@@ -308,10 +329,7 @@ export class InputParser {
 
   private reset() {
     this.entityIndexByUniqueQueryMap = new Map<string, number>()
-    this.entityIdByUniqueQueryMapInitialized = false
-
     this.classIdByNameMap = new Map<string, number>()
-    this.classMapInitialized = false
 
     this.createEntityOperations = []
     this.addSchemaToEntityOprations = []
@@ -321,7 +339,6 @@ export class InputParser {
   }
 
   public async getEntityBatchOperations() {
-    await this.initializeClassMap()
     // First - create entityUniqueQueryMap to allow referencing any entity at any point
     this.batchInputs.forEach((batch) => {
       const entitySchema = this.schemaByClassName(batch.className)
@@ -346,7 +363,6 @@ export class InputParser {
     className: string,
     entityId: number
   ): Promise<OperationType[]> {
-    await this.initializeClassMap()
     const schema = this.schemaByClassName(className)
     await this.parseEntityInput(input, schema, entityId)
     const operations = [
@@ -360,13 +376,14 @@ export class InputParser {
   }
 
   public async parseAddClassSchemaExtrinsic(inputData: AddClassSchema) {
-    await this.initializeClassMap() // Initialize if not yet initialized
-    const classId = this.getClassIdByName(inputData.className)
-    const newProperties = inputData.newProperties.map((p) => ({
-      ...p,
-      // Parse different format for Reference (and potentially other propTypes in the future)
-      property_type: this.parsePropertyType(p.property_type).toJSON(),
-    }))
+    const classId = await this.getClassIdByName(inputData.className)
+    const newProperties = await Promise.all(
+      inputData.newProperties.map(async (p) => ({
+        ...p,
+        // Parse different format for Reference (and potentially other propTypes in the future)
+        property_type: (await this.parsePropertyType(p.property_type)).toJSON(),
+      }))
+    )
     return this.api.tx.contentDirectory.addClassSchema(
       classId,
       new (JoyBTreeSet(PropertyId))(this.api.registry, inputData.existingProperties),
@@ -391,4 +408,27 @@ export class InputParser {
   public getCreateClassExntrinsics() {
     return this.classInputs.map((data) => this.parseCreateClassExtrinsic(data))
   }
+
+  // Helper parser for "standalone" extrinsics like addSchemaSupportToEntity / updateEntityPropertyValues
+  public async parseToInputEntityValuesMap(
+    inputData: SimpleEntityInput,
+    className: string
+  ): Promise<InputEntityValuesMap> {
+    await this.parseEntityInput(inputData, this.schemaByClassName(className))
+    const inputPropValMap = new Map<PropertyId, InputPropertyValue>()
+
+    const [operation] = this.addSchemaToEntityOprations
+    operation
+      .asType('AddSchemaSupportToEntity')
+      .parametrized_property_values /* First we need to sort by propertyId, since otherwise there will be issues
+      when encoding the BTreeMap (similar to BTreeSet) */
+      .sort((a, b) => a.in_class_index.toNumber() - b.in_class_index.toNumber())
+      .map((pcpv) => {
+        inputPropValMap.set(pcpv.in_class_index, pcpv.value.asType('InputPropertyValue'))
+      })
+
+    this.reset()
+
+    return createType('InputEntityValuesMap', inputPropValMap)
+  }
 }

+ 32 - 8
content-directory-schemas/src/helpers/extrinsics.ts

@@ -2,6 +2,10 @@ import { Keyring } from '@polkadot/keyring'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { ApiPromise } from '@polkadot/api'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DispatchError } from '@polkadot/types/interfaces/system'
+import { TypeRegistry } from '@polkadot/types'
+
+// TODO: Move to @joystream/js soon
 
 export function getAlicePair() {
   const keyring = new Keyring({ type: 'sr25519' })
@@ -34,17 +38,37 @@ export class ExtrinsicsHelper {
       promises.push(
         new Promise((resolve, reject) => {
           tx.signAndSend(sender, { nonce }, (result) => {
+            let txError: string | null = null
             if (result.isError) {
-              reject(new Error(errorMessage))
+              txError = `Transaction failed with status: ${result.status.type}`
+              reject(new Error(`${errorMessage} - ${txError}`))
             }
+
             if (result.status.isInBlock) {
-              if (
-                result.events.some(({ event }) => event.section === 'system' && event.method === 'ExtrinsicSuccess')
-              ) {
-                resolve()
-              } else {
-                reject(new Error(errorMessage))
-              }
+              result.events
+                .filter(({ event }) => event.section === 'system')
+                .forEach(({ event }) => {
+                  if (event.method === 'ExtrinsicFailed') {
+                    const dispatchError = event.data[0] as DispatchError
+                    let errorMsg = dispatchError.toString()
+                    if (dispatchError.isModule) {
+                      try {
+                        // Need to assert that registry is of TypeRegistry type, since Registry intefrace
+                        // seems outdated and doesn't include DispatchErrorModule as possible argument for "findMetaError"
+                        const { name, documentation } = (this.api.registry as TypeRegistry).findMetaError(
+                          dispatchError.asModule
+                        )
+                        errorMsg = `${name} (${documentation})`
+                      } catch (e) {
+                        // This probably means we don't have this error in the metadata
+                        // In this case - continue (we'll just display dispatchError.toString())
+                      }
+                    }
+                    reject(new Error(`${errorMessage} - Extrinsic execution error: ${errorMsg}`))
+                  } else if (event.method === 'ExtrinsicSuccess') {
+                    resolve()
+                  }
+                })
             }
           })
         })

+ 19 - 0
content-directory-schemas/src/helpers/initialize.ts

@@ -0,0 +1,19 @@
+import { ApiPromise } from '@polkadot/api'
+import { InputParser } from './InputParser'
+import { ExtrinsicsHelper } from './extrinsics'
+import { KeyringPair } from '@polkadot/keyring/types'
+
+export default async function initializeContentDir(api: ApiPromise, leadKey: KeyringPair): Promise<void> {
+  const txHelper = new ExtrinsicsHelper(api)
+  const parser = InputParser.createWithInitialInputs(api)
+
+  // Initialize classes first in order to later be able to get classIdByNameMap
+  const createClassTxs = await parser.getCreateClassExntrinsics()
+  await txHelper.sendAndCheck(leadKey, createClassTxs, 'Classes initialization failed!')
+
+  // Initialize schemas and entities
+  const addSchemaTxs = await parser.getAddSchemaExtrinsics()
+  const entitiesTx = api.tx.contentDirectory.transaction({ Lead: null }, await parser.getEntityBatchOperations())
+  await txHelper.sendAndCheck(leadKey, addSchemaTxs, 'Schemas initialization failed!')
+  await txHelper.sendAndCheck(leadKey, [entitiesTx], 'Entities initialization failed!')
+}

+ 1 - 0
content-directory-schemas/src/index.ts

@@ -3,3 +3,4 @@ export { InputParser } from './helpers/InputParser'
 export { getInputs, getInputsLocation } from './helpers/inputs'
 export { isReference, isSingle } from './helpers/propertyType'
 export { getSchemasLocation } from './helpers/schemas'
+export { default as initializeContentDir } from './helpers/initialize'

+ 1 - 1
content-directory-schemas/tsconfig.json

@@ -23,5 +23,5 @@
       "@polkadot/api/augment": ["../types/augment-codec/augment-api.ts"]
     }
   },
-  "include": [ "src/**/*", "scripts/**/*", "typings/**/*" ]
+  "include": [ "src/**/*", "scripts/**/*", "typings/**/*", "examples/**/*" ]
 }

File diff suppressed because it is too large
+ 1 - 1
types/augment-codec/all.ts


File diff suppressed because it is too large
+ 0 - 0
types/augment-codec/augment-types.ts


+ 1 - 0
types/augment/all/defs.json

@@ -966,6 +966,7 @@
             "AddSchemaSupportToEntity": "AddSchemaSupportToEntityOperation"
         }
     },
+    "InputEntityValuesMap": "BTreeMap<PropertyId,InputPropertyValue>",
     "ClassPermissionsType": "Null",
     "ClassPropertyValue": "Null",
     "Operation": "Null",

+ 3 - 0
types/augment/all/types.ts

@@ -565,6 +565,9 @@ export interface InboundReferenceCounter extends Struct {
   readonly same_owner: u32;
 }
 
+/** @name InputEntityValuesMap */
+export interface InputEntityValuesMap extends BTreeMap<PropertyId, InputPropertyValue> {}
+
 /** @name InputPropertyValue */
 export interface InputPropertyValue extends Enum {
   readonly isSingle: boolean;

File diff suppressed because it is too large
+ 0 - 0
types/augment/augment-types.ts


+ 3 - 0
types/src/content-directory/index.ts

@@ -269,6 +269,8 @@ export class ClassPropertyValue extends Null {}
 export class Operation extends Null {}
 export class ReferenceConstraint extends Null {}
 
+export class InputEntityValuesMap extends BTreeMap.with(PropertyId, InputPropertyValue) {}
+
 export const contentDirectoryTypes = {
   Nonce,
   EntityId,
@@ -316,6 +318,7 @@ export const contentDirectoryTypes = {
   UpdatePropertyValuesOperation,
   AddSchemaSupportToEntityOperation,
   OperationType,
+  InputEntityValuesMap,
   // Versioned store relicts - to be removed:
   ClassPermissionsType,
   ClassPropertyValue,

+ 3 - 1
types/src/index.ts

@@ -15,7 +15,7 @@ import media from './media'
 import proposals from './proposals'
 import contentDirectory from './content-directory'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
-import { TypeRegistry, Text, UInt, Null, bool, Option, Vec, BTreeSet } from '@polkadot/types'
+import { TypeRegistry, Text, UInt, Null, bool, Option, Vec, BTreeSet, BTreeMap } from '@polkadot/types'
 import { ExtendedEnum } from './JoyEnum'
 import { ExtendedStruct } from './JoyStruct'
 import BN from 'bn.js'
@@ -87,6 +87,8 @@ type CreateInterface_NoOption<T extends Codec> =
       ? boolean
       : T extends Vec<infer S> | BTreeSet<infer S>
       ? CreateInterface<S>[]
+      : T extends BTreeMap<infer K, infer V>
+      ? Map<K, V>
       : any)
 
 // Wrapper for CreateInterface_NoOption that includes resolving an Option

Some files were not shown because too many files changed in this diff