[iOS] Media Item Menu | Refresh Metadata & Delete Item (#1310)
Some checks failed
Build 🔨 / Build 🔨 (Swiftfin tvOS) (push) Has been cancelled
Build 🔨 / Build 🔨 (Swiftfin) (push) Has been cancelled

* [iOS] Creation of the enableItemEditor & enableItemDeletion settings. Creation of the ItemEditorView. Creation of Refresh/Deletion Logic and Buttons. Wrap buttons in permissions.

* You can make delete permissions without edit (admin) permissions. So, flip this so you can get to the edit page but editing is disabled if you're not an admin. The Delete option requires that the delete toggle is enabled and the user has permissions.

* Move deletion from the editView to the ItemView

* Delete from PagingLibraryView on Deletion

* Only enable delete if the user can delete something. Check deletion permission on Item level. Only allow editing for admins.

* Review Changes: ec33a6b63c

* wip

* Update RefreshMetadataButton.swift

* Update Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>

* Update Shared/Coordinators/ItemEditorCoordinator.swift

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>

* Reviews minus a learn more button

* LearnMoreAttempt v1

* Learn more v2 - Much better

* Learn More v3

* Learn More comments cleanup

* Learn More: https://github.com/jellyfin/Swiftfin/pull/1310#discussion_r1843149572

* clean up

* Remove Replace since it's already covered. Localize.

* clean up

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2024-11-16 23:59:41 -07:00 committed by GitHub
parent 128381a439
commit 687cfa6b5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 894 additions and 0 deletions

View File

@ -28,6 +28,8 @@ final class ItemCoordinator: NavigationCoordinatable {
@Route(.modal)
var itemOverview = makeItemOverview
@Route(.modal)
var itemEditor = makeItemEditor
@Route(.modal)
var mediaSourceInfo = makeMediaSourceInfo
@Route(.modal)
var downloadTask = makeDownloadTask
@ -78,6 +80,10 @@ final class ItemCoordinator: NavigationCoordinatable {
}
#if os(iOS)
func makeItemEditor(item: BaseItemDto) -> NavigationViewCoordinator<ItemEditorCoordinator> {
NavigationViewCoordinator(ItemEditorCoordinator(item: item))
}
func makeDownloadTask(downloadTask: DownloadTask) -> NavigationViewCoordinator<DownloadTaskCoordinator> {
NavigationViewCoordinator(DownloadTaskCoordinator(downloadTask: downloadTask))
}

View File

@ -0,0 +1,30 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import Stinsen
import SwiftUI
final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
let stack = NavigationStack(initial: \ItemEditorCoordinator.start)
@Root
var start = makeStart
private let item: BaseItemDto
init(item: BaseItemDto) {
self.item = item
}
@ViewBuilder
func makeStart() -> some View {
ItemEditorView(item: item)
}
}

View File

@ -81,6 +81,7 @@ extension Notifications.Key {
static let didFailMigration = NotificationKey("didFailMigration")
static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange")
static let didDeleteItem = NotificationKey("didDeleteItem")
static let didConnectToServer = NotificationKey("didConnectToServer")
static let didDeleteServer = NotificationKey("didDeleteServer")

View File

@ -52,6 +52,10 @@ internal enum L10n {
internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres")
/// All Media
internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media")
/// Allow media item deletion
internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion")
/// Allow media item editing
internal static let allowItemEditing = L10n.tr("Localizable", "allowItemEditing", fallback: "Allow media item editing")
/// Select Server View - Select All Servers
internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers")
/// View and manage all registered users on the server, including their permissions and activity status.
@ -304,6 +308,8 @@ internal enum L10n {
}
/// Are you sure you wish to delete this device? This session will be logged out.
internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.")
/// Are you sure you want to delete this item? This action cannot be undone.
internal static let deleteItemConfirmationMessage = L10n.tr("Localizable", "deleteItemConfirmationMessage", fallback: "Are you sure you want to delete this item? This action cannot be undone.")
/// Delete Selected Devices
internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices")
/// Delete Selected Users
@ -410,6 +416,10 @@ internal enum L10n {
internal static let filterResults = L10n.tr("Localizable", "filterResults", fallback: "Filter Results")
/// Filters
internal static let filters = L10n.tr("Localizable", "filters", fallback: "Filters")
/// Find Missing
internal static let findMissing = L10n.tr("Localizable", "findMissing", fallback: "Find Missing")
/// Find missing metadata and images.
internal static let findMissingDescription = L10n.tr("Localizable", "findMissingDescription", fallback: "Find missing metadata and images.")
/// Transcode FPS
internal static func fpsWithString(_ p1: Any) -> String {
return L10n.tr("Localizable", "fpsWithString", String(describing: p1), fallback: "%@fps")
@ -448,6 +458,8 @@ internal enum L10n {
internal static func itemAtItem(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "itemAtItem", String(describing: p1), String(describing: p2), fallback: "%1$@ at %2$@")
}
/// You do not have permission to delete this item.
internal static let itemDeletionPermissionFailure = L10n.tr("Localizable", "itemDeletionPermissionFailure", fallback: "You do not have permission to delete this item.")
/// SessionPlaybackMethod Remaining Time
internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@")
@ -490,6 +502,8 @@ internal enum L10n {
internal static func latestWithString(_ p1: Any) -> String {
return L10n.tr("Localizable", "latestWithString", String(describing: p1), fallback: "Latest %@")
}
/// Learn more...
internal static let learnMoreEllipsis = L10n.tr("Localizable", "learnMoreEllipsis", fallback: "Learn more...")
/// Left
internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left")
/// Letter Picker
@ -526,6 +540,8 @@ internal enum L10n {
internal static let media = L10n.tr("Localizable", "media", fallback: "Media")
/// Menu Buttons
internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons")
/// Metadata
internal static let metadata = L10n.tr("Localizable", "metadata", fallback: "Metadata")
/// The play method (e.g., Direct Play, Transcoding)
internal static let method = L10n.tr("Localizable", "method", fallback: "Method")
/// Minutes
@ -732,6 +748,8 @@ internal enum L10n {
internal static let refFramesNotSupported = L10n.tr("Localizable", "refFramesNotSupported", fallback: "The number of reference frames is not supported")
/// Refresh
internal static let refresh = L10n.tr("Localizable", "refresh", fallback: "Refresh")
/// Refresh Metadata
internal static let refreshMetadata = L10n.tr("Localizable", "refreshMetadata", fallback: "Refresh Metadata")
/// Regular
internal static let regular = L10n.tr("Localizable", "regular", fallback: "Regular")
/// Released
@ -752,6 +770,18 @@ internal enum L10n {
internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume", fallback: "Remove From Resume")
/// PlayMethod - Remux
internal static let remux = L10n.tr("Localizable", "remux", fallback: "Remux")
/// Replace All
internal static let replaceAll = L10n.tr("Localizable", "replaceAll", fallback: "Replace All")
/// Replace all unlocked metadata and images with new information.
internal static let replaceAllDescription = L10n.tr("Localizable", "replaceAllDescription", fallback: "Replace all unlocked metadata and images with new information.")
/// Replace Images
internal static let replaceImages = L10n.tr("Localizable", "replaceImages", fallback: "Replace Images")
/// Replace all images with new images.
internal static let replaceImagesDescription = L10n.tr("Localizable", "replaceImagesDescription", fallback: "Replace all images with new images.")
/// Replace Metadata
internal static let replaceMetadata = L10n.tr("Localizable", "replaceMetadata", fallback: "Replace Metadata")
/// Replace unlocked metadata with new information.
internal static let replaceMetadataDescription = L10n.tr("Localizable", "replaceMetadataDescription", fallback: "Replace unlocked metadata with new information.")
/// Report an Issue
internal static let reportIssue = L10n.tr("Localizable", "reportIssue", fallback: "Report an Issue")
/// Request a Feature

View File

@ -148,5 +148,21 @@ extension StoredValues.Keys {
default: []
)
}
static var enableItemEditor: Key<Bool> {
CurrentUserKey(
"enableItemEditor",
domain: "enableItemEditor",
default: false
)
}
static var enableItemDeletion: Key<Bool> {
CurrentUserKey(
"enableItemDeletion",
domain: "enableItemDeletion",
default: false
)
}
}
}

View File

@ -68,6 +68,11 @@ extension UserState {
data.policy?.isAdministrator ?? false
}
// Validate that the use has permission to delete something whether from a folder or all folders
var hasDeletionPermissions: Bool {
data.policy?.enableContentDeletion ?? false || data.policy?.enableContentDeletionFromFolders != []
}
var pinHint: String {
get {
StoredValues[.User.pinHint(id: id)]

View File

@ -0,0 +1,111 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import JellyfinAPI
class DeleteItemViewModel: ViewModel, Stateful, Eventful {
// MARK: Events
enum Event: Equatable {
case error(JellyfinAPIError)
case deleted
}
// MARK: Action
enum Action: Equatable {
case error(JellyfinAPIError)
case delete
}
// MARK: State
enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
case refreshing
}
@Published
var item: BaseItemDto?
@Published
final var state: State = .initial
private var deleteTask: AnyCancellable?
// MARK: Event Variables
private var eventSubject: PassthroughSubject<Event, Never> = .init()
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
// MARK: Init
init(item: BaseItemDto) {
self.item = item
super.init()
}
// MARK: Respond
func respond(to action: Action) -> State {
switch action {
case let .error(error):
return .error(error)
case .delete:
deleteTask?.cancel()
deleteTask = Task { [weak self] in
guard let self = self else { return }
do {
try await self.deleteItem()
await MainActor.run {
self.state = .content
self.eventSubject.send(.deleted)
}
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
self.state = .error(JellyfinAPIError(error.localizedDescription))
self.eventSubject.send(.error(JellyfinAPIError(error.localizedDescription)))
}
}
}
.asAnyCancellable()
return .refreshing
}
}
// MARK: Metadata Refresh Logic
private func deleteItem() async throws {
guard let itemID = item?.id else {
throw JellyfinAPIError(L10n.unknownError)
}
let request = Paths.deleteItem(itemID: itemID)
_ = try await userSession.client.send(request)
await MainActor.run {
Notifications[.didDeleteItem].post(object: item)
self.item = nil
}
}
}

View File

@ -0,0 +1,175 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import JellyfinAPI
class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
// MARK: Events
enum Event: Equatable {
case error(JellyfinAPIError)
case refreshTriggered
}
// MARK: Action
enum Action: Equatable {
case error(JellyfinAPIError)
case refreshMetadata(
metadataRefreshMode: MetadataRefreshMode,
imageRefreshMode: MetadataRefreshMode,
replaceMetadata: Bool,
replaceImages: Bool
)
}
// MARK: State
enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
case refreshing
}
// A spoof progress, since there isn't a
// single item metadata refresh task
@Published
private(set) var progress: Double = 0.0
@Published
private var item: BaseItemDto
@Published
final var state: State = .initial
private var itemTask: AnyCancellable?
private var eventSubject = PassthroughSubject<Event, Never>()
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
// MARK: Init
init(item: BaseItemDto) {
self.item = item
super.init()
}
// MARK: Respond
func respond(to action: Action) -> State {
switch action {
case let .error(error):
eventSubject.send(.error(error))
return .error(error)
case let .refreshMetadata(metadataRefreshMode, imageRefreshMode, replaceMetadata, replaceImages):
itemTask?.cancel()
itemTask = Task { [weak self] in
guard let self else { return }
do {
await MainActor.run {
self.state = .content
self.eventSubject.send(.refreshTriggered)
}
try await self.refreshMetadata(
metadataRefreshMode: metadataRefreshMode,
imageRefreshMode: imageRefreshMode,
replaceMetadata: replaceMetadata,
replaceImages: replaceImages
)
await MainActor.run {
self.state = .refreshing
self.eventSubject.send(.refreshTriggered)
}
try await self.refreshItem()
await MainActor.run {
self.state = .content
}
} catch {
guard !Task.isCancelled else { return }
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.state = .error(apiError)
self.eventSubject.send(.error(apiError))
}
}
}
.asAnyCancellable()
return .refreshing
}
}
// MARK: Metadata Refresh Logic
private func refreshMetadata(
metadataRefreshMode: MetadataRefreshMode,
imageRefreshMode: MetadataRefreshMode,
replaceMetadata: Bool = false,
replaceImages: Bool = false
) async throws {
guard let itemId = item.id else { return }
var parameters = Paths.RefreshItemParameters()
parameters.metadataRefreshMode = metadataRefreshMode
parameters.imageRefreshMode = imageRefreshMode
parameters.isReplaceAllMetadata = replaceMetadata
parameters.isReplaceAllImages = replaceImages
let request = Paths.refreshItem(
itemID: itemId,
parameters: parameters
)
_ = try await userSession.client.send(request)
}
// MARK: Refresh Item After Request Queued
private func refreshItem() async throws {
guard let itemId = item.id else { return }
let totalDuration: Double = 5.0
let interval: Double = 0.05
let steps = Int(totalDuration / interval)
// Update progress every 0.05 seconds. Ticks up "1%" at a time.
for i in 1 ... steps {
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
let currentProgress = Double(i) / Double(steps)
await MainActor.run {
self.progress = currentProgress
}
}
// After waiting for 5 seconds, fetch the updated item
let request = Paths.getItem(userID: userSession.user.id, itemID: itemId)
let response = try await userSession.client.send(request)
await MainActor.run {
self.item = response.value
self.progress = 0.0
Notifications[.itemMetadataDidChange].post(object: itemId)
}
}
}

View File

@ -159,6 +159,13 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
super.init()
Notifications[.didDeleteItem].publisher
.sink(receiveCompletion: { _ in }) { [weak self] notification in
guard let item = notification.object as? Element else { return }
self?.elements.remove(item)
}
.store(in: &cancellables)
if let filterViewModel {
filterViewModel.$currentFilters
.dropFirst()

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; };
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; };
4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; };
4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; };
4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; };
4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; };
@ -73,6 +74,13 @@
4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; };
4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; };
4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; };
4E8F74A22CE03C9000CC8969 /* ItemEditorCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */; };
4E8F74A52CE03D3C00CC8969 /* ItemEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */; };
4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */; };
4E8F74AC2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */; };
4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */; };
4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */; };
4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */; };
4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */; };
4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */; };
4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */; };
@ -123,6 +131,7 @@
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; };
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; };
4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; };
4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; };
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; };
@ -1066,6 +1075,7 @@
/* Begin PBXFileReference section */
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = "<group>"; };
4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = "<group>"; };
4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = "<group>"; };
4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = "<group>"; };
@ -1112,6 +1122,11 @@
4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; };
4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; };
4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; };
4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorCoordinator.swift; sourceTree = "<group>"; };
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorView.swift; sourceTree = "<group>"; };
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteItemViewModel.swift; sourceTree = "<group>"; };
4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataButton.swift; sourceTree = "<group>"; };
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataViewModel.swift; sourceTree = "<group>"; };
4E90F7592CC72B1F00417C31 /* DetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsSection.swift; sourceTree = "<group>"; };
4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastErrorSection.swift; sourceTree = "<group>"; };
4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastRunSection.swift; sourceTree = "<group>"; };
@ -1156,6 +1171,7 @@
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = "<group>"; };
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
@ -2073,6 +2089,7 @@
4E699BB72CB33FB0007CBD5D /* Sections */ = {
isa = PBXGroup;
children = (
4E0195E32CE04678007844F4 /* ItemSection.swift */,
4E699BB82CB33FB5007CBD5D /* HomeSection.swift */,
);
path = Sections;
@ -2113,6 +2130,32 @@
path = ActiveSessionDetailView;
sourceTree = "<group>";
};
4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = {
isa = PBXGroup;
children = (
4E8F74A62CE03D4C00CC8969 /* Components */,
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */,
);
path = ItemEditorView;
sourceTree = "<group>";
};
4E8F74A62CE03D4C00CC8969 /* Components */ = {
isa = PBXGroup;
children = (
4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */,
);
path = Components;
sourceTree = "<group>";
};
4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */ = {
isa = PBXGroup;
children = (
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */,
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */,
);
path = ItemEditorViewModel;
sourceTree = "<group>";
};
4E90F75E2CC72B1F00417C31 /* Sections */ = {
isa = PBXGroup;
children = (
@ -2322,6 +2365,7 @@
E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */,
E113133928BEB71D00930F75 /* FilterViewModel.swift */,
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */,
E107BB9127880A4000354E07 /* ItemViewModel */,
E1EDA8D52B924CA500F9A57E /* LibraryViewModel */,
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
@ -2719,6 +2763,7 @@
E178B0752BE435D70023651B /* HourMinutePicker.swift */,
E1DC7AC92C63337C00AEE368 /* iOS15View.swift */,
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */,
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */,
4E16FD4E2C0183B500110147 /* LetterPickerBar */,
E1A8FDEB2C0574A800D0A51C /* ListRow.swift */,
E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */,
@ -2809,6 +2854,7 @@
6220D0B926D6092100B8E046 /* FilterCoordinator.swift */,
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */,
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */,
4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */,
6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */,
E102312B2BCF8A08009D71FC /* LiveTVCoordinator */,
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */,
@ -3296,6 +3342,7 @@
62C83B07288C6A630004ED0C /* FontPickerView.swift */,
E168BD07289A4162001A6922 /* HomeView */,
E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */,
4E8F74A32CE03D3100CC8969 /* ItemEditorView */,
E14F7D0A26DB3714007C3AE6 /* ItemView */,
E170D104294D21FA0017224C /* MediaSourceInfoView.swift */,
E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */,
@ -4634,6 +4681,7 @@
E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */,
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */,
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */,
@ -4871,6 +4919,7 @@
E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */,
E1AEFA382BE36C4900CFAFD8 /* SwiftinStore+UserState.swift in Sources */,
E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */,
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */,
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
@ -5099,6 +5148,7 @@
E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */,
4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */,
E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */,
4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */,
E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */,
E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */,
E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */,
@ -5116,12 +5166,14 @@
E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
E15756322935642A00976E1F /* Double.swift in Sources */,
4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */,
E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */,
E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */,
E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */,
E1E6C45029B104840064123F /* Button.swift in Sources */,
4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */,
E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */,
4E8F74A52CE03D3C00CC8969 /* ItemEditorView.swift in Sources */,
E10432F62BE4426F006FF9DD /* FormatStyle.swift in Sources */,
E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */,
E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */,
@ -5226,10 +5278,13 @@
6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */,
4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */,
4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */,
4E8F74AC2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */,
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */,
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */,
4E8F74A22CE03C9000CC8969 /* ItemEditorCoordinator.swift in Sources */,
E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */,
E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */,
4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */,
E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
@ -5449,6 +5504,7 @@
E18E01EA288747230022598C /* MovieItemView.swift in Sources */,
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */,
4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */,
E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */,
E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,

View File

@ -0,0 +1,62 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct LearnMoreButton: View {
@State
private var isPresented: Bool = false
private let title: String
private let items: [TextPair]
// MARK: - Initializer
init(_ title: String, @ArrayBuilder<TextPair> items: () -> [TextPair]) {
self.title = title
self.items = items()
}
// MARK: - Body
var body: some View {
Button(L10n.learnMoreEllipsis) {
isPresented = true
}
.foregroundStyle(Color.accentColor)
.font(.subheadline)
.sheet(isPresented: $isPresented) {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ForEach(items) { content in
VStack(alignment: .leading, spacing: 8) {
Text(content.title)
.font(.headline)
.foregroundStyle(.primary)
Text(content.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Divider()
}
}
.edgePadding()
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
isPresented = false
}
}
}
}
}

View File

@ -0,0 +1,127 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ItemEditorView {
struct RefreshMetadataButton: View {
// Bug in SwiftUI where Menu item icons will be black in dark mode
// when a HierarchicalShapeStyle is applied to the Buttons
@Environment(\.colorScheme)
private var colorScheme: ColorScheme
@StateObject
private var viewModel: RefreshMetadataViewModel
@State
private var isPresentingEventAlert = false
@State
private var error: JellyfinAPIError?
// MARK: - Initializer
init(item: BaseItemDto) {
_viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item))
}
// MARK: - Body
var body: some View {
Menu {
Group {
Button(L10n.findMissing, systemImage: "magnifyingglass") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .fullRefresh,
replaceMetadata: false,
replaceImages: false
)
)
}
Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .none,
replaceMetadata: true,
replaceImages: false
)
)
}
Button(L10n.replaceImages, systemImage: "photo") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .none,
imageRefreshMode: .fullRefresh,
replaceMetadata: false,
replaceImages: true
)
)
}
Button(L10n.replaceAll, systemImage: "staroflife") {
viewModel.send(
.refreshMetadata(
metadataRefreshMode: .fullRefresh,
imageRefreshMode: .fullRefresh,
replaceMetadata: true,
replaceImages: true
)
)
}
}
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
} label: {
HStack {
Text(L10n.refreshMetadata)
.foregroundStyle(.primary)
Spacer()
if viewModel.state == .refreshing {
ProgressView(value: viewModel.progress)
.progressViewStyle(.gauge)
.transition(.opacity.combined(with: .scale).animation(.bouncy))
.frame(width: 25, height: 25)
} else {
Image(systemName: "arrow.clockwise")
.foregroundStyle(.secondary)
.backport
.fontWeight(.semibold)
}
}
}
.foregroundStyle(.primary, .secondary)
.disabled(viewModel.state == .refreshing || isPresentingEventAlert)
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
isPresentingEventAlert = true
case .refreshTriggered:
UIDevice.impact(.light)
}
}
.alert(
L10n.error,
isPresented: $isPresentingEventAlert,
presenting: error
) { _ in
} message: { error in
Text(error.localizedDescription)
}
}
}
}

