diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index da02af575..da096707a 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -317,6 +317,8 @@ type Mutation { addGalleryImages(input: GalleryAddInput!): Boolean! removeGalleryImages(input: GalleryRemoveInput!): Boolean! + setGalleryCover(input: GallerySetCoverInput!): Boolean! + resetGalleryCover(input: GalleryResetCoverInput!): Boolean! galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 3cf3216b9..fe8e3fab6 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -115,3 +115,12 @@ input GalleryRemoveInput { gallery_id: ID! image_ids: [ID!]! } + +input GallerySetCoverInput { + gallery_id: ID! + cover_image_id: ID! +} + +input GalleryResetCoverInput { + gallery_id: ID! +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 2df6f1b77..5d5cd4b37 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -478,6 +478,61 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler return true, nil } +func (r *mutationResolver) SetGalleryCover(ctx context.Context, input GallerySetCoverInput) (bool, error) { + galleryID, err := strconv.Atoi(input.GalleryID) + if err != nil { + return false, fmt.Errorf("converting gallery id: %w", err) + } + + coverImageID, err := strconv.Atoi(input.CoverImageID) + if err != nil { + return false, fmt.Errorf("converting cover image id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Gallery + gallery, err := qb.Find(ctx, galleryID) + if err != nil { + return err + } + + if gallery == nil { + return fmt.Errorf("gallery with id %d not found", galleryID) + } + + return r.galleryService.SetCover(ctx, gallery, coverImageID) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) ResetGalleryCover(ctx context.Context, input GalleryResetCoverInput) (bool, error) { + galleryID, err := strconv.Atoi(input.GalleryID) + if err != nil { + return false, fmt.Errorf("converting gallery id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Gallery + gallery, err := qb.Find(ctx, galleryID) + if err != nil { + return err + } + + if gallery == nil { + return fmt.Errorf("gallery with id %d not found", galleryID) + } + + return r.galleryService.ResetCover(ctx, gallery) + }); err != nil { + return false, err + } + + return true, nil +} + func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.GalleryChapter.Find(ctx, id) diff --git a/internal/manager/repository.go b/internal/manager/repository.go index adfbfcb63..766f8039f 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -24,6 +24,9 @@ type GalleryService interface { AddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error RemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error + SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error + ResetCover(ctx context.Context, g *models.Gallery) error + Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index d66da197c..4f8b1f198 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -52,6 +52,22 @@ func (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove return s.Updated(ctx, g.ID) } +func (s *Service) SetCover(ctx context.Context, g *models.Gallery, coverImageID int) error { + if err := s.Repository.SetCover(ctx, g.ID, coverImageID); err != nil { + return fmt.Errorf("failed to set cover: %w", err) + } + + return s.Updated(ctx, g.ID) +} + +func (s *Service) ResetCover(ctx context.Context, g *models.Gallery) error { + if err := s.Repository.ResetCover(ctx, g.ID); err != nil { + return fmt.Errorf("failed to reset cover: %w", err) + } + + return s.Updated(ctx, g.ID) +} + func AddPerformer(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, performerID int) error { galleryPartial := models.NewGalleryPartial() galleryPartial.PerformerIDs = &models.UpdateIDs{ diff --git a/pkg/image/query.go b/pkg/image/query.go index a5c9a1732..9e82cd09a 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -107,6 +107,13 @@ func FindGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, } func findGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) { + img, err := r.CoverByGalleryID(ctx, galleryID) + if err != nil { + return nil, err + } else if img != nil { + return img, nil + } + // try to find cover.jpg in the gallery perPage := 1 sortBy := "path" diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index bd1fbf0d2..f07f8a7d9 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -628,6 +628,34 @@ func (_m *GalleryReaderWriter) RemoveImages(ctx context.Context, galleryID int, return r0 } +// ResetCover provides a mock function with given fields: ctx, galleryID +func (_m *GalleryReaderWriter) ResetCover(ctx context.Context, galleryID int) error { + ret := _m.Called(ctx, galleryID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, galleryID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetCover provides a mock function with given fields: ctx, galleryID, coverImageID +func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, coverImageID int) error { + ret := _m.Called(ctx, galleryID, coverImageID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, int) error); ok { + r0 = rf(ctx, galleryID, coverImageID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGallery func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error { ret := _m.Called(ctx, updatedGallery) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 4cdd0d8ee..04fd66900 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -114,6 +114,29 @@ func (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int return r0, r1 } +// CoverByGalleryID provides a mock function with given fields: ctx, galleryId +func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error) { + ret := _m.Called(ctx, galleryId) + + var r0 *models.Image + if rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok { + r0 = rf(ctx, galleryId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Image) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, galleryId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newImage, fileIDs func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error { ret := _m.Called(ctx, newImage, fileIDs) diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index 45ad5beb7..0cfb9964f 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -83,6 +83,8 @@ type GalleryWriter interface { AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error + SetCover(ctx context.Context, galleryID int, coverImageID int) error + ResetCover(ctx context.Context, galleryID int) error } // GalleryReaderWriter provides all gallery methods. diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index fd58ed762..274374b41 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -25,6 +25,7 @@ type ImageFinder interface { type ImageQueryer interface { Query(ctx context.Context, options ImageQueryOptions) (*ImageQueryResult, error) QueryCount(ctx context.Context, imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error) + CoverByGalleryID(ctx context.Context, galleryId int) (*Image, error) } // ImageCounter provides methods to count images. diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 348631384..ee5e5399b 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 65 +var appSchemaVersion uint = 66 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index b7f7552c2..5473b9c36 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -890,6 +890,14 @@ func (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageID return galleryRepository.images.replace(ctx, galleryID, imageIDs) } +func (qb *GalleryStore) SetCover(ctx context.Context, galleryID int, coverImageID int) error { + return imageGalleriesTableMgr.setCover(ctx, coverImageID, galleryID) +} + +func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error { + return imageGalleriesTableMgr.resetCover(ctx, galleryID) +} + func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.scenes.getIDs(ctx, id) } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index fcc10aece..be1edb687 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -2973,6 +2973,34 @@ func TestGalleryQueryHasChapters(t *testing.T) { }) } +func TestGallerySetAndResetCover(t *testing.T) { + withTxn(func(ctx context.Context) error { + sqb := db.Gallery + + imagePath2 := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx2WithGallery)) + + result, err := db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + assert.Nil(t, result) + + err = sqb.SetCover(ctx, galleryIDs[galleryIdxWithTwoImages], imageIDs[imageIdx2WithGallery]) + assert.Nil(t, err) + + result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + assert.Equal(t, result.Path, imagePath2) + + err = sqb.ResetCover(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + + result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + assert.Nil(t, result) + + return nil + }) +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 3d1882a1e..8248427a8 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -480,6 +480,42 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo return ret, nil } +// Returns the custom cover for the gallery, if one has been set. +func (qb *ImageStore) CoverByGalleryID(ctx context.Context, galleryID int) (*models.Image, error) { + table := qb.table() + + sq := dialect.From(table). + InnerJoin( + galleriesImagesJoinTable, + goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), + ). + Select(table.Col(idColumn)). + Where(goqu.And( + galleriesImagesJoinTable.Col("gallery_id").Eq(galleryID), + galleriesImagesJoinTable.Col("cover").Eq(true), + )) + + q := qb.selectDataset().Prepared(true).Where( + table.Col(idColumn).Eq( + sq, + ), + ) + + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, fmt.Errorf("getting cover for gallery %d: %w", galleryID, err) + } + + switch { + case len(ret) > 1: + return nil, fmt.Errorf("internal error: multiple covers returned for gallery %d", galleryID) + case len(ret) == 1: + return ret[0], nil + default: + return nil, nil + } +} + func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { fileIDs, err := imageRepository.files.get(ctx, id) if err != nil { diff --git a/pkg/sqlite/migrations/66_gallery_cover.up.sql b/pkg/sqlite/migrations/66_gallery_cover.up.sql new file mode 100644 index 000000000..7be80293a --- /dev/null +++ b/pkg/sqlite/migrations/66_gallery_cover.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `galleries_images` ADD COLUMN `cover` BOOLEAN NOT NULL DEFAULT 0; +CREATE UNIQUE INDEX `index_galleries_images_gallery_id_cover` on `galleries_images` (`gallery_id`, `cover`) WHERE `cover` = 1; \ No newline at end of file diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 240918f3e..2ae3bf945 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -710,6 +710,45 @@ func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models. return nil } +type imageGalleriesTable struct { + joinTable +} + +func (t *imageGalleriesTable) setCover(ctx context.Context, id int, galleryID int) error { + if err := t.resetCover(ctx, galleryID); err != nil { + return err + } + + table := t.table.table + + q := dialect.Update(table).Prepared(true).Set(goqu.Record{ + "cover": true, + }).Where(t.idColumn.Eq(id), table.Col(galleryIDColumn).Eq(galleryID)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("setting cover flag in %s: %w", t.table.table.GetTable(), err) + } + + return nil +} + +func (t *imageGalleriesTable) resetCover(ctx context.Context, galleryID int) error { + table := t.table.table + + q := dialect.Update(table).Prepared(true).Set(goqu.Record{ + "cover": false, + }).Where( + table.Col(galleryIDColumn).Eq(galleryID), + table.Col("cover").Eq(true), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("unsetting cover flags in %s: %w", t.table.table.GetTable(), err) + } + + return nil +} + type relatedFilesTable struct { table } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7f93c8148..365abe812 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -57,12 +57,14 @@ var ( }, } - imageGalleriesTableMgr = &joinTable{ - table: table{ - table: galleriesImagesJoinTable, - idColumn: galleriesImagesJoinTable.Col(imageIDColumn), + imageGalleriesTableMgr = &imageGalleriesTable{ + joinTable: joinTable{ + table: table{ + table: galleriesImagesJoinTable, + idColumn: galleriesImagesJoinTable.Col(imageIDColumn), + }, + fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn), }, - fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn), } imagesTagsTableMgr = &joinTable{ diff --git a/ui/v2.5/graphql/mutations/gallery.graphql b/ui/v2.5/graphql/mutations/gallery.graphql index 9f9fd1e0b..d76f98a4f 100644 --- a/ui/v2.5/graphql/mutations/gallery.graphql +++ b/ui/v2.5/graphql/mutations/gallery.graphql @@ -43,3 +43,13 @@ mutation AddGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) { mutation RemoveGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) { removeGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids }) } + +mutation SetGalleryCover($gallery_id: ID!, $cover_image_id: ID!) { + setGalleryCover( + input: { gallery_id: $gallery_id, cover_image_id: $cover_image_id } + ) +} + +mutation ResetGalleryCover($gallery_id: ID!) { + resetGalleryCover(input: { gallery_id: $gallery_id }) +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index ae73f2a1d..7dc2a17b6 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -11,6 +11,7 @@ import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { mutateMetadataScan, + mutateResetGalleryCover, useFindGallery, useGalleryUpdate, } from "src/core/StashService"; @@ -138,6 +139,25 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { ); } + async function onResetCover() { + try { + await mutateResetGalleryCover({ + gallery_id: gallery.id!, + }); + + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + async function onClickChapter(imageindex: number) { showLightbox(imageindex - 1); } @@ -176,7 +196,6 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { {path ? ( onRescan()} > @@ -184,7 +203,12 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { ) : undefined} onResetCover()} + > + + + setIsDeleteAlertOpen(true)} > diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index ea28ffabf..1ca450c5e 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -3,8 +3,14 @@ import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ImageList } from "src/components/Images/ImageList"; -import { mutateRemoveGalleryImages } from "src/core/StashService"; -import { showWhenSelected } from "src/components/List/ItemList"; +import { + mutateRemoveGalleryImages, + mutateSetGalleryCover, +} from "src/core/StashService"; +import { + showWhenSelected, + showWhenSingleSelection, +} from "src/components/List/ItemList"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faMinus } from "@fortawesome/free-solid-svg-icons"; @@ -58,6 +64,35 @@ export const GalleryImagesPanel: React.FC = ({ return filter; } + async function setCover( + result: GQL.FindImagesQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const coverImageID = selectedIds.values().next(); + if (coverImageID.done) { + // operation should only be displayed when exactly one image is selected + return; + } + try { + await mutateSetGalleryCover({ + gallery_id: gallery.id!, + cover_image_id: coverImageID.value, + }); + + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + async function removeImages( result: GQL.FindImagesQueryResult, filter: ListFilterModel, @@ -85,6 +120,11 @@ export const GalleryImagesPanel: React.FC = ({ } const otherOperations = [ + { + text: intl.formatMessage({ id: "actions.set_cover" }), + onClick: setCover, + isDisplayed: showWhenSingleSelection, + }, { text: intl.formatMessage({ id: "actions.remove_from_gallery" }), onClick: removeImages, diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 5be226c33..dbcdde1d3 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -335,3 +335,11 @@ export const showWhenSelected = ( ) => { return selectedIds.size > 0; }; + +export const showWhenSingleSelection = ( + result: T, + filter: ListFilterModel, + selectedIds: Set +) => { + return selectedIds.size == 1; +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 1685e92dc..e0ff90aaf 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1526,6 +1526,34 @@ export const mutateAddGalleryImages = (input: GQL.GalleryAddInput) => }, }); +export const mutateSetGalleryCover = (input: GQL.GallerySetCoverInput) => + client.mutate({ + mutation: GQL.SetGalleryCoverDocument, + variables: input, + update(cache, result) { + if (!result.data?.setGalleryCover) return; + + cache.evict({ + id: cache.identify({ __typename: "Gallery", id: input.gallery_id }), + fieldName: "cover", + }); + }, + }); + +export const mutateResetGalleryCover = (input: GQL.GalleryResetCoverInput) => + client.mutate({ + mutation: GQL.ResetGalleryCoverDocument, + variables: input, + update(cache, result) { + if (!result.data?.resetGalleryCover) return; + + cache.evict({ + id: cache.identify({ __typename: "Gallery", id: input.gallery_id }), + fieldName: "cover", + }); + }, + }); + export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => client.mutate({ mutation: GQL.RemoveGalleryImagesDocument, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 59ea1459e..b57214013 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -94,6 +94,7 @@ "remove_from_gallery": "Remove from Gallery", "rename_gen_files": "Rename generated files", "rescan": "Rescan", + "reset_cover": "Restore Default Cover", "reshuffle": "Reshuffle", "running": "running", "save": "Save", @@ -114,6 +115,7 @@ "selective_scan": "Selective Scan", "set_as_default": "Set as default", "set_back_image": "Back image…", + "set_cover": "Set as Cover", "set_front_image": "Front image…", "set_image": "Set image…", "show": "Show",