diff --git a/graphql/documents/data/file.graphql b/graphql/documents/data/file.graphql index 108025ed5..da02f00d6 100644 --- a/graphql/documents/data/file.graphql +++ b/graphql/documents/data/file.graphql @@ -4,6 +4,7 @@ fragment FolderData on Folder { } fragment VideoFileData on VideoFile { + id path size duration @@ -20,6 +21,7 @@ fragment VideoFileData on VideoFile { } fragment ImageFileData on ImageFile { + id path size width @@ -31,6 +33,7 @@ fragment ImageFileData on ImageFile { } fragment GalleryFileData on GalleryFile { + id path size fingerprints { diff --git a/graphql/documents/mutations/file.graphql b/graphql/documents/mutations/file.graphql new file mode 100644 index 000000000..e63de9aeb --- /dev/null +++ b/graphql/documents/mutations/file.graphql @@ -0,0 +1,3 @@ +mutation DeleteFiles($ids: [ID!]!) { + deleteFiles(ids: $ids) +} \ No newline at end of file diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7229dce1d..15dd669e6 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -227,6 +227,8 @@ type Mutation { tagsDestroy(ids: [ID!]!): Boolean! tagsMerge(input: TagsMergeInput!): Tag + deleteFiles(ids: [ID!]!): Boolean! + # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! destroySavedFilter(input: DestroyFilterInput!): Boolean! diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index a129448ce..993f5e010 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -53,6 +53,8 @@ input GalleryUpdateInput { studio_id: ID tag_ids: [ID!] performer_ids: [ID!] + + primary_file_id: ID } input BulkGalleryUpdateInput { diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index 3e3af9cef..82aa1e443 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -44,6 +44,8 @@ input ImageUpdateInput { performer_ids: [ID!] tag_ids: [ID!] gallery_ids: [ID!] + + primary_file_id: ID } input BulkImageUpdateInput { diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 576e9b7f2..13c22cdc5 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -90,6 +90,8 @@ input SceneUpdateInput { """This should be a URL or a base64 encoded data URL""" cover_image: String stash_ids: [StashIDInput!] + + primary_file_id: ID } enum BulkUpdateIdMode { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go new file mode 100644 index 000000000..1a9fd66a0 --- /dev/null +++ b/internal/api/resolver_mutation_file.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" +) + +func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret bool, err error) { + fileIDs, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return false, err + } + + fileDeleter := file.NewDeleter() + destroyer := &file.ZipDestroyer{ + FileDestroyer: r.repository.File, + FolderDestroyer: r.repository.Folder, + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.File + + for _, fileIDInt := range fileIDs { + fileID := file.ID(fileIDInt) + f, err := qb.Find(ctx, fileID) + if err != nil { + return err + } + + path := f[0].Base().Path + + // ensure not a primary file + isPrimary, err := qb.IsPrimary(ctx, fileID) + if err != nil { + return fmt.Errorf("checking if file %s is primary: %w", path, err) + } + + if isPrimary { + return fmt.Errorf("cannot delete primary file %s", path) + } + + // destroy files in zip file + inZip, err := qb.FindByZipFileID(ctx, fileID) + if err != nil { + return fmt.Errorf("finding zip file contents for %s: %w", path, err) + } + + for _, ff := range inZip { + const deleteFileInZip = false + if err := file.Destroy(ctx, qb, ff, fileDeleter, deleteFileInZip); err != nil { + return fmt.Errorf("destroying file %s: %w", ff.Base().Path, err) + } + } + + const deleteFile = true + if err := destroyer.DestroyZip(ctx, f[0], fileDeleter, deleteFile); err != nil { + return fmt.Errorf("deleting file %s: %w", path, err) + } + } + + return nil + }); err != nil { + fileDeleter.Rollback() + return false, err + } + + // perform the post-commit actions + fileDeleter.Commit() + + return true, nil +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 2ae081013..05b609cb9 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -194,6 +194,32 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle } updatedGallery.Organized = translator.optionalBool(input.Organized, "organized") + if input.PrimaryFileID != nil { + primaryFileID, err := strconv.Atoi(*input.PrimaryFileID) + if err != nil { + return nil, fmt.Errorf("converting primary file id: %w", err) + } + + converted := file.ID(primaryFileID) + updatedGallery.PrimaryFileID = &converted + + if err := originalGallery.LoadFiles(ctx, r.repository.Gallery); err != nil { + return nil, err + } + + // ensure that new primary file is associated with scene + var f file.File + for _, ff := range originalGallery.Files.List() { + if ff.Base().ID == converted { + f = ff + } + } + + if f == nil { + return nil, fmt.Errorf("file with id %d not associated with gallery", converted) + } + } + if translator.hasField("performer_ids") { updatedGallery.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet) if err != nil { diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 8363be17c..a6d0577f7 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -110,6 +110,32 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp } updatedImage.Organized = translator.optionalBool(input.Organized, "organized") + if input.PrimaryFileID != nil { + primaryFileID, err := strconv.Atoi(*input.PrimaryFileID) + if err != nil { + return nil, fmt.Errorf("converting primary file id: %w", err) + } + + converted := file.ID(primaryFileID) + updatedImage.PrimaryFileID = &converted + + if err := i.LoadFiles(ctx, r.repository.Image); err != nil { + return nil, err + } + + // ensure that new primary file is associated with scene + var f *file.ImageFile + for _, ff := range i.Files.List() { + if ff.ID == converted { + f = ff + } + } + + if f == nil { + return nil, fmt.Errorf("file with id %d not associated with image", converted) + } + } + if translator.hasField("gallery_ids") { updatedImage.GalleryIDs, err = translateUpdateIDs(input.GalleryIds, models.RelationshipUpdateModeSet) if err != nil { diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index cb5ae820d..cc59c76c4 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -15,6 +15,7 @@ import ( "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice" + "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) @@ -96,6 +97,17 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp return nil, err } + qb := r.repository.Scene + + s, err := qb.Find(ctx, sceneID) + if err != nil { + return nil, err + } + + if s == nil { + return nil, fmt.Errorf("scene with id %d not found", sceneID) + } + var coverImageData []byte updatedScene := models.NewScenePartial() @@ -111,6 +123,46 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp updatedScene.Organized = translator.optionalBool(input.Organized, "organized") + if input.PrimaryFileID != nil { + primaryFileID, err := strconv.Atoi(*input.PrimaryFileID) + if err != nil { + return nil, fmt.Errorf("converting primary file id: %w", err) + } + + converted := file.ID(primaryFileID) + updatedScene.PrimaryFileID = &converted + + // if file hash has changed, we should migrate generated files + // after commit + if err := s.LoadFiles(ctx, r.repository.Scene); err != nil { + return nil, err + } + + // ensure that new primary file is associated with scene + var f *file.VideoFile + for _, ff := range s.Files.List() { + if ff.ID == converted { + f = ff + } + } + + if f == nil { + return nil, fmt.Errorf("file with id %d not associated with scene", converted) + } + + fileNamingAlgorithm := config.GetInstance().GetVideoFileNamingAlgorithm() + oldHash := scene.GetHash(s.Files.Primary(), fileNamingAlgorithm) + newHash := scene.GetHash(f, fileNamingAlgorithm) + + if oldHash != "" && newHash != "" && oldHash != newHash { + // perform migration after commit + txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + scene.MigrateHash(manager.GetInstance().Paths, oldHash, newHash) + return nil + }) + } + } + if translator.hasField("performer_ids") { updatedScene.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet) if err != nil { @@ -158,8 +210,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp // update the cover after updating the scene } - qb := r.repository.Scene - s, err := qb.UpdatePartial(ctx, sceneID, updatedScene) + s, err = qb.UpdatePartial(ctx, sceneID, updatedScene) if err != nil { return nil, err } diff --git a/internal/manager/repository.go b/internal/manager/repository.go index a95bcc937..3d64ee73e 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -38,6 +38,7 @@ type FileReaderWriter interface { file.Finder Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) GetCaptions(ctx context.Context, fileID file.ID) ([]*models.VideoCaption, error) + IsPrimary(ctx context.Context, fileID file.ID) (bool, error) } type FolderReaderWriter interface { diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index ed8e078dd..8ff461238 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -63,6 +63,7 @@ type GalleryUpdateInput struct { StudioID *string `json:"studio_id"` TagIds []string `json:"tag_ids"` PerformerIds []string `json:"performer_ids"` + PrimaryFileID *string `json:"primary_file_id"` } type GalleryDestroyInput struct { diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index f2dfbf28d..3249f1785 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -178,8 +178,9 @@ type SceneUpdateInput struct { Movies []*SceneMovieInput `json:"movies"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` - StashIds []StashID `json:"stash_ids"` + CoverImage *string `json:"cover_image"` + StashIds []StashID `json:"stash_ids"` + PrimaryFileID *string `json:"primary_file_id"` } // UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object. diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 448e9d3ff..4c72d299e 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -686,6 +686,40 @@ func (qb *FileStore) FindByZipFileID(ctx context.Context, zipFileID file.ID) ([] return qb.getMany(ctx, q) } +func (qb *FileStore) IsPrimary(ctx context.Context, fileID file.ID) (bool, error) { + joinTables := []exp.IdentifierExpression{ + scenesFilesJoinTable, + galleriesFilesJoinTable, + imagesFilesJoinTable, + } + + var sq *goqu.SelectDataset + + for _, t := range joinTables { + qq := dialect.From(t).Select(t.Col(fileIDColumn)).Where( + t.Col(fileIDColumn).Eq(fileID), + t.Col("primary").Eq(1), + ) + + if sq == nil { + sq = qq + } else { + sq = sq.Union(qq) + } + } + + q := dialect.Select(goqu.COUNT("*").As("count")).Prepared(true).From( + sq, + ) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return false, err + } + + return ret > 0, nil +} + func (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error { const and = "AND" const or = "OR" diff --git a/pkg/sqlite/file_test.go b/pkg/sqlite/file_test.go index 0c6deae56..2bcbe42e9 100644 --- a/pkg/sqlite/file_test.go +++ b/pkg/sqlite/file_test.go @@ -613,3 +613,52 @@ func TestFileStore_FindByFingerprint(t *testing.T) { }) } } + +func TestFileStore_IsPrimary(t *testing.T) { + tests := []struct { + name string + fileID file.ID + want bool + }{ + { + "scene file", + sceneFileIDs[sceneIdx1WithPerformer], + true, + }, + { + "image file", + imageFileIDs[imageIdx1WithGallery], + true, + }, + { + "gallery file", + galleryFileIDs[galleryIdx1WithImage], + true, + }, + { + "orphan file", + fileIDs[fileIdxZip], + false, + }, + { + "invalid file", + invalidFileID, + false, + }, + } + + qb := db.File + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + got, err := qb.IsPrimary(ctx, tt.fileID) + if err != nil { + t.Errorf("FileStore.IsPrimary() error = %v", err) + return + } + + assert.Equal(tt.want, got) + }) + } +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index fd69d6903..94456aba4 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -1,13 +1,22 @@ -import React, { useMemo } from "react"; -import { Accordion, Card } from "react-bootstrap"; +import React, { useMemo, useState } from "react"; +import { Accordion, Button, Card } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; import { TruncatedText } from "src/components/Shared"; +import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog"; import * as GQL from "src/core/generated-graphql"; +import { mutateGallerySetPrimaryFile } from "src/core/StashService"; +import { useToast } from "src/hooks"; import { TextUtils } from "src/utils"; import { TextField, URLField } from "src/utils/field"; interface IFileInfoPanelProps { folder?: Pick; file?: GQL.GalleryFileDataFragment; + primary?: boolean; + ofMany?: boolean; + onSetPrimaryFile?: () => void; + onDeleteFile?: () => void; + loading?: boolean; } const FileInfoPanel: React.FC = ( @@ -18,15 +27,43 @@ const FileInfoPanel: React.FC = ( const id = props.folder ? "folder" : "path"; return ( -
- - -
+
+
+ {props.primary && ( + <> +
+
+ +
+ + )} + + +
+ {props.ofMany && props.onSetPrimaryFile && !props.primary && ( +
+ + +
+ )} +
); }; interface IGalleryFileInfoPanelProps { @@ -36,6 +73,13 @@ interface IGalleryFileInfoPanelProps { export const GalleryFileInfoPanel: React.FC = ( props: IGalleryFileInfoPanelProps ) => { + const Toast = useToast(); + + const [loading, setLoading] = useState(false); + const [deletingFile, setDeletingFile] = useState< + GQL.GalleryFileDataFragment | undefined + >(); + const filesPanel = useMemo(() => { if (props.gallery.folder) { return ; @@ -49,23 +93,47 @@ export const GalleryFileInfoPanel: React.FC = ( return ; } + async function onSetPrimaryFile(fileID: string) { + try { + setLoading(true); + await mutateGallerySetPrimaryFile(props.gallery.id, fileID); + } catch (e) { + Toast.error(e); + } finally { + setLoading(false); + } + } + return ( - + + {deletingFile && ( + setDeletingFile(undefined)} + selected={[deletingFile]} + /> + )} {props.gallery.files.map((file, index) => ( - - + + - + - + onSetPrimaryFile(file.id)} + loading={loading} + onDeleteFile={() => setDeletingFile(file)} + /> ))} ); - }, [props.gallery]); + }, [props.gallery, loading, Toast, deletingFile]); return ( <> diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index c3b6bfded..a8377f12c 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -1,13 +1,21 @@ -import React from "react"; -import { Accordion, Card } from "react-bootstrap"; -import { FormattedNumber } from "react-intl"; +import React, { useState } from "react"; +import { Accordion, Button, Card } from "react-bootstrap"; +import { FormattedMessage, FormattedNumber } from "react-intl"; import { TruncatedText } from "src/components/Shared"; +import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog"; import * as GQL from "src/core/generated-graphql"; +import { mutateImageSetPrimaryFile } from "src/core/StashService"; +import { useToast } from "src/hooks"; import { TextUtils } from "src/utils"; import { TextField, URLField } from "src/utils/field"; interface IFileInfoPanelProps { file: GQL.ImageFileDataFragment; + primary?: boolean; + ofMany?: boolean; + onSetPrimaryFile?: () => void; + onDeleteFile?: () => void; + loading?: boolean; } const FileInfoPanel: React.FC = ( @@ -39,21 +47,49 @@ const FileInfoPanel: React.FC = ( const checksum = props.file.fingerprints.find((f) => f.type === "md5"); return ( -
- - - {renderFileSize()} - -
+
+
+ {props.primary && ( + <> +
+
+ +
+ + )} + + + {renderFileSize()} + +
+ {props.ofMany && props.onSetPrimaryFile && !props.primary && ( +
+ + +
+ )} +
); }; interface IImageFileInfoPanelProps { @@ -63,6 +99,13 @@ interface IImageFileInfoPanelProps { export const ImageFileInfoPanel: React.FC = ( props: IImageFileInfoPanelProps ) => { + const Toast = useToast(); + + const [loading, setLoading] = useState(false); + const [deletingFile, setDeletingFile] = useState< + GQL.ImageFileDataFragment | undefined + >(); + if (props.image.files.length === 0) { return <>; } @@ -71,16 +114,40 @@ export const ImageFileInfoPanel: React.FC = ( return ; } + async function onSetPrimaryFile(fileID: string) { + try { + setLoading(true); + await mutateImageSetPrimaryFile(props.image.id, fileID); + } catch (e) { + Toast.error(e); + } finally { + setLoading(false); + } + } + return ( - + + {deletingFile && ( + setDeletingFile(undefined)} + selected={[deletingFile]} + /> + )} {props.image.files.map((file, index) => ( - - + + - + - + onSetPrimaryFile(file.id)} + onDeleteFile={() => setDeletingFile(file)} + loading={loading} + /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index e70be6428..1d19e44fa 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -1,13 +1,21 @@ -import React, { useMemo } from "react"; -import { Accordion, Card } from "react-bootstrap"; +import React, { useMemo, useState } from "react"; +import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import { TruncatedText } from "src/components/Shared"; +import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog"; import * as GQL from "src/core/generated-graphql"; +import { mutateSceneSetPrimaryFile } from "src/core/StashService"; +import { useToast } from "src/hooks"; import { NavUtils, TextUtils, getStashboxBase } from "src/utils"; import { TextField, URLField } from "src/utils/field"; interface IFileInfoPanelProps { file: GQL.VideoFileDataFragment; + primary?: boolean; + ofMany?: boolean; + onSetPrimaryFile?: () => void; + onDeleteFile?: () => void; + loading?: boolean; } const FileInfoPanel: React.FC = ( @@ -40,62 +48,90 @@ const FileInfoPanel: React.FC = ( const checksum = props.file.fingerprints.find((f) => f.type === "md5"); return ( -
- - - - - {renderFileSize()} - - - - +
+ {props.primary && ( + <> +
+
+ +
+ + )} + + + - - - - - - -
+ {renderFileSize()} + + + + + + + + + + +
+ {props.ofMany && props.onSetPrimaryFile && !props.primary && ( +
+ + +
+ )} + ); }; @@ -106,6 +142,13 @@ interface ISceneFileInfoPanelProps { export const SceneFileInfoPanel: React.FC = ( props: ISceneFileInfoPanelProps ) => { + const Toast = useToast(); + + const [loading, setLoading] = useState(false); + const [deletingFile, setDeletingFile] = useState< + GQL.VideoFileDataFragment | undefined + >(); + function renderStashIDs() { if (!props.scene.stash_ids.length) { return; @@ -173,23 +216,47 @@ export const SceneFileInfoPanel: React.FC = ( return ; } + async function onSetPrimaryFile(fileID: string) { + try { + setLoading(true); + await mutateSceneSetPrimaryFile(props.scene.id, fileID); + } catch (e) { + Toast.error(e); + } finally { + setLoading(false); + } + } + return ( - + + {deletingFile && ( + setDeletingFile(undefined)} + selected={[deletingFile]} + /> + )} {props.scene.files.map((file, index) => ( - - + + - + - + onSetPrimaryFile(file.id)} + onDeleteFile={() => setDeletingFile(file)} + loading={loading} + /> ))} ); - }, [props.scene]); + }, [props.scene, loading, Toast, deletingFile]); return ( <> diff --git a/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx b/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx new file mode 100644 index 000000000..9e23c400f --- /dev/null +++ b/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx @@ -0,0 +1,113 @@ +import React, { useState } from "react"; +import { mutateDeleteFiles } from "src/core/StashService"; +import { Modal } from "src/components/Shared"; +import { useToast } from "src/hooks"; +import { FormattedMessage, useIntl } from "react-intl"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; + +interface IFile { + id: string; + path: string; +} + +interface IDeleteSceneDialogProps { + selected: IFile[]; + onClose: (confirmed: boolean) => void; +} + +export const DeleteFilesDialog: React.FC = ( + props: IDeleteSceneDialogProps +) => { + const intl = useIntl(); + const singularEntity = intl.formatMessage({ id: "file" }); + const pluralEntity = intl.formatMessage({ id: "files" }); + + const header = intl.formatMessage( + { id: "dialogs.delete_entity_title" }, + { count: props.selected.length, singularEntity, pluralEntity } + ); + const toastMessage = intl.formatMessage( + { id: "toast.delete_past_tense" }, + { count: props.selected.length, singularEntity, pluralEntity } + ); + const message = intl.formatMessage( + { id: "dialogs.delete_entity_simple_desc" }, + { count: props.selected.length, singularEntity, pluralEntity } + ); + + const Toast = useToast(); + + // Network state + const [isDeleting, setIsDeleting] = useState(false); + + async function onDelete() { + setIsDeleting(true); + try { + await mutateDeleteFiles(props.selected.map((f) => f.id)); + Toast.success({ content: toastMessage }); + props.onClose(true); + } catch (e) { + Toast.error(e); + props.onClose(false); + } + setIsDeleting(false); + } + + function renderDeleteFileAlert() { + const deletedFiles = props.selected.map((f) => f.path); + + return ( +
+

+ +

+
    + {deletedFiles.slice(0, 5).map((s) => ( +
  • {s}
  • + ))} + {deletedFiles.length > 5 && ( + + )} +
+
+ ); + } + + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isDeleting} + > +

{message}

+ {renderDeleteFileAlert()} +
+ ); +}; + +export default DeleteFilesDialog; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 9db748c66..1f42a3ffd 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -495,6 +495,18 @@ export const useSceneGenerateScreenshot = () => update: deleteCache([GQL.FindScenesDocument]), }); +export const mutateSceneSetPrimaryFile = (id: string, fileID: string) => + client.mutate({ + mutation: GQL.SceneUpdateDocument, + variables: { + input: { + id, + primary_file_id: fileID, + }, + }, + update: deleteCache(sceneMutationImpactedQueries), + }); + const imageMutationImpactedQueries = [ GQL.FindPerformerDocument, GQL.FindPerformersDocument, @@ -617,6 +629,18 @@ export const mutateImageResetO = (id: string) => }, }); +export const mutateImageSetPrimaryFile = (id: string, fileID: string) => + client.mutate({ + mutation: GQL.ImageUpdateDocument, + variables: { + input: { + id, + primary_file_id: fileID, + }, + }, + update: deleteCache(imageMutationImpactedQueries), + }); + const galleryMutationImpactedQueries = [ GQL.FindPerformerDocument, GQL.FindPerformersDocument, @@ -665,6 +689,18 @@ export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => update: deleteCache(galleryMutationImpactedQueries), }); +export const mutateGallerySetPrimaryFile = (id: string, fileID: string) => + client.mutate({ + mutation: GQL.GalleryUpdateDocument, + variables: { + input: { + id, + primary_file_id: fileID, + }, + }, + update: deleteCache(galleryMutationImpactedQueries), + }); + export const studioMutationImpactedQueries = [ GQL.FindStudiosDocument, GQL.FindSceneDocument, @@ -672,6 +708,24 @@ export const studioMutationImpactedQueries = [ GQL.AllStudiosForFilterDocument, ]; +export const mutateDeleteFiles = (ids: string[]) => + client.mutate({ + mutation: GQL.DeleteFilesDocument, + variables: { + ids, + }, + update: deleteCache([ + ...sceneMutationImpactedQueries, + ...imageMutationImpactedQueries, + ...galleryMutationImpactedQueries, + ]), + refetchQueries: getQueryNames([ + GQL.FindSceneDocument, + GQL.FindImageDocument, + GQL.FindGalleryDocument, + ]), + }); + export const useStudioCreate = () => GQL.useStudioCreateMutation({ refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]), diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 4735aa70b..97ab4e11c 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -829,3 +829,7 @@ select { .left-spacing { margin-left: 0.5em; } + +.primary-file { + font-weight: bold; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2c98a74e8..7833bfa8f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -54,6 +54,7 @@ "import": "Import…", "import_from_file": "Import from file", "logout": "Log out", + "make_primary": "Make Primary", "merge": "Merge", "merge_from": "Merge from", "merge_into": "Merge into", @@ -632,6 +633,7 @@ "delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:", "delete_confirm": "Are you sure you want to delete {entityName}?", "delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}", + "delete_entity_simple_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}", "delete_galleries_extra": "…plus any image files not attached to any other gallery.", "delete_gallery_files": "Delete gallery folder/zip file and any images not attached to any other gallery.", @@ -905,6 +907,7 @@ }, "performers": "Performers", "piercings": "Piercings", + "primary_file": "Primary file", "queue": "Queue", "random": "Random", "rating": "Rating",