Gallery list improvement (#622)

* Add grid view to galleries
* Show scene in gallery card
* Add is missing scene gallery filter
* Don't store galleries with no images
This commit is contained in:
WithoutPants
2020-06-21 21:43:57 +10:00
committed by GitHub
parent e50f1d01be
commit d3ababf0a1
26 changed files with 296 additions and 35 deletions

View File

@@ -8,4 +8,9 @@ fragment GalleryData on Gallery {
name name
path path
} }
scene {
id
title
path
}
} }

View File

@@ -1,5 +1,5 @@
query FindGalleries($filter: FindFilterType) { query FindGalleries($filter: FindFilterType, $gallery_filter: GalleryFilterType) {
findGalleries(filter: $filter) { findGalleries(gallery_filter: $gallery_filter, filter: $filter) {
count count
galleries { galleries {
...GalleryData ...GalleryData

View File

@@ -28,7 +28,7 @@ type Query {
findMovies(movie_filter: MovieFilterType, filter: FindFilterType): FindMoviesResultType! findMovies(movie_filter: MovieFilterType, filter: FindFilterType): FindMoviesResultType!
findGallery(id: ID!): Gallery findGallery(id: ID!): Gallery
findGalleries(filter: FindFilterType): FindGalleriesResultType! findGalleries(gallery_filter: GalleryFilterType, filter: FindFilterType): FindGalleriesResultType!
findTag(id: ID!): Tag findTag(id: ID!): Tag

View File

@@ -96,6 +96,11 @@ input StudioFilterType {
parents: MultiCriterionInput parents: MultiCriterionInput
} }
input GalleryFilterType {
"""Filter to only include galleries missing this property"""
is_missing: String
}
enum CriterionModifier { enum CriterionModifier {
"""=""" """="""
EQUALS, EQUALS,

View File

@@ -4,6 +4,7 @@ type Gallery {
checksum: String! checksum: String!
path: String! path: String!
title: String title: String
scene: Scene
"""The files in the gallery""" """The files in the gallery"""
files: [GalleryFilesType!]! # Resolver files: [GalleryFilesType!]! # Resolver

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@@ -13,3 +14,12 @@ func (r *galleryResolver) Files(ctx context.Context, obj *models.Gallery) ([]*mo
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
return obj.GetFiles(baseURL), nil return obj.GetFiles(baseURL), nil
} }
func (r *galleryResolver) Scene(ctx context.Context, obj *models.Gallery) (*models.Scene, error) {
if !obj.SceneID.Valid {
return nil, nil
}
qb := models.NewSceneQueryBuilder()
return qb.Find(int(obj.SceneID.Int64))
}

View File

@@ -2,8 +2,9 @@ package api
import ( import (
"context" "context"
"github.com/stashapp/stash/pkg/models"
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models"
) )
func (r *queryResolver) FindGallery(ctx context.Context, id string) (*models.Gallery, error) { func (r *queryResolver) FindGallery(ctx context.Context, id string) (*models.Gallery, error) {
@@ -12,9 +13,9 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (*models.Gal
return qb.Find(idInt) return qb.Find(idInt)
} }
func (r *queryResolver) FindGalleries(ctx context.Context, filter *models.FindFilterType) (*models.FindGalleriesResultType, error) { func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType) (*models.FindGalleriesResultType, error) {
qb := models.NewGalleryQueryBuilder() qb := models.NewGalleryQueryBuilder()
galleries, total := qb.Query(filter) galleries, total := qb.Query(galleryFilter, filter)
return &models.FindGalleriesResultType{ return &models.FindGalleriesResultType{
Count: total, Count: total,
Galleries: galleries, Galleries: galleries,

View File

@@ -26,7 +26,7 @@ func (t *CleanTask) Start(wg *sync.WaitGroup) {
t.deleteScene(t.Scene.ID) t.deleteScene(t.Scene.ID)
} }
if t.Gallery != nil && t.shouldClean(t.Gallery.Path) { if t.Gallery != nil && t.shouldCleanGallery(t.Gallery) {
t.deleteGallery(t.Gallery.ID) t.deleteGallery(t.Gallery.ID)
} }
} }
@@ -46,6 +46,19 @@ func (t *CleanTask) shouldClean(path string) bool {
return false return false
} }
func (t *CleanTask) shouldCleanGallery(g *models.Gallery) bool {
if t.shouldClean(g.Path) {
return true
}
if t.Gallery.CountFiles() == 0 {
logger.Infof("Gallery has 0 images. Cleaning: \"%s\"", g.Path)
return true
}
return false
}
func (t *CleanTask) deleteScene(sceneID int) { func (t *CleanTask) deleteScene(sceneID int) {
ctx := context.TODO() ctx := context.TODO()
qb := models.NewSceneQueryBuilder() qb := models.NewSceneQueryBuilder()

View File

@@ -64,7 +64,6 @@ func (t *ScanTask) scanGallery() {
_, err = qb.Update(*gallery, tx) _, err = qb.Update(*gallery, tx)
} }
} else { } else {
logger.Infof("%s doesn't exist. Creating new item...", t.FilePath)
currentTime := time.Now() currentTime := time.Now()
newGallery := models.Gallery{ newGallery := models.Gallery{
@@ -73,7 +72,12 @@ func (t *ScanTask) scanGallery() {
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
} }
_, err = qb.Create(newGallery, tx)
// don't create gallery if it has no images
if newGallery.CountFiles() > 0 {
logger.Infof("%s doesn't exist. Creating new item...", t.FilePath)
_, err = qb.Create(newGallery, tx)
}
} }
if err != nil { if err != nil {

View File

@@ -4,17 +4,18 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"database/sql" "database/sql"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
_ "golang.org/x/image/webp"
"image" "image"
"image/jpeg" "image/jpeg"
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
_ "golang.org/x/image/webp"
) )
type Gallery struct { type Gallery struct {
@@ -28,6 +29,16 @@ type Gallery struct {
const DefaultGthumbWidth int = 200 const DefaultGthumbWidth int = 200
func (g *Gallery) CountFiles() int {
filteredFiles, readCloser, err := g.listZipContents()
if err != nil {
return 0
}
defer readCloser.Close()
return len(filteredFiles)
}
func (g *Gallery) GetFiles(baseURL string) []*GalleryFilesType { func (g *Gallery) GetFiles(baseURL string) []*GalleryFilesType {
var galleryFiles []*GalleryFilesType var galleryFiles []*GalleryFilesType
filteredFiles, readCloser, err := g.listZipContents() filteredFiles, readCloser, err := g.listZipContents()

View File

@@ -9,6 +9,8 @@ import (
"github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/database"
) )
const galleryTable = "galleries"
type GalleryQueryBuilder struct{} type GalleryQueryBuilder struct{}
func NewGalleryQueryBuilder() GalleryQueryBuilder { func NewGalleryQueryBuilder() GalleryQueryBuilder {
@@ -112,25 +114,36 @@ func (qb *GalleryQueryBuilder) All() ([]*Gallery, error) {
return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil, nil) return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil, nil)
} }
func (qb *GalleryQueryBuilder) Query(findFilter *FindFilterType) ([]*Gallery, int) { func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int) {
if galleryFilter == nil {
galleryFilter = &GalleryFilterType{}
}
if findFilter == nil { if findFilter == nil {
findFilter = &FindFilterType{} findFilter = &FindFilterType{}
} }
var whereClauses []string query := queryBuilder{
var havingClauses []string tableName: galleryTable,
var args []interface{} }
body := selectDistinctIDs("galleries")
query.body = selectDistinctIDs("galleries")
if q := findFilter.Q; q != nil && *q != "" { if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"galleries.path", "galleries.checksum"} searchColumns := []string{"galleries.path", "galleries.checksum"}
clause, thisArgs := getSearchBinding(searchColumns, *q, false) clause, thisArgs := getSearchBinding(searchColumns, *q, false)
whereClauses = append(whereClauses, clause) query.addWhere(clause)
args = append(args, thisArgs...) query.addArg(thisArgs...)
} }
sortAndPagination := qb.getGallerySort(findFilter) + getPagination(findFilter) if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
idsResult, countResult := executeFindQuery("galleries", body, args, sortAndPagination, whereClauses, havingClauses) switch *isMissingFilter {
case "scene":
query.addWhere("galleries.scene_id IS NULL")
}
}
query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter)
idsResult, countResult := query.executeFind()
var galleries []*Gallery var galleries []*Gallery
for _, id := range idsResult { for _, id := range idsResult {

View File

@@ -98,6 +98,58 @@ func TestGalleryFindBySceneID(t *testing.T) {
assert.Nil(t, gallery) assert.Nil(t, gallery)
} }
func TestGalleryQueryQ(t *testing.T) {
const galleryIdx = 0
q := getGalleryStringValue(galleryIdx, pathField)
sqb := models.NewGalleryQueryBuilder()
galleryQueryQ(t, sqb, q, galleryIdx)
}
func galleryQueryQ(t *testing.T, qb models.GalleryQueryBuilder, q string, expectedGalleryIdx int) {
filter := models.FindFilterType{
Q: &q,
}
galleries, _ := qb.Query(nil, &filter)
assert.Len(t, galleries, 1)
gallery := galleries[0]
assert.Equal(t, galleryIDs[expectedGalleryIdx], gallery.ID)
// no Q should return all results
filter.Q = nil
galleries, _ = qb.Query(nil, &filter)
assert.Len(t, galleries, totalGalleries)
}
func TestGalleryQueryIsMissingScene(t *testing.T) {
qb := models.NewGalleryQueryBuilder()
isMissing := "scene"
galleryFilter := models.GalleryFilterType{
IsMissing: &isMissing,
}
q := getGalleryStringValue(galleryIdxWithScene, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
galleries, _ := qb.Query(&galleryFilter, &findFilter)
assert.Len(t, galleries, 0)
findFilter.Q = nil
galleries, _ = qb.Query(&galleryFilter, &findFilter)
// ensure non of the ids equal the one with gallery
for _, gallery := range galleries {
assert.NotEqual(t, galleryIDs[galleryIdxWithScene], gallery.ID)
}
}
// TODO ValidGalleriesForScenePath // TODO ValidGalleriesForScenePath
// TODO Count // TODO Count
// TODO All // TODO All

View File

@@ -24,7 +24,7 @@ const performersNameCase = 3
const performersNameNoCase = 2 const performersNameNoCase = 2
const moviesNameCase = 2 const moviesNameCase = 2
const moviesNameNoCase = 1 const moviesNameNoCase = 1
const totalGalleries = 1 const totalGalleries = 2
const tagsNameNoCase = 2 const tagsNameNoCase = 2
const tagsNameCase = 5 const tagsNameCase = 5
const studiosNameCase = 4 const studiosNameCase = 4

View File

@@ -6,6 +6,9 @@ const markup = `
* Add support for parent/child studios. * Add support for parent/child studios.
### 🎨 Improvements ### 🎨 Improvements
* Add gallery grid view.
* Add is-missing scene filter for gallery query.
* Don't import galleries with no images, and delete galleries with no images during clean.
* Show pagination at top as well as bottom of the page. * Show pagination at top as well as bottom of the page.
* Add split xpath post-processing action. * Add split xpath post-processing action.
* Improved the layout of the scene page. * Improved the layout of the scene page.

View File

@@ -0,0 +1,71 @@
import { Card, Button, ButtonGroup } from "react-bootstrap";
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { FormattedPlural } from "react-intl";
import { HoverPopover, Icon, TagLink } from "../Shared";
interface IProps {
gallery: GQL.GalleryDataFragment;
zoomIndex: number;
}
export const GalleryCard: React.FC<IProps> = ({ gallery, zoomIndex }) => {
function maybeRenderScenePopoverButton() {
if (!gallery.scene) return;
const popoverContent = (
<TagLink key={gallery.scene.id} scene={gallery.scene} />
);
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Link to={`/scenes/${gallery.scene.id}`}>
<Button className="minimal">
<Icon icon="play-circle" />
</Button>
</Link>
</HoverPopover>
);
}
function maybeRenderPopoverButtonGroup() {
if (gallery.scene) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenePopoverButton()}
</ButtonGroup>
</>
);
}
}
return (
<Card className={`gallery-card zoom-${zoomIndex}`}>
<Link to={`/galleries/${gallery.id}`} className="gallery-card-header">
{gallery.files.length > 0 ? (
<img
className="gallery-card-image"
alt={gallery.path}
src={`${gallery.files[0].path}?thumb=true`}
/>
) : undefined}
</Link>
<div className="card-section">
<h5 className="card-section-title">{gallery.path}</h5>
<span>
{gallery.files.length}&nbsp;
<FormattedPlural
value={gallery.files.length ?? 0}
one="image"
other="images"
/>
.
</span>
</div>
{maybeRenderPopoverButtonGroup()}
</Card>
);
};

View File

@@ -5,21 +5,35 @@ import { FindGalleriesQueryResult } from "src/core/generated-graphql";
import { useGalleriesList } from "src/hooks"; import { useGalleriesList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { GalleryCard } from "./GalleryCard";
export const GalleryList: React.FC = () => { export const GalleryList: React.FC = () => {
const listData = useGalleriesList({ const listData = useGalleriesList({
zoomable: true,
renderContent, renderContent,
}); });
function renderContent( function renderContent(
result: FindGalleriesQueryResult, result: FindGalleriesQueryResult,
filter: ListFilterModel filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
) { ) {
if (!result.data || !result.data.findGalleries) { if (!result.data || !result.data.findGalleries) {
return; return;
} }
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return <h1>TODO</h1>; return (
<div className="row justify-content-center">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
gallery={gallery}
zoomIndex={zoomIndex}
/>
))}
</div>
);
} }
if (filter.displayMode === DisplayMode.List) { if (filter.displayMode === DisplayMode.List) {
return ( return (

View File

@@ -5,3 +5,12 @@
} }
} }
/* stylelint-enable selector-class-pattern */ /* stylelint-enable selector-class-pattern */
.gallery-card {
padding: 0.5rem;
&-image {
object-fit: contain;
vertical-align: middle;
}
}

View File

@@ -203,7 +203,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
return ( return (
<> <>
<hr /> <hr />
<ButtonGroup className="scene-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()} {maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()} {maybeRenderMoviePopoverButton()}

View File

@@ -1,4 +1,4 @@
.scene-popovers { .card-popovers {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 10px; margin-bottom: 10px;

View File

@@ -6,6 +6,7 @@ import {
SceneMarkerDataFragment, SceneMarkerDataFragment,
TagDataFragment, TagDataFragment,
MovieDataFragment, MovieDataFragment,
SceneDataFragment,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils"; import { NavUtils, TextUtils } from "src/utils";
@@ -14,6 +15,7 @@ interface IProps {
performer?: Partial<PerformerDataFragment>; performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>; marker?: Partial<SceneMarkerDataFragment>;
movie?: Partial<MovieDataFragment>; movie?: Partial<MovieDataFragment>;
scene?: Partial<SceneDataFragment>;
className?: string; className?: string;
} }
@@ -34,6 +36,11 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp( title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(
props.marker.seconds || 0 props.marker.seconds || 0
)}`; )}`;
} else if (props.scene) {
link = `/scenes/${props.scene.id}`;
title = props.scene.title
? props.scene.title
: TextUtils.fileNameFromPath(props.scene.path ?? "");
} }
return ( return (
<Badge className={`tag-item ${props.className}`} variant="secondary"> <Badge className={`tag-item ${props.className}`} variant="secondary">

View File

@@ -33,6 +33,7 @@ export const useFindGalleries = (filter: ListFilterModel) =>
GQL.useFindGalleriesQuery({ GQL.useFindGalleriesQuery({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
gallery_filter: filter.makeGalleryFilter(),
}, },
}); });

View File

@@ -98,7 +98,8 @@ textarea.text-input {
.zoom-0 { .zoom-0 {
width: 240px; width: 240px;
.scene-card-video { .scene-card-video,
.gallery-card-image {
max-height: 180px; max-height: 180px;
} }
@@ -110,7 +111,8 @@ textarea.text-input {
.zoom-1 { .zoom-1 {
width: 320px; width: 320px;
.scene-card-video { .scene-card-video,
.gallery-card-image {
max-height: 240px; max-height: 240px;
} }
@@ -122,7 +124,8 @@ textarea.text-input {
.zoom-2 { .zoom-2 {
width: 480px; width: 480px;
.scene-card-video { .scene-card-video,
.gallery-card-image {
max-height: 360px; max-height: 360px;
} }
@@ -134,7 +137,8 @@ textarea.text-input {
.zoom-3 { .zoom-3 {
width: 640px; width: 640px;
.scene-card-video { .scene-card-video,
.gallery-card-image {
max-height: 480px; max-height: 480px;
} }
@@ -144,7 +148,8 @@ textarea.text-input {
} }
} }
.scene-card-video { .scene-card-video,
.gallery-card-image {
height: auto; height: auto;
width: 100%; width: 100%;
} }

View File

@@ -14,6 +14,7 @@ export type CriterionType =
| "hasMarkers" | "hasMarkers"
| "sceneIsMissing" | "sceneIsMissing"
| "performerIsMissing" | "performerIsMissing"
| "galleryIsMissing"
| "tags" | "tags"
| "sceneTags" | "sceneTags"
| "performers" | "performers"
@@ -58,6 +59,8 @@ export abstract class Criterion {
return "Is Missing"; return "Is Missing";
case "performerIsMissing": case "performerIsMissing":
return "Is Missing"; return "Is Missing";
case "galleryIsMissing":
return "Is Missing";
case "tags": case "tags":
return "Tags"; return "Tags";
case "sceneTags": case "sceneTags":

View File

@@ -53,3 +53,13 @@ export class PerformerIsMissingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("performerIsMissing"); public label: string = Criterion.getLabel("performerIsMissing");
public value: CriterionType = "performerIsMissing"; public value: CriterionType = "performerIsMissing";
} }
export class GalleryIsMissingCriterion extends IsMissingCriterion {
public type: CriterionType = "galleryIsMissing";
public options: string[] = ["scene"];
}
export class GalleryIsMissingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("galleryIsMissing");
public value: CriterionType = "galleryIsMissing";
}

View File

@@ -12,6 +12,7 @@ import { HasMarkersCriterion } from "./has-markers";
import { import {
PerformerIsMissingCriterion, PerformerIsMissingCriterion,
SceneIsMissingCriterion, SceneIsMissingCriterion,
GalleryIsMissingCriterion,
} from "./is-missing"; } from "./is-missing";
import { NoneCriterion } from "./none"; import { NoneCriterion } from "./none";
import { PerformersCriterion } from "./performers"; import { PerformersCriterion } from "./performers";
@@ -42,6 +43,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new SceneIsMissingCriterion(); return new SceneIsMissingCriterion();
case "performerIsMissing": case "performerIsMissing":
return new PerformerIsMissingCriterion(); return new PerformerIsMissingCriterion();
case "galleryIsMissing":
return new GalleryIsMissingCriterion();
case "tags": case "tags":
return new TagsCriterion("tags"); return new TagsCriterion("tags");
case "sceneTags": case "sceneTags":

View File

@@ -8,6 +8,7 @@ import {
SortDirectionEnum, SortDirectionEnum,
MovieFilterType, MovieFilterType,
StudioFilterType, StudioFilterType,
GalleryFilterType,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { stringToGender } from "src/core/StashService"; import { stringToGender } from "src/core/StashService";
import { import {
@@ -31,6 +32,7 @@ import {
IsMissingCriterion, IsMissingCriterion,
PerformerIsMissingCriterionOption, PerformerIsMissingCriterionOption,
SceneIsMissingCriterionOption, SceneIsMissingCriterionOption,
GalleryIsMissingCriterionOption,
} from "./criteria/is-missing"; } from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none"; import { NoneCriterionOption } from "./criteria/none";
import { import {
@@ -182,8 +184,11 @@ export class ListFilterModel {
case FilterMode.Galleries: case FilterMode.Galleries:
this.sortBy = "path"; this.sortBy = "path";
this.sortByOptions = ["path"]; this.sortByOptions = ["path"];
this.displayModeOptions = [DisplayMode.List]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
this.criterionOptions = [new NoneCriterionOption()]; this.criterionOptions = [
new NoneCriterionOption(),
new GalleryIsMissingCriterionOption(),
];
break; break;
case FilterMode.SceneMarkers: case FilterMode.SceneMarkers:
this.sortBy = "title"; this.sortBy = "title";
@@ -601,6 +606,21 @@ export class ListFilterModel {
// no default // no default
} }
}); });
return result;
}
public makeGalleryFilter(): GalleryFilterType {
const result: GalleryFilterType = {};
this.criteria.forEach((criterion) => {
switch (criterion.type) {
case "galleryIsMissing":
result.is_missing = (criterion as IsMissingCriterion).value;
break;
// no default
}
});
return result; return result;
} }
} }