View File

@ -0,0 +1,73 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Factory
import JellyfinAPI
import SwiftUI
struct ItemEditorView: View {
@Injected(\.currentUserSession)
private var userSession
@EnvironmentObject
private var router: ItemEditorCoordinator.Router
@State
var item: BaseItemDto
// MARK: - Body
var body: some View {
contentView
.navigationBarTitle(L10n.metadata)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
router.dismissCoordinator()
}
.onNotification(.itemMetadataDidChange) { notification in
guard let newItem = notification.object as? BaseItemDto else { return }
item = newItem
}
}
// MARK: - Content View
private var contentView: some View {
List {
ListTitleSection(
item.name ?? L10n.unknown,
description: item.path
)
Section {
RefreshMetadataButton(item: item)
.environment(\.isEnabled, userSession?.user.isAdministrator ?? false)
} footer: {
LearnMoreButton(L10n.metadata) {
TextPair(
title: L10n.findMissing,
subtitle: L10n.findMissingDescription
)
TextPair(
title: L10n.replaceMetadata,
subtitle: L10n.replaceMetadataDescription
)
TextPair(
title: L10n.replaceImages,
subtitle: L10n.replaceImagesDescription
)
TextPair(
title: L10n.replaceAll,
subtitle: L10n.replaceAllDescription
)
}
}
}
}
}

