Add advanced search in client
authorChocobozzz <me@florianbigard.com>
Fri, 20 Jul 2018 16:31:49 +0000 (18:31 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 24 Jul 2018 12:04:05 +0000 (14:04 +0200)
21 files changed:
client/src/app/search/advanced-search.model.ts [new file with mode: 0644]
client/src/app/search/search-filters.component.html [new file with mode: 0644]
client/src/app/search/search-filters.component.scss [new file with mode: 0644]
client/src/app/search/search-filters.component.ts [new file with mode: 0644]
client/src/app/search/search.component.html
client/src/app/search/search.component.scss
client/src/app/search/search.component.ts
client/src/app/search/search.module.ts
client/src/app/search/search.service.ts
client/src/assets/images/search/filter.svg [new file with mode: 0644]
client/tsconfig.json
server/helpers/custom-validators/search.ts
server/helpers/express-utils.ts
server/initializers/database.ts
server/middlewares/validators/search.ts
server/models/video/video.ts
server/tests/api/search/search-videos.ts
server/tests/api/videos/video-nsfw.ts
shared/models/search/index.ts
shared/models/search/nsfw-query.model.ts [new file with mode: 0644]
shared/models/search/videos-search-query.model.ts

diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
new file mode 100644 (file)
index 0000000..a0f3331
--- /dev/null
@@ -0,0 +1,101 @@
+import { NSFWQuery } from '../../../../shared/models/search'
+
+export class AdvancedSearch {
+  startDate: string // ISO 8601
+  endDate: string // ISO 8601
+
+  nsfw: NSFWQuery
+
+  categoryOneOf: string
+
+  licenceOneOf: string
+
+  languageOneOf: string
+
+  tagsOneOf: string
+  tagsAllOf: string
+
+  durationMin: number // seconds
+  durationMax: number // seconds
+
+  constructor (options?: {
+    startDate?: string
+    endDate?: string
+    nsfw?: NSFWQuery
+    categoryOneOf?: string
+    licenceOneOf?: string
+    languageOneOf?: string
+    tagsOneOf?: string
+    tagsAllOf?: string
+    durationMin?: string
+    durationMax?: string
+  }) {
+    if (!options) return
+
+    this.startDate = options.startDate
+    this.endDate = options.endDate
+    this.nsfw = options.nsfw
+    this.categoryOneOf = options.categoryOneOf
+    this.licenceOneOf = options.licenceOneOf
+    this.languageOneOf = options.languageOneOf
+    this.tagsOneOf = options.tagsOneOf
+    this.tagsAllOf = options.tagsAllOf
+    this.durationMin = parseInt(options.durationMin, 10)
+    this.durationMax = parseInt(options.durationMax, 10)
+
+    if (isNaN(this.durationMin)) this.durationMin = undefined
+    if (isNaN(this.durationMax)) this.durationMax = undefined
+  }
+
+  containsValues () {
+    const obj = this.toUrlObject()
+    for (const k of Object.keys(obj)) {
+      if (obj[k] !== undefined) return true
+    }
+
+    return false
+  }
+
+  reset () {
+    this.startDate = undefined
+    this.endDate = undefined
+    this.nsfw = undefined
+    this.categoryOneOf = undefined
+    this.licenceOneOf = undefined
+    this.languageOneOf = undefined
+    this.tagsOneOf = undefined
+    this.tagsAllOf = undefined
+    this.durationMin = undefined
+    this.durationMax = undefined
+  }
+
+  toUrlObject () {
+    return {
+      startDate: this.startDate,
+      endDate: this.endDate,
+      nsfw: this.nsfw,
+      categoryOneOf: this.categoryOneOf,
+      licenceOneOf: this.licenceOneOf,
+      languageOneOf: this.languageOneOf,
+      tagsOneOf: this.tagsOneOf,
+      tagsAllOf: this.tagsAllOf,
+      durationMin: this.durationMin,
+      durationMax: this.durationMax
+    }
+  }
+
+  toAPIObject () {
+    return {
+      startDate: this.startDate,
+      endDate: this.endDate,
+      nsfw: this.nsfw,
+      categoryOneOf: this.categoryOneOf ? this.categoryOneOf.split(',') : undefined,
+      licenceOneOf: this.licenceOneOf ? this.licenceOneOf.split(',') : undefined,
+      languageOneOf: this.languageOneOf ? this.languageOneOf.split(',') : undefined,
+      tagsOneOf: this.tagsOneOf ? this.tagsOneOf.split(',') : undefined,
+      tagsAllOf: this.tagsAllOf ? this.tagsAllOf.split(',') : undefined,
+      durationMin: this.durationMin,
+      durationMax: this.durationMax
+    }
+  }
+}
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
new file mode 100644 (file)
index 0000000..f8b3675
--- /dev/null
@@ -0,0 +1,87 @@
+<form role="form" (ngSubmit)="formUpdated()">
+
+  <div class="row">
+    <div class="col-lg-4 col-md-6 col-xs-12">
+      <div class="form-group">
+        <div i18n class="radio-label">Published date</div>
+
+        <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
+          <input type="radio" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
+          <label [for]="date.id" class="radio">{{ date.label }}</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <div i18n class="radio-label">Duration</div>
+
+        <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
+          <input type="radio" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
+          <label [for]="duration.id" class="radio">{{ duration.label }}</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <div i18n class="radio-label">Display sensitive content</div>
+
+        <div class="peertube-radio-container">
+          <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
+          <label i18n for="sensitiveContentYes" class="radio">Yes</label>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
+          <label i18n for="sensitiveContentNo" class="radio">No</label>
+        </div>
+      </div>
+
+    </div>
+
+    <div class="col-lg-4 col-md-6 col-xs-12">
+      <div class="form-group">
+        <label i18n for="category">Category</label>
+        <div class="peertube-select-container">
+          <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf">
+            <option></option>
+            <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="licence">Licence</label>
+        <div class="peertube-select-container">
+          <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf">
+            <option></option>
+            <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="language">Language</label>
+        <div class="peertube-select-container">
+          <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf">
+            <option></option>
+            <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
+          </select>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-4 col-md-6 col-xs-12">
+      <div class="form-group">
+        <label i18n for="tagsAllOf">All of these tags</label>
+        <input type="text" name="tagsAllOf" id="tagsAllOf" [(ngModel)]="advancedSearch.tagsAllOf" />
+      </div>
+
+      <div class="form-group">
+        <label i18n for="tagsOneOf">One of these tags</label>
+        <input type="text" name="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf" />
+      </div>
+    </div>
+  </div>
+
+  <div class="submit-button">
+    <input type="submit" i18n-value value="Filter">
+  </div>
+</form>
\ No newline at end of file
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss
new file mode 100644 (file)
index 0000000..cfc48fb
--- /dev/null
@@ -0,0 +1,40 @@
+@import '_variables';
+@import '_mixins';
+
+form {
+  margin-top: 40px;
+}
+
+.radio-label {
+  font-size: 15px;
+  font-weight: $font-bold;
+}
+
+.peertube-radio-container {
+  @include peertube-radio-container;
+
+  display: inline-block;
+  margin-right: 30px;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(auto);
+}
+
+.form-group {
+  margin-bottom: 25px;
+}
+
+input[type=text] {
+  @include peertube-input-text(100%);
+  display: block;
+}
+
+input[type=submit] {
+  @include peertube-button-link;
+  @include orange-button;
+}
+
+.submit-button {
+  text-align: right;
+}
\ No newline at end of file
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
new file mode 100644 (file)
index 0000000..4219f99
--- /dev/null
@@ -0,0 +1,170 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { RedirectService, ServerService } from '@app/core'
+import { NotificationsService } from 'angular2-notifications'
+import { SearchService } from '@app/search/search.service'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { MetaService } from '@ngx-meta/core'
+import { AdvancedSearch } from '@app/search/advanced-search.model'
+import { VideoConstant } from '../../../../shared'
+
+@Component({
+  selector: 'my-search-filters',
+  styleUrls: [ './search-filters.component.scss' ],
+  templateUrl: './search-filters.component.html'
+})
+export class SearchFiltersComponent implements OnInit {
+  @Input() advancedSearch: AdvancedSearch = new AdvancedSearch()
+
+  @Output() filtered = new EventEmitter<AdvancedSearch>()
+
+  videoCategories: VideoConstant<string>[] = []
+  videoLicences: VideoConstant<string>[] = []
+  videoLanguages: VideoConstant<string>[] = []
+
+  publishedDateRanges: { id: string, label: string }[] = []
+  durationRanges: { id: string, label: string }[] = []
+
+  publishedDateRange: string
+  durationRange: string
+
+  constructor (
+    private i18n: I18n,
+    private route: ActivatedRoute,
+    private metaService: MetaService,
+    private redirectService: RedirectService,
+    private notificationsService: NotificationsService,
+    private searchService: SearchService,
+    private serverService: ServerService
+  ) {
+    this.publishedDateRanges = [
+      {
+        id: 'today',
+        label: this.i18n('Today')
+      },
+      {
+        id: 'last_7days',
+        label: this.i18n('Last 7 days')
+      },
+      {
+        id: 'last_30days',
+        label: this.i18n('Last 30 days')
+      },
+      {
+        id: 'last_365days',
+        label: this.i18n('Last 365 days')
+      }
+    ]
+
+    this.durationRanges = [
+      {
+        id: 'short',
+        label: this.i18n('Short (< 4 minutes)')
+      },
+      {
+        id: 'long',
+        label: this.i18n('Long (> 10 minutes)')
+      },
+      {
+        id: 'medium',
+        label: this.i18n('Medium (4-10 minutes)')
+      }
+    ]
+  }
+
+  ngOnInit () {
+    this.videoCategories = this.serverService.getVideoCategories()
+    this.videoLicences = this.serverService.getVideoLicences()
+    this.videoLanguages = this.serverService.getVideoLanguages()
+
+    this.loadFromDurationRange()
+    this.loadFromPublishedRange()
+  }
+
+  formUpdated () {
+    this.updateModelFromDurationRange()
+    this.updateModelFromPublishedRange()
+
+    this.filtered.emit(this.advancedSearch)
+  }
+
+  private loadFromDurationRange () {
+    if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) {
+      const fourMinutes = 60 * 4
+      const tenMinutes = 60 * 10
+
+      if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) {
+        this.durationRange = 'medium'
+      } else if (this.advancedSearch.durationMax === fourMinutes) {
+        this.durationRange = 'short'
+      } else if (this.advancedSearch.durationMin === tenMinutes) {
+        this.durationRange = 'long'
+      }
+    }
+  }
+
+  private loadFromPublishedRange () {
+    if (this.advancedSearch.startDate) {
+      const date = new Date(this.advancedSearch.startDate)
+      const now = new Date()
+
+      const diff = Math.abs(date.getTime() - now.getTime())
+
+      const dayMS = 1000 * 3600 * 24
+      const numberOfDays = diff / dayMS
+
+      if (numberOfDays >= 365) this.publishedDateRange = 'last_365days'
+      else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days'
+      else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days'
+      else if (numberOfDays >= 0) this.publishedDateRange = 'today'
+    }
+  }
+
+  private updateModelFromDurationRange () {
+    if (!this.durationRange) return
+
+    const fourMinutes = 60 * 4
+    const tenMinutes = 60 * 10
+
+    switch (this.durationRange) {
+      case 'short':
+        this.advancedSearch.durationMin = undefined
+        this.advancedSearch.durationMax = fourMinutes
+        break
+
+      case 'medium':
+        this.advancedSearch.durationMin = fourMinutes
+        this.advancedSearch.durationMax = tenMinutes
+        break
+
+      case 'long':
+        this.advancedSearch.durationMin = tenMinutes
+        this.advancedSearch.durationMax = undefined
+        break
+    }
+  }
+
+  private updateModelFromPublishedRange () {
+    if (!this.publishedDateRange) return
+
+    // today
+    const date = new Date()
+    date.setHours(0, 0, 0, 0)
+
+    switch (this.publishedDateRange) {
+      case 'last_7days':
+        date.setDate(date.getDate() - 7)
+        break
+
+      case 'last_30days':
+        date.setDate(date.getDate() - 30)
+        break
+
+      case 'last_365days':
+        date.setDate(date.getDate() - 365)
+        break
+    }
+
+    this.advancedSearch.startDate = date.toISOString()
+  }
+}
index b8c4d7dc59b5b69424b3c7b7338b0712383e9486..3a63dbcec394718f79b5e5c3233d4199b2b153ac 100644 (file)
@@ -1,10 +1,28 @@
-<div i18n *ngIf="pagination.totalItems === 0" class="no-result">
-  No results found
-</div>
-
 <div myInfiniteScroller [autoLoading]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
