mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
[Files Refactor] Object file management (#2790)
* Add Make Primary file function * Add delete file functionality
This commit is contained in:
@@ -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 {
|
||||
|
||||
3
graphql/documents/mutations/file.graphql
Normal file
3
graphql/documents/mutations/file.graphql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation DeleteFiles($ids: [ID!]!) {
|
||||
deleteFiles(ids: $ids)
|
||||
}
|
||||
@@ -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!
|
||||
|
||||
@@ -53,6 +53,8 @@ input GalleryUpdateInput {
|
||||
studio_id: ID
|
||||
tag_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
|
||||
primary_file_id: ID
|
||||
}
|
||||
|
||||
input BulkGalleryUpdateInput {
|
||||
|
||||
@@ -44,6 +44,8 @@ input ImageUpdateInput {
|
||||
performer_ids: [ID!]
|
||||
tag_ids: [ID!]
|
||||
gallery_ids: [ID!]
|
||||
|
||||
primary_file_id: ID
|
||||
}
|
||||
|
||||
input BulkImageUpdateInput {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
74
internal/api/resolver_mutation_file.go
Normal file
74
internal/api/resolver_mutation_file.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -180,6 +180,7 @@ type SceneUpdateInput struct {
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GQL.Folder, "id" | "path">;
|
||||
file?: GQL.GalleryFileDataFragment;
|
||||
primary?: boolean;
|
||||
ofMany?: boolean;
|
||||
onSetPrimaryFile?: () => void;
|
||||
onDeleteFile?: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
@@ -18,7 +27,16 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
const id = props.folder ? "folder" : "path";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<dl className="container gallery-file-info details-list">
|
||||
{props.primary && (
|
||||
<>
|
||||
<dt></dt>
|
||||
<dd className="primary-file">
|
||||
<FormattedMessage id="primary_file" />
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
||||
<URLField
|
||||
id={id}
|
||||
@@ -27,6 +45,25 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
truncate
|
||||
/>
|
||||
</dl>
|
||||
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
|
||||
<div>
|
||||
<Button
|
||||
className="edit-button"
|
||||
disabled={props.loading}
|
||||
onClick={props.onSetPrimaryFile}
|
||||
>
|
||||
<FormattedMessage id="actions.make_primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={props.loading}
|
||||
onClick={props.onDeleteFile}
|
||||
>
|
||||
<FormattedMessage id="actions.delete_file" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
interface IGalleryFileInfoPanelProps {
|
||||
@@ -36,6 +73,13 @@ interface IGalleryFileInfoPanelProps {
|
||||
export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
||||
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 <FileInfoPanel folder={props.gallery.folder} />;
|
||||
@@ -49,23 +93,47 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
||||
return <FileInfoPanel file={props.gallery.files[0]} />;
|
||||
}
|
||||
|
||||
async function onSetPrimaryFile(fileID: string) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await mutateGallerySetPrimaryFile(props.gallery.id, fileID);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion defaultActiveKey="0">
|
||||
<Accordion defaultActiveKey={props.gallery.files[0].id}>
|
||||
{deletingFile && (
|
||||
<DeleteFilesDialog
|
||||
onClose={() => setDeletingFile(undefined)}
|
||||
selected={[deletingFile]}
|
||||
/>
|
||||
)}
|
||||
{props.gallery.files.map((file, index) => (
|
||||
<Card key={index} className="gallery-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
|
||||
<Card key={file.id} className="gallery-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
||||
</Accordion.Toggle>
|
||||
<Accordion.Collapse eventKey={index.toString()}>
|
||||
<Accordion.Collapse eventKey={file.id}>
|
||||
<Card.Body>
|
||||
<FileInfoPanel file={file} />
|
||||
<FileInfoPanel
|
||||
file={file}
|
||||
primary={index === 0}
|
||||
ofMany
|
||||
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||
loading={loading}
|
||||
onDeleteFile={() => setDeletingFile(file)}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Accordion.Collapse>
|
||||
</Card>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}, [props.gallery]);
|
||||
}, [props.gallery, loading, Toast, deletingFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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<IFileInfoPanelProps> = (
|
||||
@@ -39,7 +47,16 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<dl className="container image-file-info details-list">
|
||||
{props.primary && (
|
||||
<>
|
||||
<dt></dt>
|
||||
<dd className="primary-file">
|
||||
<FormattedMessage id="primary_file" />
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
||||
<URLField
|
||||
id="path"
|
||||
@@ -54,6 +71,25 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
truncate
|
||||
/>
|
||||
</dl>
|
||||
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
|
||||
<div>
|
||||
<Button
|
||||
className="edit-button"
|
||||
disabled={props.loading}
|
||||
onClick={props.onSetPrimaryFile}
|
||||
>
|
||||
<FormattedMessage id="actions.make_primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={props.loading}
|
||||
onClick={props.onDeleteFile}
|
||||
>
|
||||
<FormattedMessage id="actions.delete_file" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
interface IImageFileInfoPanelProps {
|
||||
@@ -63,6 +99,13 @@ interface IImageFileInfoPanelProps {
|
||||
export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
|
||||
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<IImageFileInfoPanelProps> = (
|
||||
return <FileInfoPanel file={props.image.files[0]} />;
|
||||
}
|
||||
|
||||
async function onSetPrimaryFile(fileID: string) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await mutateImageSetPrimaryFile(props.image.id, fileID);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion defaultActiveKey="0">
|
||||
<Accordion defaultActiveKey={props.image.files[0].id}>
|
||||
{deletingFile && (
|
||||
<DeleteFilesDialog
|
||||
onClose={() => setDeletingFile(undefined)}
|
||||
selected={[deletingFile]}
|
||||
/>
|
||||
)}
|
||||
{props.image.files.map((file, index) => (
|
||||
<Card key={index} className="image-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
|
||||
<Card key={file.id} className="image-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
||||
</Accordion.Toggle>
|
||||
<Accordion.Collapse eventKey={index.toString()}>
|
||||
<Accordion.Collapse eventKey={file.id}>
|
||||
<Card.Body>
|
||||
<FileInfoPanel file={file} />
|
||||
<FileInfoPanel
|
||||
file={file}
|
||||
primary={index === 0}
|
||||
ofMany
|
||||
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||
onDeleteFile={() => setDeletingFile(file)}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Accordion.Collapse>
|
||||
</Card>
|
||||
|
||||
@@ -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<IFileInfoPanelProps> = (
|
||||
@@ -40,7 +48,16 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<dl className="container scene-file-info details-list">
|
||||
{props.primary && (
|
||||
<>
|
||||
<dt></dt>
|
||||
<dd className="primary-file">
|
||||
<FormattedMessage id="primary_file" />
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<TextField id="media_info.hash" value={oshash?.value} truncate />
|
||||
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
||||
<URLField
|
||||
@@ -96,6 +113,25 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
truncate
|
||||
/>
|
||||
</dl>
|
||||
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
|
||||
<div>
|
||||
<Button
|
||||
className="edit-button"
|
||||
disabled={props.loading}
|
||||
onClick={props.onSetPrimaryFile}
|
||||
>
|
||||
<FormattedMessage id="actions.make_primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={props.loading}
|
||||
onClick={props.onDeleteFile}
|
||||
>
|
||||
<FormattedMessage id="actions.delete_file" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -106,6 +142,13 @@ interface ISceneFileInfoPanelProps {
|
||||
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
||||
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<ISceneFileInfoPanelProps> = (
|
||||
return <FileInfoPanel file={props.scene.files[0]} />;
|
||||
}
|
||||
|
||||
async function onSetPrimaryFile(fileID: string) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await mutateSceneSetPrimaryFile(props.scene.id, fileID);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion defaultActiveKey="0">
|
||||
<Accordion defaultActiveKey={props.scene.files[0].id}>
|
||||
{deletingFile && (
|
||||
<DeleteFilesDialog
|
||||
onClose={() => setDeletingFile(undefined)}
|
||||
selected={[deletingFile]}
|
||||
/>
|
||||
)}
|
||||
{props.scene.files.map((file, index) => (
|
||||
<Card key={index} className="scene-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
|
||||
<Card key={file.id} className="scene-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
||||
</Accordion.Toggle>
|
||||
<Accordion.Collapse eventKey={index.toString()}>
|
||||
<Accordion.Collapse eventKey={file.id}>
|
||||
<Card.Body>
|
||||
<FileInfoPanel file={file} />
|
||||
<FileInfoPanel
|
||||
file={file}
|
||||
primary={index === 0}
|
||||
ofMany
|
||||
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||
onDeleteFile={() => setDeletingFile(file)}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Accordion.Collapse>
|
||||
</Card>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}, [props.scene]);
|
||||
}, [props.scene, loading, Toast, deletingFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
113
ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx
Normal file
113
ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx
Normal file
@@ -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<IDeleteSceneDialogProps> = (
|
||||
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 (
|
||||
<div className="delete-dialog alert alert-danger text-break">
|
||||
<p className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
values={{
|
||||
count: props.selected.length,
|
||||
singularEntity: intl.formatMessage({ id: "file" }),
|
||||
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||
}}
|
||||
id="dialogs.delete_alert"
|
||||
/>
|
||||
</p>
|
||||
<ul>
|
||||
{deletedFiles.slice(0, 5).map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
{deletedFiles.length > 5 && (
|
||||
<FormattedMessage
|
||||
values={{
|
||||
count: deletedFiles.length - 5,
|
||||
singularEntity: intl.formatMessage({ id: "file" }),
|
||||
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||
}}
|
||||
id="dialogs.delete_object_overflow"
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
icon={faTrashAlt}
|
||||
header={header}
|
||||
accept={{
|
||||
variant: "danger",
|
||||
onClick: onDelete,
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(false),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "secondary",
|
||||
}}
|
||||
isRunning={isDeleting}
|
||||
>
|
||||
<p>{message}</p>
|
||||
{renderDeleteFileAlert()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteFilesDialog;
|
||||
@@ -495,6 +495,18 @@ export const useSceneGenerateScreenshot = () =>
|
||||
update: deleteCache([GQL.FindScenesDocument]),
|
||||
});
|
||||
|
||||
export const mutateSceneSetPrimaryFile = (id: string, fileID: string) =>
|
||||
client.mutate<GQL.SceneUpdateMutation>({
|
||||
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<GQL.ImageUpdateMutation>({
|
||||
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<GQL.GalleryUpdateMutation>({
|
||||
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<GQL.DeleteFilesMutation>({
|
||||
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]),
|
||||
|
||||
@@ -829,3 +829,7 @@ select {
|
||||
.left-spacing {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.primary-file {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user