mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 07:11:28 +00:00
When an Invidious/Piped account is active, several views read the local SwiftData subscription store instead of the account, so counts contradicted each other (Home tile said 10 while the Channels list showed the server's 2): - Home Channels tile now uses the provider's count via new SubscriptionService.cachedSubscriptionCount (fetched once in the background when the server cache isn't populated yet) - Export footer and export content use the active account's list; export runs async with a spinner and surfaces fetch errors as a toast - CSV/OPML import routes through SubscriptionService.importSubscriptions, subscribing on the server for server accounts instead of silently writing to the invisible local store - ChannelView subscribe-state is corrected via the provider after the optimistic local-store read; unused *Sync write helpers removed - Server-account subscribe/unsubscribe/import now post subscriptionsDidChange so other views refresh - New "Delete Local Subscription Data" section in Subscriptions settings (visible with a server account) clears the local store and queues CloudKit deletions so iCloud doesn't restore it
518 lines
18 KiB
Swift
518 lines
18 KiB
Swift
//
|
|
// SubscriptionsSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Settings for importing and exporting subscriptions.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
#if !os(tvOS)
|
|
|
|
// MARK: - Export File Wrapper
|
|
|
|
private struct ExportFile: Identifiable {
|
|
let id = UUID()
|
|
let url: URL
|
|
}
|
|
|
|
struct SubscriptionsSettingsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
// Account selection state
|
|
@State private var pendingAccountChange: SubscriptionAccount?
|
|
@State private var showingAccountSwitchConfirmation = false
|
|
|
|
// Import state
|
|
@State private var showingImportPicker = false
|
|
@State private var isImporting = false
|
|
@State private var importResult: (imported: Int, skipped: Int, format: String)?
|
|
@State private var showingImportResult = false
|
|
@State private var importError: String?
|
|
@State private var showingImportError = false
|
|
|
|
// Export state
|
|
@State private var selectedExportFormat: SubscriptionExportFormat = .json
|
|
@State private var exportFile: ExportFile?
|
|
@State private var isExporting = false
|
|
|
|
// Account subscription count (provider-scoped, fetched for server accounts)
|
|
@State private var accountSubscriptionCount: Int?
|
|
|
|
// Local data deletion state
|
|
@State private var showingDeleteLocalConfirmation = false
|
|
|
|
private var dataManager: DataManager? {
|
|
appEnvironment?.dataManager
|
|
}
|
|
|
|
private var settingsManager: SettingsManager? {
|
|
appEnvironment?.settingsManager
|
|
}
|
|
|
|
private var validator: SubscriptionAccountValidator? {
|
|
appEnvironment?.subscriptionAccountValidator
|
|
}
|
|
|
|
private var subscriptionService: SubscriptionService? {
|
|
appEnvironment?.subscriptionService
|
|
}
|
|
|
|
/// Subscription count of the active account (local store or server).
|
|
private var subscriptionCount: Int {
|
|
accountSubscriptionCount ?? subscriptionService?.cachedSubscriptionCount ?? 0
|
|
}
|
|
|
|
/// Count of subscriptions in the local store, shown in the local-data section.
|
|
private var localSubscriptionCount: Int {
|
|
dataManager?.subscriptionCount ?? 0
|
|
}
|
|
|
|
private var currentAccount: SubscriptionAccount {
|
|
settingsManager?.subscriptionAccount ?? .local
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
accountSection
|
|
if validator?.hasAvailableAccounts == true {
|
|
importSection
|
|
exportSection
|
|
localDataSection
|
|
}
|
|
}
|
|
.task(id: currentAccount) {
|
|
await refreshAccountSubscriptionCount()
|
|
}
|
|
.navigationTitle(String(localized: "settings.subscriptions.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.sheet(isPresented: $showingImportPicker) {
|
|
SubscriptionFilePickerView { url in
|
|
handleImportedFile(url)
|
|
}
|
|
}
|
|
.sheet(item: $exportFile) { file in
|
|
ShareSheet(items: [file.url])
|
|
}
|
|
#endif
|
|
.alert(
|
|
String(localized: "settings.subscriptions.import.success.title"),
|
|
isPresented: $showingImportResult
|
|
) {
|
|
Button(String(localized: "common.ok")) {}
|
|
} message: {
|
|
if let result = importResult {
|
|
Text(String(localized: "settings.subscriptions.import.success.message \(result.imported) \(result.skipped) \(result.format)"))
|
|
}
|
|
}
|
|
.alert(
|
|
String(localized: "settings.subscriptions.import.error.title"),
|
|
isPresented: $showingImportError
|
|
) {
|
|
Button(String(localized: "common.ok")) {}
|
|
} message: {
|
|
if let error = importError {
|
|
Text(error)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.subscriptions.account.switch.title"),
|
|
isPresented: $showingAccountSwitchConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.subscriptions.account.switch.action")) {
|
|
confirmAccountSwitch()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
cancelAccountSwitch()
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.subscriptions.account.switch.message"))
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.subscriptions.localData.delete.confirmation.title \(localSubscriptionCount)"),
|
|
isPresented: $showingDeleteLocalConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.subscriptions.localData.delete.confirmation.action"), role: .destructive) {
|
|
deleteLocalSubscriptions()
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.subscriptions.localData.delete.confirmation.message"))
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
}
|
|
|
|
// MARK: - Account Section
|
|
|
|
@ViewBuilder
|
|
private var accountSection: some View {
|
|
Section {
|
|
if let validator, validator.hasAvailableAccounts {
|
|
Picker(
|
|
String(localized: "settings.subscriptions.account.label"),
|
|
selection: Binding(
|
|
get: { currentAccount },
|
|
set: { newAccount in
|
|
if newAccount != currentAccount {
|
|
pendingAccountChange = newAccount
|
|
showingAccountSwitchConfirmation = true
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
ForEach(validator.availableAccounts, id: \.self) { account in
|
|
Text(validator.displayName(for: account))
|
|
.tag(account)
|
|
}
|
|
}
|
|
} else {
|
|
// No accounts available - show setup prompt
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(
|
|
String(localized: "settings.subscriptions.account.noAccounts.title"),
|
|
systemImage: "exclamationmark.triangle"
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(String(localized: "settings.subscriptions.account.noAccounts.message"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Button(String(localized: "settings.subscriptions.account.noAccounts.action")) {
|
|
appEnvironment?.navigationCoordinator.navigate(to: .settings)
|
|
}
|
|
.font(.caption)
|
|
.padding(.top, 4)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
} footer: {
|
|
if validator?.hasAvailableAccounts == true {
|
|
Text(String(localized: "settings.subscriptions.account.footer"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Account Switch Actions
|
|
|
|
private func confirmAccountSwitch() {
|
|
guard let newAccount = pendingAccountChange else { return }
|
|
|
|
settingsManager?.subscriptionAccount = newAccount
|
|
SubscriptionFeedCache.shared.handleAccountChange()
|
|
|
|
// Trigger feed refresh in background
|
|
Task {
|
|
guard let appEnvironment else { return }
|
|
await SubscriptionFeedCache.shared.refresh(using: appEnvironment)
|
|
}
|
|
|
|
pendingAccountChange = nil
|
|
LoggingService.shared.logSubscriptions("Switched subscription account to: \(String(describing: newAccount.type))")
|
|
}
|
|
|
|
private func cancelAccountSwitch() {
|
|
pendingAccountChange = nil
|
|
}
|
|
|
|
// MARK: - Import Section
|
|
|
|
@ViewBuilder
|
|
private var importSection: some View {
|
|
Section {
|
|
Button {
|
|
showImportPicker()
|
|
} label: {
|
|
HStack {
|
|
Label(String(localized: "settings.subscriptions.import.button"), systemImage: "square.and.arrow.down")
|
|
Spacer()
|
|
if isImporting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(isImporting)
|
|
} header: {
|
|
Text(String(localized: "settings.subscriptions.import.title"))
|
|
} footer: {
|
|
Text(String(localized: "settings.subscriptions.import.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Export Section
|
|
|
|
@ViewBuilder
|
|
private var exportSection: some View {
|
|
Section {
|
|
Picker(String(localized: "settings.subscriptions.export.format"), selection: $selectedExportFormat) {
|
|
ForEach(SubscriptionExportFormat.allCases) { format in
|
|
Text(format.rawValue).tag(format)
|
|
}
|
|
}
|
|
|
|
Button {
|
|
exportSubscriptions()
|
|
} label: {
|
|
HStack {
|
|
Label(String(localized: "settings.subscriptions.export.button"), systemImage: "square.and.arrow.up")
|
|
Spacer()
|
|
if isExporting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(subscriptionCount == 0 || isExporting)
|
|
} header: {
|
|
Text(String(localized: "settings.subscriptions.export.title"))
|
|
} footer: {
|
|
Text(String(localized: "settings.subscriptions.export.footer \(subscriptionCount)"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Local Data Section
|
|
|
|
/// Lets the user delete locally stored subscriptions while a server account
|
|
/// is active. Hidden in local mode, where the account IS the local store.
|
|
@ViewBuilder
|
|
private var localDataSection: some View {
|
|
if currentAccount.type != .local && localSubscriptionCount > 0 {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showingDeleteLocalConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "settings.subscriptions.localData.delete.button"), systemImage: "trash")
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.subscriptions.localData.title"))
|
|
} footer: {
|
|
Text(String(localized: "settings.subscriptions.localData.footer \(localSubscriptionCount)"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func showImportPicker() {
|
|
#if os(iOS)
|
|
showingImportPicker = true
|
|
#elseif os(macOS)
|
|
showMacOSImportPanel()
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func showMacOSImportPanel() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [
|
|
UTType.commaSeparatedText,
|
|
UTType(filenameExtension: "opml") ?? .xml,
|
|
UTType.xml
|
|
]
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = false
|
|
panel.message = String(localized: "settings.subscriptions.import.panel.message")
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
handleImportedFile(url)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func handleImportedFile(_ url: URL) {
|
|
isImporting = true
|
|
|
|
Task {
|
|
do {
|
|
// Read file data
|
|
let data: Data
|
|
if url.startAccessingSecurityScopedResource() {
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
data = try Data(contentsOf: url)
|
|
} else {
|
|
data = try Data(contentsOf: url)
|
|
}
|
|
|
|
// Parse subscriptions
|
|
let parseResult = try SubscriptionImportExport.parseAuto(data)
|
|
|
|
// Import into the active account (local store or server)
|
|
guard let subscriptionService else {
|
|
throw SubscriptionImportError.invalidData
|
|
}
|
|
|
|
let importStats = await subscriptionService.importSubscriptions(parseResult.channels)
|
|
|
|
await MainActor.run {
|
|
isImporting = false
|
|
importResult = (imported: importStats.imported, skipped: importStats.skipped, format: parseResult.format)
|
|
showingImportResult = true
|
|
LoggingService.shared.logSubscriptions("Import completed: \(importStats.imported) imported, \(importStats.skipped) skipped")
|
|
}
|
|
await refreshAccountSubscriptionCount()
|
|
} catch {
|
|
await MainActor.run {
|
|
isImporting = false
|
|
importError = error.localizedDescription
|
|
showingImportError = true
|
|
LoggingService.shared.logSubscriptionsError("Import failed", error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exportSubscriptions() {
|
|
isExporting = true
|
|
Task {
|
|
await performExport()
|
|
isExporting = false
|
|
}
|
|
}
|
|
|
|
/// Exports subscriptions of the active account: the local store in local
|
|
/// mode, or the server's list for Invidious/Piped accounts.
|
|
private func performExport() async {
|
|
let subscriptions: [Subscription]
|
|
if currentAccount.type == .local {
|
|
guard let dataManager else { return }
|
|
subscriptions = dataManager.allSubscriptions
|
|
} else {
|
|
guard let subscriptionService else { return }
|
|
do {
|
|
let channels = try await subscriptionService.fetchSubscriptions()
|
|
accountSubscriptionCount = channels.count
|
|
// Detached model instances used purely as export carriers
|
|
subscriptions = channels.map { Subscription.from(channel: $0) }
|
|
} catch {
|
|
LoggingService.shared.logSubscriptionsError("Failed to fetch account subscriptions for export", error: error)
|
|
appEnvironment?.toastManager.showError(
|
|
String(localized: "settings.subscriptions.export.error.title"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
let data: Data?
|
|
switch selectedExportFormat {
|
|
case .json:
|
|
data = SubscriptionImportExport.exportToJSON(subscriptions)
|
|
case .opml:
|
|
data = SubscriptionImportExport.exportToOPML(subscriptions)
|
|
}
|
|
|
|
guard let exportData = data else {
|
|
LoggingService.shared.logSubscriptionsError("Failed to generate export data")
|
|
return
|
|
}
|
|
|
|
let filename = SubscriptionImportExport.generateExportFilename(format: selectedExportFormat)
|
|
|
|
#if os(iOS)
|
|
// Write to temp file and share
|
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
do {
|
|
try exportData.write(to: tempURL)
|
|
exportFile = ExportFile(url: tempURL)
|
|
} catch {
|
|
LoggingService.shared.logSubscriptionsError("Failed to write export file", error: error)
|
|
}
|
|
#elseif os(macOS)
|
|
showMacOSSavePanel(data: exportData, filename: filename)
|
|
#endif
|
|
}
|
|
|
|
/// Refreshes the account-scoped subscription count shown in the export footer.
|
|
private func refreshAccountSubscriptionCount() async {
|
|
guard let subscriptionService else { return }
|
|
|
|
if let cached = subscriptionService.cachedSubscriptionCount {
|
|
accountSubscriptionCount = cached
|
|
}
|
|
if subscriptionService.currentAccountType != .local {
|
|
if let channels = try? await subscriptionService.fetchSubscriptions() {
|
|
accountSubscriptionCount = channels.count
|
|
}
|
|
}
|
|
}
|
|
|
|
private func deleteLocalSubscriptions() {
|
|
dataManager?.deleteAllSubscriptions()
|
|
appEnvironment?.toastManager.showSuccess(String(localized: "settings.subscriptions.localData.deleted.title"))
|
|
Task {
|
|
await refreshAccountSubscriptionCount()
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func showMacOSSavePanel(data: Data, filename: String) {
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = filename
|
|
panel.allowedContentTypes = selectedExportFormat == .json ? [.json] : [UTType(filenameExtension: "opml") ?? .xml]
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
do {
|
|
try data.write(to: url)
|
|
LoggingService.shared.logSubscriptions("Exported subscriptions to \(url.path)")
|
|
} catch {
|
|
LoggingService.shared.logSubscriptionsError("Failed to save export", error: error)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - File Picker (iOS)
|
|
|
|
#if os(iOS)
|
|
struct SubscriptionFilePickerView: UIViewControllerRepresentable {
|
|
let onSelect: (URL) -> Void
|
|
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let supportedTypes: [UTType] = [
|
|
.commaSeparatedText,
|
|
UTType(filenameExtension: "opml") ?? .xml,
|
|
.xml,
|
|
.text
|
|
]
|
|
|
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: supportedTypes)
|
|
picker.delegate = context.coordinator
|
|
picker.allowsMultipleSelection = false
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onSelect: onSelect)
|
|
}
|
|
|
|
class Coordinator: NSObject, UIDocumentPickerDelegate {
|
|
let onSelect: (URL) -> Void
|
|
|
|
init(onSelect: @escaping (URL) -> Void) {
|
|
self.onSelect = onSelect
|
|
}
|
|
|
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
guard let url = urls.first else { return }
|
|
onSelect(url)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
SubscriptionsSettingsView()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|
|
#endif
|