-  <div i18n *ngIf="pagination.totalItems" class="results-counter">
-    {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
+  <div i18n class="results-header">
+    <div class="first-line">
+      <div class="results-counter">
+        <ng-container *ngIf="pagination.totalItems">
+          {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
+        </ng-container>
+      </div>
+
+      <div
+        class="results-filter-button" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
+        [attr.aria-expanded]="isSearchFilterCollapsed" aria-controls="collapseBasic"
+      >
+        <span class="icon icon-filter"></span>
+        <ng-container i18n>Filters</ng-container>
+      </div>
+    </div>
+
+    <div class="results-filter" [collapse]="isSearchFilterCollapsed">
+      <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered($event)"></my-search-filters>
+    </div>
+  </div>
+
+  <div i18n *ngIf="pagination.totalItems === 0" class="no-result">
+    No results found
   </div>
 
   <div *ngFor="let video of videos" class="entry video">
index 06e3c9542863779c42db5fb46456f7c520740f35..f70d4bf8708b846961f088f59c2629c68ccb9b56 100644 (file)
@@ -2,7 +2,7 @@
 @import '_mixins';
 
 .no-result {
-  height: 70vh;
+  height: 40vh;
   display: flex;
   align-items: center;
   justify-content: center;
 }
 
 .search-result {
-  margin-left: 40px;
-  margin-top: 40px;
+  margin: 40px;
 
-  .results-counter {
-    font-size: 15px;
+  .results-header {
+    font-size: 16px;
     padding-bottom: 20px;
     margin-bottom: 30px;
     border-bottom: 1px solid #DADADA;
 
-    .search-value {
-      font-weight: $font-semibold;
+    .first-line {
+      display: flex;
+      flex-direction: row;
+
+      .results-counter {
+        flex-grow: 1;
+
+        .search-value {
+          font-weight: $font-semibold;
+        }
+      }
+
+      .results-filter-button {
+
+        .icon.icon-filter {
+          @include icon(20px);
+
+          position: relative;
+          top: -1px;
+          margin-right: 5px;
+          background-image: url('../../assets/images/search/filter.svg');
+        }
+      }
+    }
+
+    .results-filter {
+      // Animation when we show/hide the filters
+      transition: max-height 0.3s;
+      display: block !important;
+      overflow: hidden !important;
+      max-height: 0;
+
+      &.show {
+        max-height: 800px;
+      }
     }
   }
 
index be1cb3689d02c30db5579b6575eccb47f78e6bf2..09028fec5e0f75c8cdbc1835b0d367bdfe7c7081 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute } from '@angular/router'
+import { ActivatedRoute, Router } from '@angular/router'
 import { RedirectService } from '@app/core'
 import { NotificationsService } from 'angular2-notifications'
 import { Subscription } from 'rxjs'
@@ -8,6 +8,7 @@ import { ComponentPagination } from '@app/shared/rest/component-pagination.model
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { Video } from '../../../../shared'
 import { MetaService } from '@ngx-meta/core'
+import { AdvancedSearch } from '@app/search/advanced-search.model'
 
 @Component({
   selector: 'my-search',
@@ -21,6 +22,8 @@ export class SearchComponent implements OnInit, OnDestroy {
     itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc)
     totalItems: null
   }
+  advancedSearch: AdvancedSearch = new AdvancedSearch()
+  isSearchFilterCollapsed = true
 
   private subActivatedRoute: Subscription
   private currentSearch: string
@@ -28,6 +31,7 @@ export class SearchComponent implements OnInit, OnDestroy {
   constructor (
     private i18n: I18n,
     private route: ActivatedRoute,
+    private router: Router,
     private metaService: MetaService,
     private redirectService: RedirectService,
     private notificationsService: NotificationsService,
@@ -35,6 +39,9 @@ export class SearchComponent implements OnInit, OnDestroy {
   ) { }
 
   ngOnInit () {
+    this.advancedSearch = new AdvancedSearch(this.route.snapshot.queryParams)
+    if (this.advancedSearch.containsValues()) this.isSearchFilterCollapsed = false
+
     this.subActivatedRoute = this.route.queryParams.subscribe(
       queryParams => {
         const querySearch = queryParams['search']
@@ -42,6 +49,9 @@ export class SearchComponent implements OnInit, OnDestroy {
         if (!querySearch) return this.redirectService.redirectToHomepage()
         if (querySearch === this.currentSearch) return
 
+        // Search updated, reset filters
+        if (this.currentSearch) this.advancedSearch.reset()
+
         this.currentSearch = querySearch
         this.updateTitle()
 
@@ -57,7 +67,7 @@ export class SearchComponent implements OnInit, OnDestroy {
   }
 
   search () {
-    return this.searchService.searchVideos(this.currentSearch, this.pagination)
+    return this.searchService.searchVideos(this.currentSearch, this.pagination, this.advancedSearch)
       .subscribe(
         ({ videos, totalVideos }) => {
           this.videos = this.videos.concat(videos)
@@ -78,6 +88,14 @@ export class SearchComponent implements OnInit, OnDestroy {
     this.search()
   }
 
+  onFiltered () {
+    this.updateUrlFromAdvancedSearch()
+    // Hide the filters
+    this.isSearchFilterCollapsed = true
+
+    this.reload()
+  }
+
   private reload () {
     this.pagination.currentPage = 1
     this.pagination.totalItems = null
@@ -90,4 +108,11 @@ export class SearchComponent implements OnInit, OnDestroy {
   private updateTitle () {
     this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch)
   }
+
+  private updateUrlFromAdvancedSearch () {
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search: this.currentSearch })
+    })
+  }
 }
index c6ec74d209bad22296cc34db05fb8be60962fcc2..488046cf1fc2363e7bd48be5aad963f74335d3cb 100644 (file)
@@ -3,15 +3,20 @@ import { SharedModule } from '../shared'
 import { SearchComponent } from '@app/search/search.component'
 import { SearchService } from '@app/search/search.service'
 import { SearchRoutingModule } from '@app/search/search-routing.module'
+import { SearchFiltersComponent } from '@app/search/search-filters.component'
+import { CollapseModule } from 'ngx-bootstrap/collapse'
 
 @NgModule({
   imports: [
     SearchRoutingModule,
-    SharedModule
+    SharedModule,
+
+    CollapseModule.forRoot()
   ],
 
   declarations: [
-    SearchComponent
+    SearchComponent,
+    SearchFiltersComponent
   ],
 
   exports: [
index 02d5f5915c18032d12ffbdcfffe7df9c0181d942..c6106afd659f2c57fc7295eb8c7b8bd449b1d755 100644 (file)
@@ -8,6 +8,7 @@ import { RestExtractor, RestService } from '@app/shared'
 import { environment } from 'environments/environment'
 import { ResultList, Video } from '../../../../shared'
 import { Video as VideoServerModel } from '@app/shared/video/video.model'
+import { AdvancedSearch } from '@app/search/advanced-search.model'
 
 export type SearchResult = {
   videosResult: { totalVideos: number, videos: Video[] }
@@ -26,7 +27,8 @@ export class SearchService {
 
   searchVideos (
     search: string,
-    componentPagination: ComponentPagination
+    componentPagination: ComponentPagination,
+    advancedSearch: AdvancedSearch
   ): Observable<{ videos: Video[], totalVideos: number }> {
     const url = SearchService.BASE_SEARCH_URL + 'videos'
 
@@ -36,6 +38,19 @@ export class SearchService {
     params = this.restService.addRestGetParams(params, pagination)
     params = params.append('search', search)
 
+    const advancedSearchObject = advancedSearch.toAPIObject()
+
+    for (const name of Object.keys(advancedSearchObject)) {
+      const value = advancedSearchObject[name]
+      if (!value) continue
+
+      if (Array.isArray(value)) {
+        for (const v of value) params = params.append(name, v)
+      } else {
+        params = params.append(name, value)
+      }
+    }
+
     return this.authHttp
                .get<ResultList<VideoServerModel>>(url, { params })
                .pipe(
diff --git a/client/src/assets/images/search/filter.svg b/client/src/assets/images/search/filter.svg
new file mode 100644 (file)
index 0000000..218d6de
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+    <title>filter-ios</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Artboard-4" transform="translate(-796.000000, -291.000000)">
+            <g id="98" transform="translate(796.000000, 291.000000)">
+                <circle id="Oval-23" stroke="#333333" stroke-width="2" cx="12" cy="12" r="10"></circle>
+                <rect id="Rectangle-44" fill="#333333" x="6" y="8" width="12" height="2" rx="1"></rect>
+                <rect id="Rectangle-44" fill="#333333" x="8" y="12" width="8" height="2" rx="1"></rect>
+                <rect id="Rectangle-44" fill="#333333" x="10" y="16" width="4" height="2" rx="1"></rect>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
index 60c343867496496f0b30648622839a5b941f7c11..6ac5e6a9e46e261a05e32c4c36607e4bcb1bb99b 100644 (file)
       "stream": [ "./shims/noop" ],
       "crypto": [ "./shims/noop" ]
     }
-  }
+  },
+  "exclude": [
+    "../node_modules",
+    "node_modules",
+    "dist",
+    "../server"
+  ]
 }
index 2fde391602014aa33ee46726e7dddfa7c7c041fc..15b389a58e5969f36070c1cd59e859ec737c8d9f 100644 (file)
@@ -11,9 +11,14 @@ function isStringArray (value: any) {
   return isArray(value) && value.every(v => typeof v === 'string')
 }
 
+function isNSFWQueryValid (value: any) {
+  return value === 'true' || value === 'false' || value === 'both'
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   isNumberArray,
-  isStringArray
+  isStringArray,
+  isNSFWQueryValid
 }
index 5bf1e1a5f513dcce46121e884f31c2bdd6fc257d..76440348f32ccc38bcdd8adb625e34695530d1c7 100644 (file)
@@ -5,8 +5,10 @@ import { logger } from './logger'
 import { User } from '../../shared/models/users'
 import { generateRandomString } from './utils'
 
-function buildNSFWFilter (res: express.Response, paramNSFW?: boolean) {
-  if (paramNSFW === true || paramNSFW === false) return paramNSFW
+function buildNSFWFilter (res: express.Response, paramNSFW?: string) {
+  if (paramNSFW === 'true') return true
+  if (paramNSFW === 'false') return false
+  if (paramNSFW === 'both') return undefined
 
   if (res.locals.oauth) {
     const user: User = res.locals.oauth.token.User
index 045f41a96a3cc9665abb31702972e7fe730d4025..d95e34bce5363fc6fd8262a16d53e5644f9ef4f9 100644 (file)
@@ -86,8 +86,6 @@ async function initDatabaseModels (silent: boolean) {
   // Create custom PostgreSQL functions
   await createFunctions()
 
-  await sequelizeTypescript.query('CREATE EXTENSION IF NOT EXISTS pg_trgm', { raw: true })
-
   if (!silent) logger.info('Database %s is ready.', dbname)
 
   return
index fb2148eb3b268b38f57770482ad2cf85594e306a..a97f5b581d8b280e0bf895844b5512c55a8613b0 100644 (file)
@@ -2,7 +2,7 @@ import * as express from 'express'
 import { areValidationErrors } from './utils'
 import { logger } from '../../helpers/logger'
 import { query } from 'express-validator/check'
-import { isNumberArray, isStringArray } from '../../helpers/custom-validators/search'
+import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search'
 import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc'
 
 const searchValidator = [
@@ -46,8 +46,7 @@ const commonVideosFiltersValidator = [
     .custom(isStringArray).withMessage('Should have a valid all of tags array'),
   query('nsfw')
     .optional()
-    .toBoolean()
-    .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
+    .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking commons video filters query', { parameters: req.query })
index 68116e3096ebe556197a8469f3592697a3d8da57..b97dfd96f259f96feeefe2c820b42a2c0ec94a60 100644 (file)
@@ -851,7 +851,22 @@ export class VideoModel extends Model<VideoModel> {
       })
   }
 
-  static async searchAndPopulateAccountAndServer (options: VideosSearchQuery) {
+  static async searchAndPopulateAccountAndServer (options: {
+    search: string
+    start?: number
+    count?: number
+    sort?: string
+    startDate?: string // ISO 8601
+    endDate?: string // ISO 8601
+    nsfw?: boolean
+    categoryOneOf?: number[]
+    licenceOneOf?: number[]
+    languageOneOf?: string[]
+    tagsOneOf?: string[]
+    tagsAllOf?: string[]
+    durationMin?: number // seconds
+    durationMax?: number // seconds
+  }) {
     const whereAnd = [ ]
 
     if (options.startDate || options.endDate) {
index 7fc133b468b94e659920f7358d1ceff644b2e594..d2b0f03129e63f7ac18bdb679d668c7cece568e1 100644 (file)
@@ -216,7 +216,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ]
     }
 
@@ -235,7 +235,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ],
       sort: '-name'
     }
@@ -255,7 +255,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ],
       sort: '-name',
       start: 0,
@@ -274,7 +274,7 @@ describe('Test a videos search', function () {
       search: '1111 2222 3333',
       languageOneOf: [ 'pl', 'fr' ],
       durationMax: 4,
-      nsfw: false,
+      nsfw: 'false' as 'false',
       licenceOneOf: [ 1, 4 ],
       sort: '-name',
       start: 3,
index 38bdaa54e312c67140d3445c87c5836057d6b9f0..370e69d2a4f15eac3987efcdcfe755cbbf62360e 100644 (file)
@@ -220,6 +220,17 @@ describe('Test video NSFW policy', function () {
         expect(videos[ 0 ].name).to.equal('normal')
       }
     })
+
+    it('Should display both videos when the nsfw param === both', async function () {
+      for (const res of await getVideosFunctions(server.accessToken, { nsfw: 'both' })) {
+        expect(res.body.total).to.equal(2)
+
+        const videos = res.body.data
+        expect(videos).to.have.lengthOf(2)
+        expect(videos[ 0 ].name).to.equal('normal')
+        expect(videos[ 1 ].name).to.equal('nsfw')
+      }
+    })
   })
 
   after(async function () {
index 288ee41efff5aba5b6a3f214ecbf76209cba8a19..928846c39cf82b0d67b7813e8d314e9c3a285d82 100644 (file)
@@ -1 +1,2 @@
+export * from './nsfw-query.model'
 export * from './videos-search-query.model'
diff --git a/shared/models/search/nsfw-query.model.ts b/shared/models/search/nsfw-query.model.ts
new file mode 100644 (file)
index 0000000..6b6ad19
--- /dev/null
@@ -0,0 +1 @@
+export type NSFWQuery = 'true' | 'false' | 'both'
index bb23bd63677213100fbab352760f01c6b593be00..dc14b11775fe90da2e66fcb7f8b254a309c939b2 100644 (file)
@@ -1,3 +1,5 @@
+import { NSFWQuery } from './nsfw-query.model'
+
 export interface VideosSearchQuery {
   search: string
 
@@ -8,7 +10,7 @@ export interface VideosSearchQuery {
   startDate?: string // ISO 8601
   endDate?: string // ISO 8601
 
-  nsfw?: boolean
+  nsfw?: NSFWQuery
 
   categoryOneOf?: number[]