View File

@ -15,8 +15,34 @@ import SwiftUI
struct ItemView: View {
@EnvironmentObject
private var router: ItemCoordinator.Router
@StateObject
private var viewModel: ItemViewModel
@StateObject
private var deleteViewModel: DeleteItemViewModel
@State
private var showConfirmationDialog = false
@State
private var isPresentingEventAlert = false
@State
private var error: JellyfinAPIError?
@StoredValue(.User.enableItemDeletion)
private var enableItemDeletion: Bool
@StoredValue(.User.enableItemEditor)
private var enableItemEditor: Bool
private var canDelete: Bool {
enableItemDeletion && viewModel.item.canDelete ?? false
}
// As more menu items exist, this can either be expanded to include more validation or removed if there are permanent menu items.
private var enableMenu: Bool {
canDelete || enableItemEditor
}
private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel {
switch item.type {
@ -36,6 +62,7 @@ struct ItemView: View {
init(item: BaseItemDto) {
self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item))
self._deleteViewModel = StateObject(wrappedValue: DeleteItemViewModel(item: item))
}
@ViewBuilder
@ -100,6 +127,59 @@ struct ItemView: View {
if viewModel.backgroundStates.contains(.refresh) {
ProgressView()
}
if enableMenu {
itemActionMenu
}
}
.confirmationDialog(
L10n.deleteItemConfirmationMessage,
isPresented: $showConfirmationDialog,
titleVisibility: .visible
) {
Button(L10n.confirm, role: .destructive) {
deleteViewModel.send(.delete)
}
Button(L10n.cancel, role: .cancel) {}
}
.onReceive(deleteViewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
isPresentingEventAlert = true
case .deleted:
router.dismissCoordinator()
}
}
.alert(
L10n.error,
isPresented: $isPresentingEventAlert,
presenting: error
) { _ in
} message: { error in
Text(error.localizedDescription)
}
}
@ViewBuilder
private var itemActionMenu: some View {
Menu(L10n.options, systemImage: "ellipsis.circle") {
if enableItemEditor {
Button(L10n.edit, systemImage: "pencil") {
router.route(to: \.itemEditor, viewModel.item)
}
}
if canDelete {
Divider()
Button(L10n.delete, systemImage: "trash", role: .destructive) {
showConfirmationDialog = true
}
}
}
.labelStyle(.iconOnly)
.backport
.fontWeight(.semibold)
}
}

