]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video-channel.ts
Cleanup server fixme
[github/Chocobozzz/PeerTube.git] / server / models / video / video-channel.ts
index 64130310d4db7ed47844812820f5072cc1d4b8b7..4e98e7ba3f064333176f8a30c2e87c98365c7518 100644 (file)
-import * as Sequelize from 'sequelize'
-import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../../helpers'
-import { isVideoChannelUrlValid } from '../../helpers/custom-validators/video-channels'
-import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
-import { sendDeleteVideoChannel } from '../../lib/activitypub/send/send-delete'
-
-import { addMethodsToModel, getSort } from '../utils'
-import { VideoChannelAttributes, VideoChannelInstance, VideoChannelMethods } from './video-channel-interface'
-
-let VideoChannel: Sequelize.Model<VideoChannelInstance, VideoChannelAttributes>
-let toFormattedJSON: VideoChannelMethods.ToFormattedJSON
-let toActivityPubObject: VideoChannelMethods.ToActivityPubObject
-let isOwned: VideoChannelMethods.IsOwned
-let countByAccount: VideoChannelMethods.CountByAccount
-let listForApi: VideoChannelMethods.ListForApi
-let listByAccount: VideoChannelMethods.ListByAccount
-let loadByIdAndAccount: VideoChannelMethods.LoadByIdAndAccount
-let loadByUUID: VideoChannelMethods.LoadByUUID
-let loadAndPopulateAccount: VideoChannelMethods.LoadAndPopulateAccount
-let loadByUUIDAndPopulateAccount: VideoChannelMethods.LoadByUUIDAndPopulateAccount
-let loadByHostAndUUID: VideoChannelMethods.LoadByHostAndUUID
-let loadAndPopulateAccountAndVideos: VideoChannelMethods.LoadAndPopulateAccountAndVideos
-let loadByUrl: VideoChannelMethods.LoadByUrl
-let loadByUUIDOrUrl: VideoChannelMethods.LoadByUUIDOrUrl
-
-export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
-  VideoChannel = sequelize.define<VideoChannelInstance, VideoChannelAttributes>('VideoChannel',
+import {
+  AllowNull,
+  BeforeDestroy,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  DefaultScope,
+  ForeignKey,
+  HasMany,
+  Is,
+  Model,
+  Scopes,
+  Sequelize,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { ActivityPubActor } from '../../../shared/models/activitypub'
+import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
+import {
+  isVideoChannelDescriptionValid,
+  isVideoChannelNameValid,
+  isVideoChannelSupportValid
+} from '../../helpers/custom-validators/video-channels'
+import { sendDeleteActor } from '../../lib/activitypub/send'
+import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
+import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
+import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
+import { ServerModel } from '../server/server'
+import { FindOptions, ModelIndexesOptions, Op } from 'sequelize'
+import { AvatarModel } from '../avatar/avatar'
+import { VideoPlaylistModel } from './video-playlist'
+import * as Bluebird from 'bluebird'
+import {
+  MChannelAccountDefault,
+  MChannelActor,
+  MChannelActorAccountDefaultVideos,
+  MChannelAP,
+  MChannelFormattable,
+  MChannelSummaryFormattable
+} from '../../typings/models/video'
+
+export enum ScopeNames {
+  FOR_API = 'FOR_API',
+  WITH_ACCOUNT = 'WITH_ACCOUNT',
+  WITH_ACTOR = 'WITH_ACTOR',
+  WITH_VIDEOS = 'WITH_VIDEOS',
+  SUMMARY = 'SUMMARY'
+}
+
+type AvailableForListOptions = {
+  actorId: number
+}
+
+export type SummaryOptions = {
+  withAccount?: boolean // Default: false
+  withAccountBlockerIds?: number[]
+}
+
+@DefaultScope(() => ({
+  include: [
     {
-      uuid: {
-        type: DataTypes.UUID,
-        defaultValue: DataTypes.UUIDV4,
-        allowNull: false,
-        validate: {
-          isUUID: 4
-        }
-      },
-      name: {
-        type: DataTypes.STRING,
-        allowNull: false,
-        validate: {
-          nameValid: value => {
-            const res = isVideoChannelNameValid(value)
-            if (res === false) throw new Error('Video channel name is not valid.')
-          }
-        }
-      },
-      description: {
-        type: DataTypes.STRING,
-        allowNull: true,
-        validate: {
-          descriptionValid: value => {
-            const res = isVideoChannelDescriptionValid(value)
-            if (res === false) throw new Error('Video channel description is not valid.')
-          }
+      model: ActorModel,
+      required: true
+    }
+  ]
+}))
+@Scopes(() => ({
+  [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
+    const base: FindOptions = {
+      attributes: [ 'id', 'name', 'description', 'actorId' ],
+      include: [
+        {
+          attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
+          model: ActorModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'host' ],
+              model: ServerModel.unscoped(),
+              required: false
+            },
+            {
+              model: AvatarModel.unscoped(),
+              required: false
+            }
+          ]
         }
-      },
-      remote: {
-        type: DataTypes.BOOLEAN,
-        allowNull: false,
-        defaultValue: false
-      },
-      url: {
-        type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.URL.max),
-        allowNull: false,
-        validate: {
-          urlValid: value => {
-            const res = isVideoChannelUrlValid(value)
-            if (res === false) throw new Error('Video channel URL is not valid.')
+      ]
+    }
+
+    if (options.withAccount === true) {
+      base.include.push({
+        model: AccountModel.scope({
+          method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
+        }),
+        required: true
+      })
+    }
+
+    return base
+  },
+  [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
+    // Only list local channels OR channels that are on an instance followed by actorId
+    const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
+
+    return {
+      include: [
+        {
+          attributes: {
+            exclude: unusedActorAttributesForAPI
+          },
+          model: ActorModel,
+          where: {
+            [Op.or]: [
+              {
+                serverId: null
+              },
+              {
+                serverId: {
+                  [ Op.in ]: Sequelize.literal(inQueryInstanceFollow)
+                }
+              }
+            ]
           }
+        },
+        {
+          model: AccountModel,
+          required: true,
+          include: [
+            {
+              attributes: {
+                exclude: unusedActorAttributesForAPI
+              },
+              model: ActorModel, // Default scope includes avatar and server
+              required: true
+            }
+          ]
         }
+      ]
+    }
+  },
+  [ScopeNames.WITH_ACCOUNT]: {
+    include: [
+      {
+        model: AccountModel,
+        required: true
       }
+    ]
+  },
+  [ScopeNames.WITH_VIDEOS]: {
+    include: [
+      VideoModel
+    ]
+  },
+  [ScopeNames.WITH_ACTOR]: {
+    include: [
+      ActorModel
+    ]
+  }
+}))
+@Table({
+  tableName: 'videoChannel',
+  indexes: [
+    buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
+
+    {
+      fields: [ 'accountId' ]
     },
     {
-      indexes: [
-        {
-          fields: [ 'accountId' ]
-        }
-      ],
-      hooks: {
-        afterDestroy
-      }
+      fields: [ 'actorId' ]
     }
-  )
-
-  const classMethods = [
-    associate,
-
-    listForApi,
-    listByAccount,
-    loadByIdAndAccount,
-    loadAndPopulateAccount,
-    loadByUUIDAndPopulateAccount,
-    loadByUUID,
-    loadByHostAndUUID,
-    loadAndPopulateAccountAndVideos,
-    countByAccount,
-    loadByUrl,
-    loadByUUIDOrUrl
   ]
-  const instanceMethods = [
-    isOwned,
-    toFormattedJSON,
-    toActivityPubObject
-  ]
-  addMethodsToModel(VideoChannel, classMethods, instanceMethods)
+})
+export class VideoChannelModel extends Model<VideoChannelModel> {
 
-  return VideoChannel
-}
+  @AllowNull(false)
+  @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
+  @Column
+  name: string
 
-// ------------------------------ METHODS ------------------------------
+  @AllowNull(true)
+  @Default(null)
+  @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
+  description: string
 
-isOwned = function (this: VideoChannelInstance) {
-  return this.remote === false
-}
+  @AllowNull(true)
+  @Default(null)
+  @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
+  support: string
 
-toFormattedJSON = function (this: VideoChannelInstance) {
-  const json = {
-    id: this.id,
-    uuid: this.uuid,
-    name: this.name,
-    description: this.description,
-    isLocal: this.isOwned(),
-    createdAt: this.createdAt,
-    updatedAt: this.updatedAt
-  }
+  @CreatedAt
+  createdAt: Date
 
-  if (this.Account !== undefined) {
-    json['owner'] = {
-      name: this.Account.name,
-      uuid: this.Account.uuid
-    }
-  }
+  @UpdatedAt
+  updatedAt: Date
 
-  if (Array.isArray(this.Videos)) {
-    json['videos'] = this.Videos.map(v => v.toFormattedJSON())
-  }
-
-  return json
-}
-
-toActivityPubObject = function (this: VideoChannelInstance) {
-  const json = {
-    type: 'VideoChannel' as 'VideoChannel',
-    id: this.url,
-    uuid: this.uuid,
-    content: this.description,
-    name: this.name,
-    published: this.createdAt.toISOString(),
-    updated: this.updatedAt.toISOString()
-  }
+  @ForeignKey(() => ActorModel)
+  @Column
+  actorId: number
 
-  return json
-}
+  @BelongsTo(() => ActorModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  Actor: ActorModel
 
-// ------------------------------ STATICS ------------------------------
+  @ForeignKey(() => AccountModel)
+  @Column
+  accountId: number
 
-function associate (models) {
-  VideoChannel.belongsTo(models.Account, {
+  @BelongsTo(() => AccountModel, {
     foreignKey: {
-      name: 'accountId',
       allowNull: false
     },
-    onDelete: 'CASCADE'
+    hooks: true
   })
+  Account: AccountModel
 
-  VideoChannel.hasMany(models.Video, {
+  @HasMany(() => VideoModel, {
     foreignKey: {
       name: 'channelId',
       allowNull: false
     },
-    onDelete: 'CASCADE'
+    onDelete: 'CASCADE',
+    hooks: true
   })
-}
+  Videos: VideoModel[]
 
-function afterDestroy (videoChannel: VideoChannelInstance) {
-  if (videoChannel.isOwned()) {
-    return sendDeleteVideoChannel(videoChannel, undefined)
-  }
+  @HasMany(() => VideoPlaylistModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'CASCADE',
+    hooks: true
+  })
+  VideoPlaylists: VideoPlaylistModel[]
 
-  return undefined
-}
+  @BeforeDestroy
+  static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
+    if (!instance.Actor) {
+      instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
+    }
 
-countByAccount = function (accountId: number) {
-  const query = {
-    where: {
-      accountId
+    if (instance.Actor.isOwned()) {
+      return sendDeleteActor(instance.Actor, options.transaction)
     }
-  }
 
-  return VideoChannel.count(query)
-}
+    return undefined
+  }
 
-listForApi = function (start: number, count: number, sort: string) {
-  const query = {
-    offset: start,
-    limit: count,
-    order: [ getSort(sort) ],
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        required: true,
-        include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
+  static countByAccount (accountId: number) {
+    const query = {
+      where: {
+        accountId
       }
-    ]
+    }
+
+    return VideoChannelModel.count(query)
   }
 
-  return VideoChannel.findAndCountAll(query).then(({ rows, count }) => {
-    return { total: count, data: rows }
-  })
-}
+  static listForApi (actorId: number, start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: getSort(sort)
+    }
 
-listByAccount = function (accountId: number) {
-  const query = {
-    order: [ getSort('createdAt') ],
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        where: {
-          id: accountId
-        },
-        required: true,
-        include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
-      }
-    ]
+    const scopes = {
+      method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
+    }
+    return VideoChannelModel
+      .scope(scopes)
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
   }
 
-  return VideoChannel.findAndCountAll(query).then(({ rows, count }) => {
-    return { total: count, data: rows }
-  })
-}
+  static listLocalsForSitemap (sort: string): Bluebird<MChannelActor[]> {
+    const query = {
+      attributes: [ ],
+      offset: 0,
+      order: getSort(sort),
+      include: [
+        {
+          attributes: [ 'preferredUsername', 'serverId' ],
+          model: ActorModel.unscoped(),
+          where: {
+            serverId: null
+          }
+        }
+      ]
+    }
 
-loadByUUID = function (uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoChannelAttributes> = {
-    where: {
-      uuid
+    return VideoChannelModel
+      .unscoped()
+      .findAll(query)
+  }
+
+  static searchForApi (options: {
+    actorId: number
+    search: string
+    start: number
+    count: number
+    sort: string
+  }) {
+    const attributesInclude = []
+    const escapedSearch = VideoModel.sequelize.escape(options.search)
+    const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
+    attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
+
+    const query = {
+      attributes: {
+        include: attributesInclude
+      },
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort),
+      where: {
+        [Op.or]: [
+          Sequelize.literal(
+            'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
+          ),
+          Sequelize.literal(
+            'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
+          )
+        ]
+      }
     }
+
+    const scopes = {
+      method: [ ScopeNames.FOR_API, { actorId: options.actorId } as AvailableForListOptions ]
+    }
+    return VideoChannelModel
+      .scope(scopes)
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
   }
 
-  if (t !== undefined) query.transaction = t
+  static listByAccount (options: {
+    accountId: number,
+    start: number,
+    count: number,
+    sort: string
+  }) {
+    const query = {
+      offset: options.start,
+      limit: options.count,
+      order: getSort(options.sort),
+      include: [
+        {
+          model: AccountModel,
+          where: {
+            id: options.accountId
+          },
+          required: true
+        }
+      ]
+    }
 
-  return VideoChannel.findOne(query)
-}
+    return VideoChannelModel
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
+  }
 
-loadByUrl = function (url: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoChannelAttributes> = {
-    where: {
-      url
-    },
-    include: [ VideoChannel['sequelize'].models.Account ]
+  static loadByIdAndPopulateAccount (id: number): Bluebird<MChannelAccountDefault> {
+    return VideoChannelModel.unscoped()
+      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .findByPk(id)
   }
 
-  if (t !== undefined) query.transaction = t
+  static loadByIdAndAccount (id: number, accountId: number): Bluebird<MChannelAccountDefault> {
+    const query = {
+      where: {
+        id,
+        accountId
+      }
+    }
+
+    return VideoChannelModel.unscoped()
+      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .findOne(query)
+  }
 
-  return VideoChannel.findOne(query)
-}
+  static loadAndPopulateAccount (id: number): Bluebird<MChannelAccountDefault> {
+    return VideoChannelModel.unscoped()
+      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .findByPk(id)
+  }
 
-loadByUUIDOrUrl = function (uuid: string, url: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoChannelAttributes> = {
-    where: {
-      [Sequelize.Op.or]: [
-        { uuid },
-        { url }
+  static loadByUrlAndPopulateAccount (url: string): Bluebird<MChannelAccountDefault> {
+    const query = {
+      include: [
+        {
+          model: ActorModel,
+          required: true,
+          where: {
+            url
+          }
+        }
       ]
     }
+
+    return VideoChannelModel
+      .scope([ ScopeNames.WITH_ACCOUNT ])
+      .findOne(query)
   }
 
-  if (t !== undefined) query.transaction = t
+  static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
+    const [ name, host ] = nameWithHost.split('@')
 
-  return VideoChannel.findOne(query)
-}
+    if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
 
-loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
-  const query: Sequelize.FindOptions<VideoChannelAttributes> = {
-    where: {
-      uuid
-    },
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        include: [
-          {
-            model: VideoChannel['sequelize'].models.Server,
-            required: true,
-            where: {
-              host: fromHost
-            }
+    return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
+  }
+
+  static loadLocalByNameAndPopulateAccount (name: string): Bluebird<MChannelAccountDefault> {
+    const query = {
+      include: [
+        {
+          model: ActorModel,
+          required: true,
+          where: {
+            preferredUsername: name,
+            serverId: null
           }
-        ]
-      }
-    ]
+        }
+      ]
+    }
+
+    return VideoChannelModel.unscoped()
+      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .findOne(query)
   }
 
-  if (t !== undefined) query.transaction = t
+  static loadByNameAndHostAndPopulateAccount (name: string, host: string): Bluebird<MChannelAccountDefault> {
+    const query = {
+      include: [
+        {
+          model: ActorModel,
+          required: true,
+          where: {
+            preferredUsername: name
+          },
+          include: [
+            {
+              model: ServerModel,
+              required: true,
+              where: { host }
+            }
+          ]
+        }
+      ]
+    }
 
-  return VideoChannel.findOne(query)
-}
+    return VideoChannelModel.unscoped()
+      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
+      .findOne(query)
+  }
 
-loadByIdAndAccount = function (id: number, accountId: number) {
-  const options = {
-    where: {
-      id,
-      accountId
-    },
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
-      }
-    ]
+  static loadAndPopulateAccountAndVideos (id: number): Bluebird<MChannelActorAccountDefaultVideos> {
+    const options = {
+      include: [
+        VideoModel
+      ]
+    }
+
+    return VideoChannelModel.unscoped()
+      .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
+      .findByPk(id, options)
   }
 
-  return VideoChannel.findOne(options)
-}
+  toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
+    const actor = this.Actor.toFormattedSummaryJSON()
 
-loadAndPopulateAccount = function (id: number) {
-  const options = {
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
-      }
-    ]
+    return {
+      id: this.id,
+      name: actor.name,
+      displayName: this.getDisplayName(),
+      url: actor.url,
+      host: actor.host,
+      avatar: actor.avatar
+    }
   }
 
-  return VideoChannel.findById(id, options)
-}
+  toFormattedJSON (this: MChannelFormattable): VideoChannel {
+    const actor = this.Actor.toFormattedJSON()
+    const videoChannel = {
+      id: this.id,
+      displayName: this.getDisplayName(),
+      description: this.description,
+      support: this.support,
+      isLocal: this.Actor.isOwned(),
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt,
+      ownerAccount: undefined
+    }
 
-loadByUUIDAndPopulateAccount = function (uuid: string) {
-  const options = {
-    where: {
-      uuid
-    },
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
-      }
-    ]
+    if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
+
+    return Object.assign(actor, videoChannel)
   }
 
-  return VideoChannel.findOne(options)
-}
+  toActivityPubObject (this: MChannelAP): ActivityPubActor {
+    const obj = this.Actor.toActivityPubObject(this.name)
 
-loadAndPopulateAccountAndVideos = function (id: number) {
-  const options = {
-    include: [
-      {
-        model: VideoChannel['sequelize'].models.Account,
-        include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
-      },
-      VideoChannel['sequelize'].models.Video
-    ]
+    return Object.assign(obj, {
+      summary: this.description,
+      support: this.support,
+      attributedTo: [
+        {
+          type: 'Person' as 'Person',
+          id: this.Account.Actor.url
+        }
+      ]
+    })
   }
 
-  return VideoChannel.findById(id, options)
+  getDisplayName () {
+    return this.name
+  }
+
+  isOutdated () {
+    return this.Actor.isOutdated()
+  }
 }