Add screenshot cleanup and NAS quarantine features
- NASTransferProtocol: add deleteFile(at:) to enable server-side deletion
- SFTPService: implement deleteFile using Citadel sftp.remove(at:)
- SMBService: implement deleteFile using SMBClient deleteFile(path:)
- SyncView FREE UP SPACE: two new cards —
• "Clean up screenshots" — shows count of phone screenshots already
backed up to NAS, opens review sheet for bulk PHPhotoLibrary deletion
• "NAS screenshot quarantine" — identifies NAS files that match phone
screenshots, offers to move them into a Screenshots/ subfolder via
download → writeData → deleteFile per file
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Photos
|
||||
|
||||
struct SyncView: View {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@@ -14,6 +15,11 @@ struct SyncView: View {
|
||||
private var completedItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploaded || $0.status == .skipped } }
|
||||
private var failedItems: [BackupQueueItem] { queueItems.filter { $0.status == .failed } }
|
||||
|
||||
@State private var phoneScreenshots: [LocalPhotoIndex.Record] = []
|
||||
@State private var nasScreenshotNames: [String] = []
|
||||
@State private var showPhoneCleanup = false
|
||||
@State private var showNASQuarantine = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
@@ -55,6 +61,17 @@ struct SyncView: View {
|
||||
.refreshable {
|
||||
// Pull-to-refresh: show latest queue from engine (already live)
|
||||
}
|
||||
.task { await computeScreenshots() }
|
||||
.sheet(isPresented: $showPhoneCleanup, onDismiss: { Task { await computeScreenshots() } }) {
|
||||
if let conn = connection {
|
||||
PhoneScreenshotCleanupSheet(records: phoneScreenshots, connection: conn)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showNASQuarantine, onDismiss: { Task { await computeScreenshots() } }) {
|
||||
if let conn = connection {
|
||||
NASQuarantineSheet(filenames: nasScreenshotNames, connection: conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — NAS header (no pill, no border)
|
||||
@@ -198,6 +215,7 @@ struct SyncView: View {
|
||||
.kerning(0.8)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
// Auto-delete after backup
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
@@ -244,9 +262,94 @@ struct SyncView: View {
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
|
||||
// Phone screenshot cleanup
|
||||
Button { showPhoneCleanup = true } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "camera.viewfinder")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Clean up screenshots")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(phoneScreenshots.isEmpty
|
||||
? "No backed-up screenshots found"
|
||||
: "\(phoneScreenshots.count) screenshots safe to remove from phone")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
if !phoneScreenshots.isEmpty {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
.padding(AppTheme.cardPad)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(phoneScreenshots.isEmpty)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
|
||||
// NAS screenshot quarantine
|
||||
Button { showNASQuarantine = true } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "folder.badge.minus")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("NAS screenshot quarantine")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(nasScreenshotNames.isEmpty
|
||||
? "No screenshots found on NAS"
|
||||
: "\(nasScreenshotNames.count) screenshots on NAS")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
if !nasScreenshotNames.isEmpty {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
.padding(AppTheme.cardPad)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(nasScreenshotNames.isEmpty)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func computeScreenshots() async {
|
||||
await LocalPhotoIndex.shared.loadOrBuild()
|
||||
let allRecords = await LocalPhotoIndex.shared.allRecords()
|
||||
let screenshotRecords = allRecords.filter { $0.isScreenshot }
|
||||
|
||||
let nasFilenames: [String]
|
||||
if let cached = await NASManifestCache.shared.directoryFilenames {
|
||||
nasFilenames = cached
|
||||
} else if let manifest = await NASManifestCache.shared.manifest {
|
||||
nasFilenames = manifest.entries.map { $0.filename }
|
||||
} else {
|
||||
nasFilenames = []
|
||||
}
|
||||
let nasLower = Set(nasFilenames.map { $0.lowercased() })
|
||||
|
||||
phoneScreenshots = screenshotRecords.filter { nasLower.contains($0.filename.lowercased()) }
|
||||
|
||||
let screenshotLower = Set(screenshotRecords.map { $0.filename.lowercased() })
|
||||
nasScreenshotNames = nasFilenames.filter { screenshotLower.contains($0.lowercased()) }
|
||||
}
|
||||
|
||||
private var retentionLabel: String {
|
||||
switch store.localRetentionDays {
|
||||
case 0: "Never"
|
||||
@@ -398,3 +501,207 @@ struct QueueFileRow: View {
|
||||
return String(format: "%.1f GB", mb / 1024)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Phone Screenshot Cleanup Sheet
|
||||
|
||||
struct PhoneScreenshotCleanupSheet: View {
|
||||
let records: [LocalPhotoIndex.Record]
|
||||
let connection: NASConnection
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isDeleting = false
|
||||
@State private var done = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(records, id: \.localIdentifier) { record in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "camera.viewfinder")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(record.filename)
|
||||
.font(.system(size: 13))
|
||||
.lineLimit(1)
|
||||
if let date = record.creationDate {
|
||||
Text(date, style: .date)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("\(records.count) screenshots already backed up to NAS")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Phone Screenshots")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
Task { await deleteAll() }
|
||||
} label: {
|
||||
Group {
|
||||
if isDeleting {
|
||||
ProgressView().tint(.white)
|
||||
} else if done {
|
||||
Label("Deleted", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("Delete \(records.count) Screenshots from Phone")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(AppTheme.destructive)
|
||||
.disabled(isDeleting || done)
|
||||
.padding()
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAll() async {
|
||||
isDeleting = true
|
||||
let ids = records.map { $0.localIdentifier }
|
||||
let assets = PHAsset.fetchAssets(withLocalIdentifiers: ids, options: nil)
|
||||
try? await PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.deleteAssets(assets)
|
||||
}
|
||||
isDeleting = false
|
||||
done = true
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — NAS Quarantine Sheet
|
||||
|
||||
struct NASQuarantineSheet: View {
|
||||
let filenames: [String]
|
||||
let connection: NASConnection
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var progress: (done: Int, total: Int) = (0, 0)
|
||||
@State private var isMoving = false
|
||||
@State private var done = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 52, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
.padding(.top, 40)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text("\(filenames.count) screenshots on NAS")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("Move to a Screenshots/ subfolder in your NAS backup directory.")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
Text(connection.remotePath + "/Screenshots/")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
if isMoving {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView(value: Double(progress.done),
|
||||
total: Double(max(progress.total, 1)))
|
||||
.padding(.horizontal, 40)
|
||||
Text("\(progress.done) of \(progress.total) moved")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
if done {
|
||||
Label("All screenshots moved", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(AppTheme.positive)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
|
||||
if let err = errorMessage {
|
||||
Text(err)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.navigationTitle("NAS Quarantine")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !done && !isMoving {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
Task { await quarantine() }
|
||||
} label: {
|
||||
Text("Move \(filenames.count) Screenshots")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding()
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quarantine() async {
|
||||
isMoving = true
|
||||
errorMessage = nil
|
||||
progress = (0, filenames.count)
|
||||
let conn = connection
|
||||
let destDir = "\(conn.remotePath)/Screenshots"
|
||||
do {
|
||||
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await transfer.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
if !(try await transfer.fileExists(at: destDir)) {
|
||||
try await transfer.createDirectory(at: destDir)
|
||||
}
|
||||
for (i, filename) in filenames.enumerated() {
|
||||
let src = "\(conn.remotePath)/\(filename)"
|
||||
let dst = "\(destDir)/\(filename)"
|
||||
if let data = try? await transfer.downloadData(at: src) {
|
||||
try? await transfer.writeData(data, to: dst)
|
||||
try? await transfer.deleteFile(at: src)
|
||||
}
|
||||
progress = (i + 1, filenames.count)
|
||||
}
|
||||
transfer.disconnect()
|
||||
done = true
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isMoving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user