View File

@ -0,0 +1,38 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import SwiftUI
extension CustomizeViewsSettings {
struct ItemSection: View {
@Injected(\.currentUserSession)
private var userSession
@StoredValue(.User.enableItemEditor)
private var enableItemEditor
@StoredValue(.User.enableItemDeletion)
private var enableItemDeletion
var body: some View {
Section(L10n.items) {
if userSession?.user.isAdministrator ?? false {
Toggle(L10n.allowItemEditing, isOn: $enableItemEditor)
}
if userSession?.user.hasDeletionPermissions ?? false {
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
}
}
}
}
}

View File

@ -158,6 +158,8 @@ struct CustomizeViewsSettings: View {
}
}
ItemSection()
HomeSection()
Section {

View File

@ -1213,6 +1213,61 @@
// Used as the button label in the options menu when there are users to edit
"editUsers" = "Edit Users";
// Refresh - Button
// Button title for the menu to refresh metadata
// Used as the label for the refresh metadata button
"refreshMetadata" = "Refresh Metadata";
// Find Missing - Menu Option
// Menu option for finding missing metadata
// Used to trigger a full metadata refresh
"findMissing" = "Find Missing";
// Replace Metadata - Menu Option
// Menu option for replacing existing metadata
// Used to trigger replacing metadata only
"replaceMetadata" = "Replace Metadata";
// Replace Images - Menu Option
// Menu option for replacing existing images
// Used to trigger replacing images only
"replaceImages" = "Replace Images";
// Replace All - Menu Option
// Menu option for replacing both metadata and images
// Used to trigger a full replacement of metadata and images
"replaceAll" = "Replace All";
// Delete Item Confirmation Message - Warning message
// Warning message to confirm deleting a media item
// Used in a confirmation for item deletion
"deleteItemConfirmationMessage" = "Are you sure you want to delete this item? This action cannot be undone.";
// Allow Media Item Editing - Toggle
// Toggle option for enabling media item editing
// Used to allow users to edit metadata of media items
"allowItemEditing" = "Allow media item editing";
// Allow Media Item Deletion - Toggle
// Toggle option for enabling media item deletion
// Used to allow users to delete media items
"allowItemDeletion" = "Allow media item deletion";
// Item Deletion Permission Failure - Error Message
// Alert the user they should not be able to delete something
// Used to inform the user a deletion failed and why it failed
"itemDeletionPermissionFailure" = "You do not have permission to delete this item.";
// Metadata - Section Title
// Title for the ItemEditorView and Metadata related views
// Used as a title for sections/views related to Metadata
"metadata" = "Metadata";
// Learn More - Button
// Opens a modal with more information
// Used as a button to show details
"learnMoreEllipsis" = "Learn more...";
/// Current Password - Placeholder
/// Placeholder text for the current password input field
/// Used in the ResetUserPasswordView
@ -1247,3 +1302,23 @@
/// Message displayed to alert the user what the password change does and does not do
/// Used in the ResetUserPasswordView
"passwordChangeWarning" = "Changes the Jellyfin server user password. This does not change any Swiftfin settings.";
/// Find Missing - Button
/// Search for missing metadata and images
/// Used to locate media files missing information or images
"findMissingDescription" = "Find missing metadata and images.";
/// Replace Metadata - Button
/// Overwrite metadata without affecting images
/// Used when updating only metadata information
"replaceMetadataDescription" = "Replace unlocked metadata with new information.";
/// Replace Images - Button
/// Overwrite existing images with new ones
/// Used to refresh media artwork
"replaceImagesDescription" = "Replace all images with new images.";
/// Replace All - Button
/// Replace all metadata and images
/// Full refresh that replaces all unlocked metadata and images
"replaceAllDescription" = "Replace all unlocked metadata and images with new information.";