diff --git a/.gitignore b/.gitignore index 8e65697610..a98f72f2b3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ Package.resolved *.generated.swift /.env-vars + +## Claude Code +.claude/ diff --git a/Brand/NCBrand.swift b/Brand/NCBrand.swift index f51c1a6ada..6287298438 100755 --- a/Brand/NCBrand.swift +++ b/Brand/NCBrand.swift @@ -121,7 +121,7 @@ final class NCBrandOptions: @unchecked Sendable { // Info Paging enum NCInfoPagingTab: Int, CaseIterable { - case activity, sharing + case activity, sharing, details } init() { diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 60fdfe7095..653144ea86 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -50,6 +50,9 @@ AAFC0D0B2F9AA10000F0A001 /* NCAutoUploadCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */; }; AB6000012F60000100FE2775 /* NCTagEditorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6000002F60000100FE2775 /* NCTagEditorModel.swift */; }; AB6000032F60000200FE2775 /* NCTagEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6000022F60000200FE2775 /* NCTagEditorView.swift */; }; + AB7000022F70000100FE2776 /* NCShareDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7000012F70000100FE2776 /* NCShareDetailsView.swift */; }; + AB7000042F70000100FE2776 /* NCShareDetailsGovernanceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7000032F70000100FE2776 /* NCShareDetailsGovernanceModel.swift */; }; + AB7000062F70000100FE2776 /* NCGovernanceLabelSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7000052F70000100FE2776 /* NCGovernanceLabelSelectorView.swift */; }; AF1A9B6427D0CA1E00F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; }; AF1A9B6527D0CC0500F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; }; AF22B206277B4E4C00DAB0CC /* NCCreateFormUploadConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = F704B5E42430AA8000632F5F /* NCCreateFormUploadConflict.swift */; }; @@ -134,7 +137,6 @@ F37208A62BAB63EE006B5430 /* SwiftRichString in Frameworks */ = {isa = PBXBuildFile; productRef = F37208A52BAB63EE006B5430 /* SwiftRichString */; }; F37208AA2BAB63EE006B5430 /* MarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = F37208A92BAB63EE006B5430 /* MarkdownKit */; }; F37208B22BAB63EF006B5430 /* DropDown in Frameworks */ = {isa = PBXBuildFile; productRef = F37208B12BAB63EF006B5430 /* DropDown */; }; - F37208B42BAB63EF006B5430 /* Parchment in Frameworks */ = {isa = PBXBuildFile; productRef = F37208B32BAB63EF006B5430 /* Parchment */; }; F37208B62BAB63EF006B5430 /* EasyTipView in Frameworks */ = {isa = PBXBuildFile; productRef = F37208B52BAB63EF006B5430 /* EasyTipView */; }; F37208B82BAB63EF006B5430 /* NextcloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = F37208B72BAB63EF006B5430 /* NextcloudKit */; }; F37208BA2BAB63EF006B5430 /* TLPhotoPicker in Frameworks */ = {isa = PBXBuildFile; productRef = F37208B92BAB63EF006B5430 /* TLPhotoPicker */; }; @@ -755,7 +757,6 @@ F7B934FE2BDCFE1E002B2FC9 /* NCDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B934FD2BDCFE1E002B2FC9 /* NCDragDrop.swift */; }; F7BAADCB1ED5A87C00B7EAD4 /* NCManageDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BAADB51ED5A87C00B7EAD4 /* NCManageDatabase.swift */; }; F7BAADCC1ED5A87C00B7EAD4 /* NCManageDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BAADB51ED5A87C00B7EAD4 /* NCManageDatabase.swift */; }; - F7BB7E4727A18C56009B9F29 /* Parchment in Frameworks */ = {isa = PBXBuildFile; productRef = F7BB7E4627A18C56009B9F29 /* Parchment */; }; F7BC287E26663F6C004D46C5 /* NCViewCertificateDetails.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */; }; F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */; }; F7BD0A002C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */; }; @@ -1294,6 +1295,9 @@ AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAutoUploadCounter.swift; sourceTree = ""; }; AB6000002F60000100FE2775 /* NCTagEditorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTagEditorModel.swift; sourceTree = ""; }; AB6000022F60000200FE2775 /* NCTagEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTagEditorView.swift; sourceTree = ""; }; + AB7000012F70000100FE2776 /* NCShareDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCShareDetailsView.swift; sourceTree = ""; }; + AB7000032F70000100FE2776 /* NCShareDetailsGovernanceModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCShareDetailsGovernanceModel.swift; sourceTree = ""; }; + AB7000052F70000100FE2776 /* NCGovernanceLabelSelectorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCGovernanceLabelSelectorView.swift; sourceTree = ""; }; AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Extension.swift"; sourceTree = ""; }; AF22B20B277C6F4D00DAB0CC /* NCShareCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCell.swift; sourceTree = ""; }; AF22B215277D196700DAB0CC /* NCShareExtension+DataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCShareExtension+DataSource.swift"; sourceTree = ""; }; @@ -1998,7 +2002,6 @@ F37208B22BAB63EF006B5430 /* DropDown in Frameworks */, F37208BC2BAB63EF006B5430 /* OpenSSL in Frameworks */, F37208B82BAB63EF006B5430 /* NextcloudKit in Frameworks */, - F37208B42BAB63EF006B5430 /* Parchment in Frameworks */, F37208BE2BAB63F0006B5430 /* Swifter in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2071,7 +2074,6 @@ F788ECC7263AAAFA00ADC67F /* MarkdownKit in Frameworks */, F3374AF02D78B01B002A38F9 /* BitCollections in Frameworks */, F77BC3EB293E5268005F2B08 /* Swifter in Frameworks */, - F7BB7E4727A18C56009B9F29 /* Parchment in Frameworks */, F33EE6E12BF4BDA500CA1A51 /* NIOSSL in Frameworks */, F734B06628E75C0100E180D5 /* TLPhotoPicker in Frameworks */, F760DE032AE66EA80027D78A /* KeychainAccess in Frameworks */, @@ -2506,6 +2508,9 @@ AF2D7C7D2742559100ADF566 /* NCShareUserCell.swift */, AF93471727E2361E002537EE /* NCShareHeader.xib */, AF93471527E2361E002537EE /* NCShareHeader.swift */, + AB7000012F70000100FE2776 /* NCShareDetailsView.swift */, + AB7000032F70000100FE2776 /* NCShareDetailsGovernanceModel.swift */, + AB7000052F70000100FE2776 /* NCGovernanceLabelSelectorView.swift */, AFCE353827E5DE0400FEA6C2 /* Shareable.swift */, AA8E03D92D2ED83300E7E89C /* TransientShare.swift */, ); @@ -3738,7 +3743,6 @@ F37208A52BAB63EE006B5430 /* SwiftRichString */, F37208A92BAB63EE006B5430 /* MarkdownKit */, F37208B12BAB63EF006B5430 /* DropDown */, - F37208B32BAB63EF006B5430 /* Parchment */, F37208B52BAB63EF006B5430 /* EasyTipView */, F37208B72BAB63EF006B5430 /* NextcloudKit */, F37208B92BAB63EF006B5430 /* TLPhotoPicker */, @@ -3907,7 +3911,6 @@ F788ECC6263AAAFA00ADC67F /* MarkdownKit */, F70B86742642CE3B00ED5349 /* FirebaseCrashlytics */, F76DA968277B77EA0082465B /* DropDown */, - F7BB7E4627A18C56009B9F29 /* Parchment */, F753BA92281FD8020015BFB6 /* EasyTipView */, F72AD70C28C24B93006CB92D /* NextcloudKit */, F734B06528E75C0100E180D5 /* TLPhotoPicker */, @@ -4110,7 +4113,6 @@ F70B86732642CE3B00ED5349 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, F76DA967277B77E90082465B /* XCRemoteSwiftPackageReference "DropDown" */, F710FC78277B7CFF00AA9FBF /* XCRemoteSwiftPackageReference "realm-swift" */, - F7BB7E4527A18C56009B9F29 /* XCRemoteSwiftPackageReference "Parchment" */, F753BA91281FD8010015BFB6 /* XCRemoteSwiftPackageReference "EasyTipView" */, F783034028B511D200B84583 /* XCRemoteSwiftPackageReference "NextcloudKit" */, F734B06428E75C0100E180D5 /* XCRemoteSwiftPackageReference "TLPhotoPicker" */, @@ -4815,6 +4817,9 @@ F321DA8A2B71205A00DDA0E6 /* NCTrashSelectTabBar.swift in Sources */, F702F2CD25EE5B4F008F8E80 /* AppDelegate.swift in Sources */, F769454022E9F077000A798A /* NCSharePaging.swift in Sources */, + AB7000022F70000100FE2776 /* NCShareDetailsView.swift in Sources */, + AB7000042F70000100FE2776 /* NCShareDetailsGovernanceModel.swift in Sources */, + AB7000062F70000100FE2776 /* NCGovernanceLabelSelectorView.swift in Sources */, F7C687E92D22BD46004757BC /* NCManageDatabase+RecommendedFiles.swift in Sources */, F74D50352C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift in Sources */, F7802B322BD5584F00D74270 /* NCMedia+DragDrop.swift in Sources */, @@ -6635,14 +6640,6 @@ minimumVersion = 1.0.0; }; }; - F7BB7E4527A18C56009B9F29 /* XCRemoteSwiftPackageReference "Parchment" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/rechsteiner/Parchment"; - requirement = { - kind = exactVersion; - version = 3.3.0; - }; - }; F7D4BF4E2CA2ECCB00A5E746 /* XCRemoteSwiftPackageReference "vlckit-spm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tylerjonesio/vlckit-spm"; @@ -6763,11 +6760,6 @@ package = F76DA967277B77E90082465B /* XCRemoteSwiftPackageReference "DropDown" */; productName = DropDown; }; - F37208B32BAB63EF006B5430 /* Parchment */ = { - isa = XCSwiftPackageProductDependency; - package = F7BB7E4527A18C56009B9F29 /* XCRemoteSwiftPackageReference "Parchment" */; - productName = Parchment; - }; F37208B52BAB63EF006B5430 /* EasyTipView */ = { isa = XCSwiftPackageProductDependency; package = F753BA91281FD8010015BFB6 /* XCRemoteSwiftPackageReference "EasyTipView" */; @@ -7018,11 +7010,6 @@ package = F710FC78277B7CFF00AA9FBF /* XCRemoteSwiftPackageReference "realm-swift" */; productName = RealmSwift; }; - F7BB7E4627A18C56009B9F29 /* Parchment */ = { - isa = XCSwiftPackageProductDependency; - package = F7BB7E4527A18C56009B9F29 /* XCRemoteSwiftPackageReference "Parchment" */; - productName = Parchment; - }; F7C55C9E2FB5B83A004A974F /* NextcloudKit */ = { isa = XCSwiftPackageProductDependency; package = F783034028B511D200B84583 /* XCRemoteSwiftPackageReference "NextcloudKit" */; diff --git a/iOSClient/Activity/NCActivity.storyboard b/iOSClient/Activity/NCActivity.storyboard index e156b6070b..8d5210d921 100644 --- a/iOSClient/Activity/NCActivity.storyboard +++ b/iOSClient/Activity/NCActivity.storyboard @@ -59,6 +59,7 @@ + diff --git a/iOSClient/Activity/NCActivity.swift b/iOSClient/Activity/NCActivity.swift index 5ca08721ed..70ef10ed1e 100644 --- a/iOSClient/Activity/NCActivity.swift +++ b/iOSClient/Activity/NCActivity.swift @@ -15,6 +15,7 @@ class NCActivity: UIViewController, NCSharePagingContent { var height: CGFloat = 0 var metadata: tableMetadata? var showComments: Bool = false + var usesGroupedBackground: Bool = false let utilityFileSystem = NCUtilityFileSystem() let utility = NCUtility() @@ -55,13 +56,13 @@ class NCActivity: UIViewController, NCSharePagingContent { super.viewDidLoad() navigationController?.setNavigationBarAppearance() - view.backgroundColor = .systemBackground + view.backgroundColor = usesGroupedBackground ? .systemGroupedBackground : .systemBackground self.title = NSLocalizedString("_activity_", comment: "") tableView.allowsSelection = false tableView.separatorColor = UIColor.clear tableView.contentInset = insets - tableView.backgroundColor = .systemBackground + tableView.backgroundColor = usesGroupedBackground ? .systemGroupedBackground : .systemBackground if showComments { setupComments() @@ -161,25 +162,25 @@ extension NCActivity: UITableViewDelegate { label.text = utility.getTitleFromDate(sectionDates[section]) label.textAlignment = .center - let blur = UIBlurEffect(style: .systemMaterial) - let blurredEffectView = UIVisualEffectView(effect: blur) - blurredEffectView.layer.cornerRadius = 11 - blurredEffectView.layer.masksToBounds = true + let pill = UIView() + pill.backgroundColor = .systemGray5 + pill.layer.cornerRadius = 11 + pill.layer.masksToBounds = true - view.addSubview(blurredEffectView) + view.addSubview(pill) view.addSubview(label) - blurredEffectView.translatesAutoresizingMaskIntoConstraints = false + pill.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - blurredEffectView.topAnchor.constraint(equalTo: view.topAnchor), - blurredEffectView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - blurredEffectView.widthAnchor.constraint(equalToConstant: label.intrinsicContentSize.width + 30), - blurredEffectView.heightAnchor.constraint(equalToConstant: 22), + pill.topAnchor.constraint(equalTo: view.topAnchor), + pill.centerXAnchor.constraint(equalTo: view.centerXAnchor), + pill.widthAnchor.constraint(equalToConstant: label.intrinsicContentSize.width + 30), + pill.heightAnchor.constraint(equalToConstant: 22), label.topAnchor.constraint(equalTo: view.topAnchor), label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: blurredEffectView.centerYAnchor) + label.centerYAnchor.constraint(equalTo: pill.centerYAnchor) ]) return view diff --git a/iOSClient/Activity/NCActivityCommentView.xib b/iOSClient/Activity/NCActivityCommentView.xib index e60f0514d4..d5113e2f58 100644 --- a/iOSClient/Activity/NCActivityCommentView.xib +++ b/iOSClient/Activity/NCActivityCommentView.xib @@ -23,7 +23,7 @@ - + @@ -32,7 +32,7 @@ - + diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 013d0c2d03..98e4c0cd65 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -189,7 +189,7 @@ extension tableMetadata { } var isCopyableMovable: Bool { - !isDirectoryE2EE && !e2eEncrypted + !isDirectoryE2EE && !e2eEncrypted && NCMetadataPermissions.canMoveAndDelete(self) } var isModifiableWithQuickLook: Bool { @@ -217,7 +217,7 @@ extension tableMetadata { } var isDeletable: Bool { - if (!isDirectoryE2EE && e2eEncrypted) || !NCMetadataPermissions.canDelete(self) { + if (!isDirectoryE2EE && e2eEncrypted) || !NCMetadataPermissions.canDelete(self) || !NCMetadataPermissions.canMoveAndDelete(self) { return false } return true diff --git a/iOSClient/Main/Create/NCCreate.swift b/iOSClient/Main/Create/NCCreate.swift index a0ac4ea192..f7059b47b3 100644 --- a/iOSClient/Main/Create/NCCreate.swift +++ b/iOSClient/Main/Create/NCCreate.swift @@ -195,6 +195,9 @@ class NCCreate: NSObject { if !metadata.isSharable(), let idx = pages.firstIndex(of: .sharing) { pages.remove(at: idx) } + if !capabilities.governanceEnabled, let idx = pages.firstIndex(of: .details) { + pages.remove(at: idx) + } shareViewController?.pages = pages shareViewController?.metadata = metadata diff --git a/iOSClient/Share/NCGovernanceLabelSelectorView.swift b/iOSClient/Share/NCGovernanceLabelSelectorView.swift new file mode 100644 index 0000000000..5b4637fae8 --- /dev/null +++ b/iOSClient/Share/NCGovernanceLabelSelectorView.swift @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import NextcloudKit + +/// Reusable selection sheet for a governance label type. +/// Single-select (sensitivity, with a "None" row) or multi-select (retention / hold); +/// holds the selection transiently and reports the chosen set on Save. +struct NCGovernanceLabelSelectorView: View { + let title: String + let labels: [NKGovernanceLabel] + let account: String + let allowsMultipleSelection: Bool + let onSave: (Set) async -> Void + + @State private var selected: Set + @State private var isSaving = false + @Environment(\.dismiss) private var dismiss + + init( + title: String, + labels: [NKGovernanceLabel], + account: String, + allowsMultipleSelection: Bool = true, + initialSelection: Set, + onSave: @escaping (Set) async -> Void + ) { + self.title = title + self.labels = labels + self.account = account + self.allowsMultipleSelection = allowsMultipleSelection + self.onSave = onSave + _selected = State(initialValue: initialSelection) + } + + var body: some View { + NavigationStack { + List { + if !allowsMultipleSelection { + noneRow + } + + ForEach(labels, id: \.id) { label in + Button { + toggle(label.id) + } label: { + row(dot: label.displayColor, name: label.name, isSelected: selected.contains(label.id)) + } + } + } + .navigationTitle(NSLocalizedString(title, comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("_cancel_", comment: ""), systemImage: "xmark") { + dismiss() + } + .tint(Color.primary) + } + + ToolbarItem(placement: .confirmationAction) { + Button(NSLocalizedString("_save_", comment: ""), systemImage: "checkmark") { + Task { + isSaving = true + await onSave(selected) + isSaving = false + dismiss() + } + } + .buttonStyle(.borderedProminent) + } + } + .overlay { + if isSaving { + ProgressView() + } + } + .disabled(isSaving) + .tint(Color(NCBrandColor.shared.getElement(account: account))) + } + } + + private var noneRow: some View { + Button { + selected = [] + } label: { + row(dot: .clear, name: NSLocalizedString("_none_", comment: ""), isSelected: selected.isEmpty) + } + } + + private func row(dot: Color, name: String, isSelected: Bool) -> some View { + HStack { + Circle() + .fill(dot) + .frame(width: 10, height: 10) + + Text(name) + .foregroundStyle(Color.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: account))) + } + } + } + + private func toggle(_ id: String) { + if !allowsMultipleSelection { + selected = [id] + return + } + + if selected.contains(id) { + selected.remove(id) + } else { + selected.insert(id) + } + } +} + +extension NKGovernanceLabel { + var displayColor: Color { + if let color = UIColor(hex: color) { + return Color(color) + } + + return .secondary + } +} diff --git a/iOSClient/Share/NCMetadataPermissions.swift b/iOSClient/Share/NCMetadataPermissions.swift index caacb3d2d1..dac4313c48 100644 --- a/iOSClient/Share/NCMetadataPermissions.swift +++ b/iOSClient/Share/NCMetadataPermissions.swift @@ -26,10 +26,16 @@ enum NCMetadataPermissions { return metadata.permissions.contains(permissionCanCreateFolder) } + /** Note: If a folder is shared it will be unshared instead of deleted */ static func canDelete(_ metadata: tableMetadata) -> Bool { return metadata.permissions.contains(permissionCanDeleteOrUnshare) } + /// This includes deleting as deleting a file is moving it to the trash + static func canMoveAndDelete(_ metadata: tableMetadata) -> Bool { + return metadata.permissions.contains(permissionCanMove) + } + static func canRename(_ metadata: tableMetadata) -> Bool { return metadata.permissions.contains(permissionCanRename) } diff --git a/iOSClient/Share/NCShare.swift b/iOSClient/Share/NCShare.swift index 45824603c1..db3f95f5d9 100644 --- a/iOSClient/Share/NCShare.swift +++ b/iOSClient/Share/NCShare.swift @@ -24,7 +24,6 @@ // import UIKit -import Parchment import DropDown import NextcloudKit import ContactsUI @@ -75,7 +74,7 @@ class NCShare: UIViewController, NCSharePagingContent { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground + view.backgroundColor = .systemGroupedBackground viewContainerConstraint.constant = height searchFieldTopConstraint.constant = 0 @@ -86,7 +85,7 @@ class NCShare: UIViewController, NCSharePagingContent { tableView.dataSource = self tableView.delegate = self tableView.allowsSelection = false - tableView.backgroundColor = .systemBackground + tableView.backgroundColor = .systemGroupedBackground tableView.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 10, right: 0) tableView.register(UINib(nibName: "NCShareLinkCell", bundle: nil), forCellReuseIdentifier: "cellLink") diff --git a/iOSClient/Share/NCShareDetailsGovernanceModel.swift b/iOSClient/Share/NCShareDetailsGovernanceModel.swift new file mode 100644 index 0000000000..e2103b3a71 --- /dev/null +++ b/iOSClient/Share/NCShareDetailsGovernanceModel.swift @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +struct GovernanceData { + let availableSensitivityLabels: [NKGovernanceLabel] + let availableRetentionLabels: [NKGovernanceLabel] + let availableHoldLabels: [NKGovernanceLabel] +} + +enum GovernanceViewState { + case loading + case loaded(GovernanceData) + case error +} + +@MainActor +@Observable +final class NCShareDetailsGovernanceModel { + var state: GovernanceViewState = .loading + + var selectedSensitivityLabelID = "" + var selectedRetentionLabelIDs: Set = [] + var selectedHoldLabelIDs: Set = [] + + private let metadata: tableMetadata + + init(metadata: tableMetadata) { + self.metadata = metadata + } + + var account: String { metadata.account } + + private var entityID: String { metadata.fileId } + + func load() async { + let result = await NextcloudKit.shared.getGovernanceAvailableLabels(entityId: entityID, account: account) + + guard let labels = result.labels else { + nkLog(error: "Could not load governance labels: \(result.error.errorDescription) (\(result.error.errorCode))") + state = .error + return + } + + selectedSensitivityLabelID = labels.sensitivity.first(where: \.isAssigned)?.id ?? "" + selectedRetentionLabelIDs = Set(labels.retention.filter(\.isAssigned).map(\.id)) + selectedHoldLabelIDs = Set(labels.hold.filter(\.isAssigned).map(\.id)) + + state = .loaded(GovernanceData( + availableSensitivityLabels: labels.sensitivity, + availableRetentionLabels: labels.retention, + availableHoldLabels: labels.hold + )) + } + + func applySensitivityLabel(from oldID: String, to newID: String) async { + await applyLabel(type: .sensitivity, oldID: oldID, newID: newID) + selectedSensitivityLabelID = newID + } + + /// Persists a multi-selection by diffing against what's applied and calling set/remove per changed label. + func saveLabels(type: NKGovernanceLabelType, selectedIDs: Set) async { + let current = (type == .retention) ? selectedRetentionLabelIDs : selectedHoldLabelIDs + + for id in selectedIDs.subtracting(current) { + await applyLabel(type: type, oldID: "", newID: id) + } + + for id in current.subtracting(selectedIDs) { + await applyLabel(type: type, oldID: id, newID: "") + } + + if type == .retention { + selectedRetentionLabelIDs = selectedIDs + } else { + selectedHoldLabelIDs = selectedIDs + } + } + + private func applyLabel(type: NKGovernanceLabelType, oldID: String, newID: String) async { + if newID.isEmpty { + _ = await NextcloudKit.shared.removeGovernanceLabel(entityId: entityID, labelType: type, labelId: oldID, account: account) + } else { + _ = await NextcloudKit.shared.setGovernanceLabel(entityId: entityID, labelType: type, labelId: newID, account: account) + } + } +} diff --git a/iOSClient/Share/NCShareDetailsView.swift b/iOSClient/Share/NCShareDetailsView.swift new file mode 100644 index 0000000000..ca62a4eda2 --- /dev/null +++ b/iOSClient/Share/NCShareDetailsView.swift @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import NextcloudKit + +struct NCShareDetailsView: View { + @State private var model: NCShareDetailsGovernanceModel + @State private var activeSelector: GovernanceSelector? + + private enum GovernanceSelector: String, Identifiable { + case sensitivity + case retention + case hold + + var id: String { rawValue } + + var title: String { + switch self { + case .sensitivity: return "_sensitivity_label_" + case .retention: return "_file_retention_" + case .hold: return "_legal_hold_" + } + } + } + + init(metadata: tableMetadata) { + _model = State(initialValue: NCShareDetailsGovernanceModel(metadata: metadata)) + } + + var body: some View { + ZStack { + switch model.state { + case .loading: + ProgressView() + case .loaded(let data): + Form { + Section(header: + Text(NSLocalizedString("_governance_", comment: "")).font(.headline) + ) { + labelSelectorSubtitleRow( + title: "_sensitivity_label_", + labels: data.availableSensitivityLabels, + selectedIDs: sensitivitySelection + ) { + activeSelector = .sensitivity + } + + labelSelectorSubtitleRow( + title: "_file_retention_", + labels: data.availableRetentionLabels, + selectedIDs: model.selectedRetentionLabelIDs + ) { + activeSelector = .retention + } + + labelSelectorSubtitleRow( + title: "_legal_hold_", + labels: data.availableHoldLabels, + selectedIDs: model.selectedHoldLabelIDs + ) { + activeSelector = .hold + } + } + } + .tint(Color(NCBrandColor.shared.getElement(account: model.account))) + .sheet(item: $activeSelector) { selector in + selectorSheet(selector, data: data) + } + + case .error: + Text(NSLocalizedString("_governance_labels_load_error_", comment: "")) + } + } + .task { + await model.load() + } + } + + private var sensitivitySelection: Set { + model.selectedSensitivityLabelID.isEmpty ? [] : [model.selectedSensitivityLabelID] + } + + private func selectorSheet(_ selector: GovernanceSelector, data: GovernanceData) -> some View { + let labels: [NKGovernanceLabel] + let initialSelection: Set + + switch selector { + case .sensitivity: + labels = data.availableSensitivityLabels + initialSelection = sensitivitySelection + case .retention: + labels = data.availableRetentionLabels + initialSelection = model.selectedRetentionLabelIDs + case .hold: + labels = data.availableHoldLabels + initialSelection = model.selectedHoldLabelIDs + } + + return NCGovernanceLabelSelectorView( + title: selector.title, + labels: labels, + account: model.account, + allowsMultipleSelection: selector != .sensitivity, + initialSelection: initialSelection + ) { newSelection in + await save(selector, newSelection: newSelection) + } + .presentationDetents([.medium, .large]) + } + + private func save(_ selector: GovernanceSelector, newSelection: Set) async { + switch selector { + case .sensitivity: + await model.applySensitivityLabel(from: model.selectedSensitivityLabelID, to: newSelection.first ?? "") + case .retention: + await model.saveLabels(type: .retention, selectedIDs: newSelection) + case .hold: + await model.saveLabels(type: .hold, selectedIDs: newSelection) + } + } + + private func labelSelectorSubtitleRow( + title: String, + labels: [NKGovernanceLabel], + selectedIDs: Set, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString(title, comment: "")) + .cappedFont(.body, maxDynamicType: .accessibility2) + .foregroundStyle(Color.primary) + + subtitle(labels: labels, selectedIDs: selectedIDs) + .font(.footnote) + .foregroundStyle(Color.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color(.tertiaryLabel)) + } + } + } + + private func subtitle(labels: [NKGovernanceLabel], selectedIDs: Set) -> Text { + let selected = labels.filter { selectedIDs.contains($0.id) } + + guard !selected.isEmpty else { + return Text(NSLocalizedString("_none_", comment: "")) + } + + var text = Text("") + + for (index, label) in selected.enumerated() { + if index > 0 { + text = text + Text(", ") + } + + text = text + + Text(Image(systemName: "circle.fill")).font(.caption2).foregroundStyle(label.displayColor) + + Text(" \(label.name)") + } + + return text + } + +} + +final class NCShareDetailsViewController: UIHostingController, NCSharePagingContent { + var textField: UIView? { nil } + var height: CGFloat = 0 + + init(metadata: tableMetadata) { + super.init(rootView: NCShareDetailsView(metadata: metadata)) + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) not supported") + } +} diff --git a/iOSClient/Share/NCShareHeader.swift b/iOSClient/Share/NCShareHeader.swift index ea3fa20a9e..c7670551cd 100644 --- a/iOSClient/Share/NCShareHeader.swift +++ b/iOSClient/Share/NCShareHeader.swift @@ -70,6 +70,7 @@ class NCShareHeader: UIView { info.text = utilityFileSystem.transformedSize(metadata.size) + ", " + NCUtility().getRelativeDateTitle(metadata.date as Date) tagListView.marginY = 8 + tagListView.backgroundColor = .clear refreshTags(metadata.tagNames, tagModels: metadata.tags.map(\.nkTag)) setNeedsLayout() diff --git a/iOSClient/Share/NCShareHeader.xib b/iOSClient/Share/NCShareHeader.xib index 50574f7299..0145ca4094 100644 --- a/iOSClient/Share/NCShareHeader.xib +++ b/iOSClient/Share/NCShareHeader.xib @@ -47,12 +47,9 @@ - + - - - - + diff --git a/iOSClient/Share/NCSharePaging.swift b/iOSClient/Share/NCSharePaging.swift index c6f9cc0c9c..5da74b406b 100644 --- a/iOSClient/Share/NCSharePaging.swift +++ b/iOSClient/Share/NCSharePaging.swift @@ -23,30 +23,52 @@ // import UIKit -import Parchment import NextcloudKit import TagListView +import SwiftUI protocol NCSharePagingContent { var textField: UIView? { get } } class NCSharePaging: UIViewController { - private let pagingViewController = NCShareHeaderViewController() private weak var appDelegate = UIApplication.shared.delegate as? AppDelegate - private var currentVC: NCSharePagingContent? + private let tabModel = NCSharePagingTabModel() + private weak var headerView: NCShareHeader? + private var pageVCs: [UIViewController] = [] + private var contentHost: UIHostingController? var metadata = tableMetadata() var controller: NCMainTabBarController? var pages: [NCBrandOptions.NCInfoPagingTab] = [] - var page: NCBrandOptions.NCInfoPagingTab = .activity + + private var initialPage: NCBrandOptions.NCInfoPagingTab = .activity + var page: NCBrandOptions.NCInfoPagingTab { + get { + guard isViewLoaded else { return initialPage } + let index = tabModel.selection + return (index < pages.count) ? pages[index] : initialPage + } + set { + initialPage = newValue + if isViewLoaded, let index = pages.firstIndex(of: newValue) { + tabModel.selection = index + } + } + } + + private var currentVC: NCSharePagingContent? { + let index = tabModel.selection + guard index < pageVCs.count else { return nil } + return pageVCs[index] as? NCSharePagingContent + } // MARK: - View Life Cycle override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground + view.backgroundColor = .systemGroupedBackground title = NSLocalizedString("_details_", comment: "") navigationItem.leftBarButtonItem = UIBarButtonItem( @@ -69,54 +91,95 @@ class NCSharePaging: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground(notification:)), name: UIApplication.didEnterBackgroundNotification, object: nil) - // *** MUST BE THE FIRST ONE *** - pagingViewController.metadata = metadata - pagingViewController.backgroundColor = .systemBackground - pagingViewController.menuBackgroundColor = .systemBackground - pagingViewController.selectedBackgroundColor = .systemBackground - pagingViewController.indicatorColor = NCBrandColor.shared.getElement(account: metadata.account) - pagingViewController.textColor = NCBrandColor.shared.textColor - pagingViewController.selectedTextColor = NCBrandColor.shared.getElement(account: metadata.account) - - // Pagination - addChild(pagingViewController) - view.addSubview(pagingViewController.view) - pagingViewController.didMove(toParent: self) - - // Customization - pagingViewController.indicatorOptions = .visible( - height: 1, - zIndex: Int.max, - spacing: .zero, - insets: .zero + setupHeader() + + pageVCs = pages.map { makeViewController(for: $0) } + tabModel.selection = pages.firstIndex(of: initialPage) ?? 0 + + setupContent() + } + + private func setupHeader() { + guard let headerView = Bundle.main.loadNibNamed("NCShareHeader", owner: self, options: nil)?.first as? NCShareHeader else { return } + self.headerView = headerView + headerView.backgroundColor = .systemGroupedBackground + headerView.setupUI(with: metadata) + + view.addSubview(headerView) + headerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) + ]) + } + + private func setupContent() { + let content = NCSharePagingContentView( + model: tabModel, + tint: Color(NCBrandColor.shared.getElement(account: metadata.account)), + titles: pages.map(titleForTab(_:)), + pageVCs: pageVCs, + onSelectionChange: { [weak self] _ in + self?.view.endEditing(true) + } ) + let host = UIHostingController(rootView: content) + host.view.backgroundColor = .systemGroupedBackground - pagingViewController.borderOptions = .visible(height: 1, zIndex: Int.max, insets: .zero) + addChild(host) + view.addSubview(host.view) + host.didMove(toParent: self) + host.view.translatesAutoresizingMaskIntoConstraints = false - // Contrain the paging view to all edges. - pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false + let topAnchor = headerView?.bottomAnchor ?? view.safeAreaLayoutGuide.topAnchor NSLayoutConstraint.activate([ - pagingViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - pagingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - pagingViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - pagingViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) + host.view.topAnchor.constraint(equalTo: topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + host.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) ]) - pagingViewController.dataSource = self - pagingViewController.delegate = self + self.contentHost = host + } - if page.rawValue < pages.count { - pagingViewController.select(index: page.rawValue) - } else { - pagingViewController.select(index: 0) - } + private func makeViewController(for tab: NCBrandOptions.NCInfoPagingTab) -> UIViewController { + // The old Parchment menu floated over the child view, so children inset by menuHeight (50). + // The new SwiftUI layout places the picker above the content, so no inset is needed. + let height: CGFloat = 0 - pagingViewController.reloadMenu() + switch tab { + case .activity: + guard let viewController = UIStoryboard(name: "NCActivity", bundle: nil).instantiateInitialViewController() as? NCActivity else { + return UIViewController() + } + viewController.height = height + viewController.showComments = true + viewController.didSelectItemEnable = false + viewController.metadata = metadata + viewController.objectType = "files" + viewController.account = metadata.account + viewController.usesGroupedBackground = true + return viewController + case .sharing: + guard let viewController = UIStoryboard(name: "NCShare", bundle: nil).instantiateViewController(withIdentifier: "sharing") as? NCShare else { + return UIViewController() + } + viewController.metadata = metadata + viewController.height = height + viewController.controller = controller + return viewController + case .details: + return NCShareDetailsViewController(metadata: metadata) + } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - currentVC = pagingViewController.pageViewController.selectedViewController as? NCSharePagingContent + private func titleForTab(_ tab: NCBrandOptions.NCInfoPagingTab) -> String { + switch tab { + case .activity: return NSLocalizedString("_activity_", comment: "") + case .sharing: return NSLocalizedString("_sharing_", comment: "") + case .details: return NSLocalizedString("_details_", comment: "") + } } override func viewWillAppear(_ animated: Bool) { @@ -177,16 +240,12 @@ class NCSharePaging: UIViewController { } @objc func editTagsTapped(_ sender: Any?) { - guard let header = (pagingViewController.view as? NCSharePagingView)?.header else { - return - } + guard let header = headerView else { return } header.presentTagEditor(from: self) { [weak self] tags in guard let self else { return } self.metadata.tags.removeAll() self.metadata.tags.append(objectsIn: tags, account: self.metadata.account) - self.pagingViewController.metadata.tags.removeAll() - self.pagingViewController.metadata.tags.append(objectsIn: tags, account: self.pagingViewController.metadata.account) } } @@ -195,129 +254,55 @@ class NCSharePaging: UIViewController { } } -// MARK: - PagingViewController Delegate +// MARK: - SwiftUI tab content -extension NCSharePaging: PagingViewControllerDelegate { - func pagingViewController(_ pagingViewController: PagingViewController, willScrollToItem pagingItem: PagingItem, startingViewController: UIViewController, destinationViewController: UIViewController) { - - currentVC?.textField?.resignFirstResponder() - self.currentVC = destinationViewController as? NCSharePagingContent - } +@Observable +final class NCSharePagingTabModel { + var selection: Int = 0 } -// MARK: - PagingViewController DataSource - -extension NCSharePaging: PagingViewControllerDataSource { - func pagingViewController(_: PagingViewController, viewControllerAt index: Int) -> UIViewController { - let height: CGFloat = 50 - - if pages[index] == .activity { - guard let viewController = UIStoryboard(name: "NCActivity", bundle: nil).instantiateInitialViewController() as? NCActivity else { - return UIViewController() +struct NCSharePagingContentView: View { + @Bindable var model: NCSharePagingTabModel + let tint: Color + let titles: [String] + let pageVCs: [UIViewController] + var onSelectionChange: (Int) -> Void + + var body: some View { + VStack(spacing: 0) { + Picker("", selection: $model.selection) { + ForEach(Array(titles.enumerated()), id: \.offset) { index, title in + Text(title).tag(index) + } } - viewController.height = height - viewController.showComments = true - viewController.didSelectItemEnable = false - viewController.metadata = metadata - viewController.objectType = "files" - viewController.account = metadata.account - return viewController - } else if pages[index] == .sharing { - guard let viewController = UIStoryboard(name: "NCShare", bundle: nil).instantiateViewController(withIdentifier: "sharing") as? NCShare else { - return UIViewController() + .pickerStyle(.segmented) + .accessibilityLabel(NSLocalizedString("_sections_", comment: "")) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .tint(tint) + + TabView(selection: $model.selection) { + ForEach(Array(pageVCs.enumerated()), id: \.offset) { index, viewController in + NCViewControllerRepresentable(viewController: viewController) + .tag(index) + } } - viewController.metadata = metadata - viewController.height = height - viewController.controller = controller - return viewController - } else { - return UIViewController() + .tabViewStyle(.page(indexDisplayMode: .never)) } - } - - func pagingViewController(_: PagingViewController, pagingItemAt index: Int) -> PagingItem { - - if pages[index] == .activity { - return PagingIndexItem(index: index, title: NSLocalizedString("_activity_", comment: "")) - } else if pages[index] == .sharing { - return PagingIndexItem(index: index, title: NSLocalizedString("_sharing_", comment: "")) - } else { - return PagingIndexItem(index: index, title: "") + .onChange(of: model.selection) { _, newValue in + onSelectionChange(newValue) } } - - func numberOfViewControllers(in pagingViewController: PagingViewController) -> Int { - return self.pages.count - } } -// MARK: - Header +private struct NCViewControllerRepresentable: UIViewControllerRepresentable { + let viewController: UIViewController -class NCShareHeaderViewController: PagingViewController { - public var image: UIImage? - public var metadata = tableMetadata() + func makeUIViewController(context: Context) -> UIViewController { viewController } - override func loadView() { - view = NCSharePagingView( - options: options, - collectionView: collectionView, - pageView: pageViewController.view, - metadata: metadata - ) - } -} - -class NCSharePagingView: PagingView { - var metadata = tableMetadata() - let utilityFileSystem = NCUtilityFileSystem() - let utility = NCUtility() - public var headerHeightConstraint: NSLayoutConstraint? - var header: NCShareHeader? - - // MARK: - View Life Cycle - - public init(options: Parchment.PagingOptions, collectionView: UICollectionView, pageView: UIView, metadata: tableMetadata) { - super.init(options: options, collectionView: collectionView, pageView: pageView) - - self.metadata = metadata - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func setupConstraints() { - guard let headerView = Bundle.main.loadNibNamed("NCShareHeader", owner: self, options: nil)?.first as? NCShareHeader else { return } - header = headerView - headerView.backgroundColor = .systemBackground - - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .short - dateFormatter.timeStyle = .short - dateFormatter.locale = Locale.current - - headerView.setupUI(with: metadata) + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - addSubview(headerView) - - collectionView.translatesAutoresizingMaskIntoConstraints = false - headerView.translatesAutoresizingMaskIntoConstraints = false - pageView.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), - collectionView.heightAnchor.constraint(equalToConstant: options.menuHeight), - collectionView.topAnchor.constraint(equalTo: headerView.bottomAnchor), - - headerView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), - headerView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), - headerView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), - - pageView.leadingAnchor.constraint(equalTo: leadingAnchor), - pageView.trailingAnchor.constraint(equalTo: trailingAnchor), - pageView.bottomAnchor.constraint(equalTo: bottomAnchor), - pageView.topAnchor.constraint(equalTo: headerView.bottomAnchor) - ]) + // TabView(.page) does not propagate appearance trait changes to represented VCs (as of iOS 18.4), seems a SwiftUI bug... + uiViewController.view.overrideUserInterfaceStyle = context.environment.colorScheme == .dark ? .dark : .light } } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 6aa75c1854..31aeff1f27 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -855,3 +855,12 @@ You can stop it at any time, adjust the settings, and enable it again."; "_certificate_valid_until_" = "Valid until"; "_certificate_" = "Certificate"; "_certificate_renew_" = "Renew certificate"; + +// Share details — Governance +"_governance_" = "Governance"; +"_sensitivity_label_" = "Sensitivity label"; +"_file_retention_" = "File retention"; +"_legal_hold_" = "Legal hold"; +"_none_" = "None"; +"_sections_" = "Sections"; +"_governance_labels_load_error_" = "Governance labels could not be loaded.";