From 2e90ee4ceca73d5126118920b486149cb227c4e9 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Apr 2026 16:57:40 -0500 Subject: feat(v2.1.0): add bulk workflows, CSV export, and filtering --- DomainDig.xcodeproj/project.pbxproj | 8 +- DomainDig/BatchResultsView.swift | 114 ++++++++ DomainDig/ContentView.swift | 293 +++++++++++++------ DomainDig/DomainDiffService.swift | 18 +- DomainDig/DomainViewModel.swift | 541 ++++++++++++++++++++++++++++++++--- DomainDig/ExportPresenter.swift | 28 ++ DomainDig/HistoryView.swift | 39 ++- DomainDig/Models.swift | 141 +++++++++ DomainDig/PremiumAccessService.swift | 18 +- DomainDig/WatchlistView.swift | 78 ++++- 10 files changed, 1131 insertions(+), 147 deletions(-) create mode 100644 DomainDig/BatchResultsView.swift create mode 100644 DomainDig/ExportPresenter.swift diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 58f2c35..8958217 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -267,7 +267,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -284,7 +284,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -303,7 +303,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -320,7 +320,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift new file mode 100644 index 0000000..e741830 --- /dev/null +++ b/DomainDig/BatchResultsView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct BatchResultsView: View { + @Bindable var viewModel: DomainViewModel + let title: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + SectionTitleView(title: title) + Spacer() + if viewModel.batchLookupRunning { + VStack(alignment: .trailing, spacing: 4) { + ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1))) + .tint(.cyan) + .frame(width: 120) + Text(viewModel.batchProgressLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } else if !viewModel.batchResults.isEmpty { + Text("\(viewModel.batchResults.count) domains") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + if viewModel.batchResults.isEmpty { + MessageCardView(text: "No batch results yet", isError: false) + } else { + CardView(allowsHorizontalScroll: false) { + ForEach(viewModel.batchResults) { result in + if let entry = viewModel.historyEntry(for: result) { + NavigationLink { + HistoryDetailView(viewModel: viewModel, entry: entry) + } label: { + BatchResultRowView(result: result) + } + .buttonStyle(.plain) + } else { + BatchResultRowView(result: result) + } + } + } + } + } + } +} + +struct BatchResultRowView: View { + let result: BatchLookupResult + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(result.domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 8) + Text(result.quickStatus) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(quickStatusColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(quickStatusColor.opacity(0.16)) + .clipShape(Capsule()) + } + + HStack(spacing: 10) { + Text(availabilityText) + Text(result.primaryIP ?? "No IP") + Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) + } + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + + if let errorMessage = result.errorMessage { + Text(errorMessage) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + + private var availabilityText: String { + switch result.availability { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown, .none: + return "Unknown" + } + } + + private var quickStatusColor: Color { + switch result.status { + case .pending: + return .secondary + case .running: + return .cyan + case .completed: + if result.quickStatus == "Changed" { + return .yellow + } + return .green + case .failed: + return .red + } + } +} diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 69f3af9..cda9f9e 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1,6 +1,13 @@ import MapKit import SwiftUI +enum LookupInputMode: String, CaseIterable, Identifiable { + case single + case bulk + + var id: String { rawValue } +} + struct ContentView: View { @State private var viewModel = DomainViewModel() @State private var navigationPath = NavigationPath() @@ -10,12 +17,17 @@ struct ContentView: View { @State private var trackingNoteDraft = "" @State private var editingTrackedDomain: TrackedDomain? @State private var showTrackLimitAlert = false + @State private var inputMode: LookupInputMode = .single var body: some View { NavigationStack(path: $navigationPath) { ScrollView(.vertical) { VStack(spacing: 0) { inputSection + if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning { + batchSection + .padding(.top, 8) + } if viewModel.hasRun { actionButtons SummaryView(fields: viewModel.summaryFields) @@ -199,28 +211,68 @@ struct ContentView: View { private var inputSection: some View { VStack(spacing: 12) { - TextField("e.g. cleberg.net", text: $viewModel.domain) - .font(.system(.title3, design: .monospaced)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .padding(12) - .background(Color(.systemGray6)) - .cornerRadius(8) - .focused($domainFieldFocused) - .onSubmit { viewModel.run() } - - Button { - domainFieldFocused = false - viewModel.run() - } label: { - Text("Run") - .font(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) + Picker("Mode", selection: $inputMode) { + Text("Single").tag(LookupInputMode.single) + Text("Bulk").tag(LookupInputMode.bulk) + } + .pickerStyle(.segmented) + + if inputMode == .single { + TextField("e.g. cleberg.net", text: $viewModel.domain) + .font(.system(.title3, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .padding(12) + .background(Color(.systemGray6)) + .cornerRadius(8) + .focused($domainFieldFocused) + .onSubmit { viewModel.run() } + + Button { + domainFieldFocused = false + viewModel.run() + } label: { + Text("Run") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.trimmedDomain.isEmpty) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Paste domains separated by new lines or commas.") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + + TextField( + "example.com\napple.com, openai.com", + text: $viewModel.bulkInput, + axis: .vertical + ) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .lineLimit(4...10) + .padding(12) + .background(Color(.systemGray6)) + .cornerRadius(8) + + Button { + domainFieldFocused = false + viewModel.runBulkLookup() + } label: { + Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) + } } - .buttonStyle(.borderedProminent) - .disabled(viewModel.trimmedDomain.isEmpty) } .padding(.vertical, 16) } @@ -236,8 +288,13 @@ struct ContentView: View { .font(.system(.body)) .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) } - Button { - shareResults() + Menu { + Button("Export TXT") { + shareSingleResults(asCSV: false) + } + Button("Export CSV") { + shareSingleResults(asCSV: true) + } } label: { Image(systemName: "square.and.arrow.up") .font(.system(.body)) @@ -247,6 +304,33 @@ struct ContentView: View { } } + private var batchSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Spacer() + if !viewModel.currentBatchResultEntries.isEmpty { + Menu { + Button("Export Batch TXT") { + shareBatchResults(asCSV: false) + } + Button("Export Batch CSV") { + shareBatchResults(asCSV: true) + } + } label: { + Label("Export", systemImage: "square.and.arrow.up") + .font(.system(.caption, design: .monospaced)) + } + .buttonStyle(.bordered) + } + } + + BatchResultsView( + viewModel: viewModel, + title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results" + ) + } + } + private var recentSearchesSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -307,29 +391,33 @@ struct ContentView: View { return ports } - private func shareResults() { - let text = viewModel.exportText() - let dateFmt = DateFormatter() - dateFmt.dateFormat = "yyyyMMdd_HHmmss" - let timestamp = dateFmt.string(from: Date()) - let filename = "\(timestamp)_domaindigresults.txt" - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - - do { - try text.write(to: tempURL, atomically: true, encoding: .utf8) - } catch { - return - } + private func shareSingleResults(asCSV: Bool) { + let (filename, contents) = exportPayload( + prefix: "domaindig_single", + text: viewModel.exportText(), + csv: viewModel.exportCSV(), + asCSV: asCSV + ) + ExportPresenter.share(filename: filename, contents: contents) + } - let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.keyWindow?.rootViewController else { return } - var presenter = rootVC - while let presented = presenter.presentedViewController { - presenter = presented - } - activityVC.popoverPresentationController?.sourceView = presenter.view - presenter.present(activityVC, animated: true) + private func shareBatchResults(asCSV: Bool) { + let (filename, contents) = exportPayload( + prefix: "domaindig_batch", + text: viewModel.exportBatchText(), + csv: viewModel.exportBatchCSV(), + asCSV: asCSV + ) + ExportPresenter.share(filename: filename, contents: contents) + } + + private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let timestamp = formatter.string(from: Date()) + let fileExtension = asCSV ? "csv" : "txt" + let filename = "\(timestamp)_\(prefix).\(fileExtension)" + return (filename, asCSV ? csv : text) } } @@ -387,17 +475,20 @@ struct DomainDiffView: View { let title: String let sections: [DomainDiffSection] let showsUnchanged: Bool + @State private var collapsedSections = Set() private var filteredSections: [DomainDiffSection] { - guard !showsUnchanged else { return sections } + guard showsUnchanged else { + return sections + .map { section in + DomainDiffSection( + title: section.title, + items: section.items.filter(\.hasChanges) + ) + } + .filter { !$0.items.isEmpty } + } return sections - .map { section in - DomainDiffSection( - title: section.title, - items: section.items.filter { $0.changeType != .unchanged } - ) - } - .filter { !$0.items.isEmpty } } var body: some View { @@ -408,34 +499,63 @@ struct DomainDiffView: View { } else { ForEach(filteredSections) { section in CardView(allowsHorizontalScroll: false) { - Text(section.title) - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - ForEach(section.items) { item in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(item.label) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - Spacer() - Text(changeLabel(for: item.changeType)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(changeColor(for: item.changeType)) - } - if let oldValue = item.oldValue { - Text("Old: \(oldValue)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if let newValue = item.newValue { - Text("New: \(newValue)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) + DisclosureGroup(isExpanded: binding(for: section)) { + let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges) + + ForEach(visibleItems) { item in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.label) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Text(changeLabel(for: item.changeType)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(changeColor(for: item.changeType)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(changeColor(for: item.changeType).opacity(0.16)) + .clipShape(Capsule()) + } + + if let oldValue = item.oldValue { + VStack(alignment: .leading, spacing: 2) { + Text("Old") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(oldValue) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + if let newValue = item.newValue { + VStack(alignment: .leading, spacing: 2) { + Text("New") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(newValue) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(item.hasChanges ? .primary : .secondary) + .textSelection(.enabled) + } + } } + .padding(10) + .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25)) + .cornerRadius(8) + } + } label: { + HStack { + Text(section.title) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + Spacer() + Text(section.hasChanges ? "Changed" : "Unchanged") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(section.hasChanges ? .yellow : .secondary) } } } @@ -469,6 +589,19 @@ struct DomainDiffView: View { return .secondary } } + + private func binding(for section: DomainDiffSection) -> Binding { + Binding( + get: { !collapsedSections.contains(section.id) }, + set: { isExpanded in + if isExpanded { + collapsedSections.remove(section.id) + } else { + collapsedSections.insert(section.id) + } + } + ) + } } struct TrackedDomainDetailHeaderView: View { diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift index fa3d8b5..65ea5a7 100644 --- a/DomainDig/DomainDiffService.swift +++ b/DomainDig/DomainDiffService.swift @@ -13,12 +13,20 @@ struct DomainDiffItem: Identifiable, Equatable { let changeType: DiffChangeType let oldValue: String? let newValue: String? + + var hasChanges: Bool { + changeType != .unchanged + } } struct DomainDiffSection: Identifiable, Equatable { let id = UUID() let title: String let items: [DomainDiffItem] + + var hasChanges: Bool { + items.contains(where: \.hasChanges) + } } enum DomainDiffService { @@ -105,13 +113,15 @@ enum DomainDiffService { private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? { let oldValue = normalized(oldValue) let newValue = normalized(newValue) + let normalizedOldValue = comparisonValue(oldValue) + let normalizedNewValue = comparisonValue(newValue) guard oldValue != nil || newValue != nil else { return nil } let changeType: DiffChangeType - switch (oldValue, newValue) { + switch (normalizedOldValue, normalizedNewValue) { case let (old?, new?) where old == new: changeType = .unchanged case (nil, _?): @@ -129,7 +139,11 @@ enum DomainDiffService { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } - return value.lowercased() + return value + } + + private static func comparisonValue(_ value: String?) -> String? { + value?.lowercased() } private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? { diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 4a20338..82ec540 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -163,6 +163,7 @@ extension HistoryEntry { @Observable final class DomainViewModel { var domain: String = "" + var bulkInput: String = "" var dnsSections: [DNSSection] = [] var dnsLoading = false @@ -221,6 +222,12 @@ final class DomainViewModel { private(set) var currentChangeSummary: DomainChangeSummary? private(set) var refreshingTrackedDomainID: UUID? private(set) var rerunNavigationToken = UUID() + private(set) var batchResults: [BatchLookupResult] = [] + private(set) var batchLookupSource: BatchLookupSource = .manual + private(set) var batchCurrentDomain: String? + private(set) var batchCompletedCount = 0 + private(set) var batchTotalCount = 0 + private(set) var batchLookupRunning = false private var lookupTask: Task? private var customPortScanTask: Task? @@ -239,7 +246,7 @@ final class DomainViewModel { var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains() private static let historyKey = "lookupHistory" - private static let maxHistory = 50 + private static let maxHistory = 250 var history: [HistoryEntry] = { guard let data = UserDefaults.standard.data(forKey: historyKey), let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { @@ -247,6 +254,13 @@ final class DomainViewModel { } return entries }() + var historySearchText = "" + var historyDateFilter: HistoryDateFilter = .all + var historyChangeFilter: ChangeFilterOption = .all + var historySortOption: HistorySortOption = .newest + var watchlistSearchText = "" + var watchlistFilter: WatchlistFilterOption = .all + var watchlistSortOption: WatchlistSortOption = .pinned var trimmedDomain: String { domain @@ -282,14 +296,73 @@ final class DomainViewModel { } var sortedTrackedDomains: [TrackedDomain] { - trackedDomains.sorted { - if $0.isPinned != $1.isPinned { - return $0.isPinned && !$1.isPinned + sortedTrackedDomains(from: trackedDomains, using: .pinned) + } + + var filteredHistory: [HistoryEntry] { + let calendar = Calendar.current + let now = Date() + let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines) + + return history + .lazy + .filter { entry in + query.isEmpty || entry.domain.localizedCaseInsensitiveContains(query) + } + .filter { entry in + switch self.historyDateFilter { + case .today: + return calendar.isDate(entry.timestamp, inSameDayAs: now) + case .last7Days: + guard let startDate = calendar.date(byAdding: .day, value: -7, to: now) else { return true } + return entry.timestamp >= startDate + case .all: + return true + } + } + .filter { entry in + switch self.historyChangeFilter { + case .all: + return true + case .changed: + return entry.changeSummary?.hasChanges == true + case .unchanged: + return entry.changeSummary?.hasChanges != true + } + } + .sorted(by: historySortPredicate) + } + + var filteredTrackedDomains: [TrackedDomain] { + let query = watchlistSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered = trackedDomains.filter { trackedDomain in + if !query.isEmpty, !trackedDomain.domain.localizedCaseInsensitiveContains(query) { + return false } - if $0.updatedAt != $1.updatedAt { - return $0.updatedAt > $1.updatedAt + + switch watchlistFilter { + case .all: + return true + case .pinnedOnly: + return trackedDomain.isPinned + case .changedOnly: + return trackedDomain.lastChangeSummary?.hasChanges == true } - return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending + } + + return sortedTrackedDomains(from: filtered, using: watchlistSortOption) + } + + var batchProgressLabel: String { + guard batchTotalCount > 0 else { return "No active batch" } + let domainLabel = batchCurrentDomain ?? "Preparing" + return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)" + } + + var currentBatchResultEntries: [HistoryEntry] { + batchResults.compactMap { result in + guard let historyEntryID = result.historyEntryID else { return nil } + return history.first(where: { $0.id == historyEntryID }) } } @@ -303,13 +376,11 @@ final class DomainViewModel { } var trackingLimitMessage: String? { - guard currentTrackedDomain == nil else { return nil } - guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil } - return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade." + nil } var canTrackCurrentDomain: Bool { - currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) + true } var resolverDisplayName: String { @@ -528,6 +599,11 @@ final class DomainViewModel { persistHistory() } + func removeHistoryEntries(withIDs ids: [UUID]) { + history.removeAll { ids.contains($0.id) } + persistHistory() + } + func clearHistory() { history.removeAll() persistHistory() @@ -554,32 +630,77 @@ final class DomainViewModel { currentDiffSections = [] currentChangeSummary = nil refreshingTrackedDomainID = nil + clearBatchState() clearLookupState() } func run() { let target = trimmedDomain guard !target.isEmpty else { return } + clearBatchState() + let lookupID = beginLookup(for: target) + + lookupTask = Task { [weak self] in + guard let self else { return } + _ = await self.performLookup(domain: target, lookupID: lookupID) + } + } + + func runBulkLookup() { + let domains = parsedDomains(from: bulkInput) + guard !domains.isEmpty else { return } + + clearBatchState() + batchLookupSource = .manual + batchTotalCount = domains.count + batchLookupRunning = true + batchResults = domains.map { + BatchLookupResult( + domain: $0, + historyEntryID: nil, + availability: nil, + primaryIP: nil, + quickStatus: "Pending", + timestamp: Date(), + status: .pending + ) + } lookupTask?.cancel() customPortScanTask?.cancel() - let lookupID = UUID() - activeLookupID = lookupID - lookupStartedAt = Date() - lastLookupDurationMs = nil - addRecentSearch(target) - searchedDomain = target - hasRun = true - currentDiffSections = [] - currentChangeSummary = nil - clearLookupState() - setAllLoadingStates(true) - customPortScanLoading = false + lookupTask = Task { [weak self] in + guard let self else { return } + await self.runBatchLookup(domains: domains, source: .manual) + } + } + + func refreshAllTrackedDomains() { + let domains = sortedTrackedDomains.map(\.domain) + guard !domains.isEmpty else { return } + + clearBatchState() + batchLookupSource = .watchlistRefresh + batchTotalCount = domains.count + batchLookupRunning = true + batchResults = domains.map { + BatchLookupResult( + domain: $0, + historyEntryID: nil, + availability: nil, + primaryIP: nil, + quickStatus: "Pending", + timestamp: Date(), + status: .pending + ) + } + + lookupTask?.cancel() + customPortScanTask?.cancel() lookupTask = Task { [weak self] in guard let self else { return } - await self.performLookup(domain: target, lookupID: lookupID) + await self.runBatchLookup(domains: domains, source: .watchlistRefresh) } } @@ -620,19 +741,75 @@ final class DomainViewModel { ) } - private func performLookup(domain: String, lookupID: UUID) async { + func exportCSV() -> String { + Self.formatCSV(from: [currentSnapshot]) + } + + func exportBatchText() -> String { + Self.formatBatchExportText( + title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export", + entries: currentBatchResultEntries.map { entry in + ( + snapshot: entry.snapshot, + trackedDomain: trackedDomains.first(where: { tracked in + tracked.id == entry.trackedDomainID || + tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame + }), + changeSummary: entry.changeSummary, + diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] + ) + } + ) + } + + func exportBatchCSV() -> String { + Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot)) + } + + func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { + Self.formatCSV(from: exportSnapshots(for: domains)) + } + + func exportTrackedDomainsText(domains: [TrackedDomain]) -> String { + let latestEntries = latestSnapshots(for: domains) + return Self.formatBatchExportText( + title: "Tracked Domains Export", + entries: domains.map { trackedDomain in + if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) { + return ( + snapshot: entry.snapshot, + trackedDomain: trackedDomain, + changeSummary: entry.changeSummary, + diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] + ) + } + + return ( + snapshot: placeholderSnapshot(for: trackedDomain), + trackedDomain: trackedDomain, + changeSummary: trackedDomain.lastChangeSummary, + diffSections: [] + ) + } + ) + } + + private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { await withTaskGroup(of: Void.self) { group in group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } + } + + await withTaskGroup(of: Void.self) { group in group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } } - guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] let primaryIP = primaryIPAddress(from: dnsSections) @@ -647,7 +824,7 @@ final class DomainViewModel { } } - guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } if availabilityResult?.status == .registered { await runSuggestions(domain: domain, lookupID: lookupID) @@ -656,10 +833,11 @@ final class DomainViewModel { suggestionsLoading = false } - guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } - saveHistoryEntry(replaceLatest: false) + let entry = saveHistoryEntry(replaceLatest: false) refreshingTrackedDomainID = nil + return entry } private func runDNS(domain: String, lookupID: UUID) async { @@ -868,7 +1046,7 @@ final class DomainViewModel { case let .success(results): customPortResults = results customPortScanError = nil - saveHistoryEntry(replaceLatest: true) + _ = saveHistoryEntry(replaceLatest: true) case let .empty(message): customPortResults = [] customPortScanError = message @@ -904,8 +1082,9 @@ final class DomainViewModel { } } - private func saveHistoryEntry(replaceLatest: Bool) { - guard !searchedDomain.isEmpty else { return } + @discardableResult + private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? { + guard !searchedDomain.isEmpty else { return nil } let trackedDomainID = trackedDomain(for: searchedDomain)?.id let timestamp = Date() @@ -969,6 +1148,7 @@ final class DomainViewModel { changeSummary: changeSummary ) persistHistory() + return entry } private func persistHistory() { @@ -1027,7 +1207,13 @@ final class DomainViewModel { } private func normalizedDomain(_ domain: String) -> String { - domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" } private func linkTrackedDomainHistory(for domain: String) { @@ -1064,6 +1250,110 @@ final class DomainViewModel { UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) } + private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID { + if cancelExistingTask { + lookupTask?.cancel() + } + customPortScanTask?.cancel() + + let lookupID = UUID() + activeLookupID = lookupID + lookupStartedAt = Date() + lastLookupDurationMs = nil + addRecentSearch(target) + searchedDomain = target + hasRun = true + currentDiffSections = [] + currentChangeSummary = nil + clearLookupState() + setAllLoadingStates(true) + customPortScanLoading = false + return lookupID + } + + private func clearBatchState() { + batchResults = [] + batchLookupSource = .manual + batchCurrentDomain = nil + batchCompletedCount = 0 + batchTotalCount = 0 + batchLookupRunning = false + } + + private func parsedDomains(from input: String) -> [String] { + let separators = CharacterSet(charactersIn: ",\n") + var seen = Set() + + return input + .components(separatedBy: separators) + .map(normalizedDomain) + .filter { !$0.isEmpty } + .filter { seen.insert($0).inserted } + } + + private func runBatchLookup(domains: [String], source: BatchLookupSource) async { + for (index, domain) in domains.enumerated() { + guard !Task.isCancelled else { break } + + batchCurrentDomain = domain + if source == .watchlistRefresh { + refreshingTrackedDomainID = trackedDomain(for: domain)?.id + } + updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) + + let lookupID = beginLookup(for: domain, cancelExistingTask: false) + let entry = await performLookup(domain: domain, lookupID: lookupID) + + if let entry { + updateBatchResult( + domain: domain, + status: .completed, + quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged", + entry: entry, + errorMessage: nil + ) + } else { + updateBatchResult( + domain: domain, + status: .failed, + quickStatus: "Failed", + entry: nil, + errorMessage: "Lookup cancelled" + ) + } + + batchCompletedCount = index + 1 + } + + batchLookupRunning = false + batchCurrentDomain = nil + refreshingTrackedDomainID = nil + } + + private func updateBatchResult( + domain: String, + status: BatchLookupStatus, + quickStatus: String, + entry: HistoryEntry?, + errorMessage: String? + ) { + guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { + return + } + + batchResults[index] = BatchLookupResult( + id: batchResults[index].id, + domain: domain, + historyEntryID: entry?.id, + availability: entry?.availabilityResult?.status, + primaryIP: entry?.primaryIP, + quickStatus: quickStatus, + timestamp: entry?.timestamp ?? Date(), + status: status, + errorMessage: errorMessage + ) + } + private func clearLookupState() { dnsSections = [] dnsError = nil @@ -1139,6 +1429,12 @@ final class DomainViewModel { .map { $0 } } + func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] { + domains.compactMap { trackedDomain in + recentSnapshots(for: trackedDomain, limit: 1).first + } + } + func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] { let snapshots = recentSnapshots(for: trackedDomain, limit: 2) guard snapshots.count == 2 else { return [] } @@ -1161,6 +1457,62 @@ final class DomainViewModel { return siblings.first?.snapshot } + func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? { + guard let historyEntryID = batchResult.historyEntryID else { return nil } + return history.first(where: { $0.id == historyEntryID }) + } + + private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { + let latestEntries = latestSnapshots(for: domains) + + return domains.map { trackedDomain in + if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) { + return entry.snapshot + } + return placeholderSnapshot(for: trackedDomain) + } + } + + private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { + LookupSnapshot( + historyEntryID: trackedDomain.lastSnapshotID, + domain: trackedDomain.domain, + timestamp: trackedDomain.updatedAt, + trackedDomainID: trackedDomain.id, + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: nil, + dnsSections: [], + dnsError: nil, + availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown), + suggestions: [], + sslInfo: nil, + sslError: nil, + hstsPreloaded: nil, + httpHeaders: [], + httpSecurityGrade: nil, + httpStatusCode: nil, + httpResponseTimeMs: nil, + httpProtocol: nil, + http3Advertised: false, + httpHeadersError: nil, + reachabilityResults: [], + reachabilityError: nil, + ipGeolocation: nil, + ipGeolocationError: nil, + emailSecurity: nil, + emailSecurityError: nil, + ptrRecord: nil, + ptrError: nil, + redirectChain: [], + redirectChainError: nil, + portScanResults: [], + portScanError: nil, + changeSummary: trackedDomain.lastChangeSummary, + isLive: false + ) + } + private static func loadTrackedDomains() -> [TrackedDomain] { let defaults = UserDefaults.standard let decoder = JSONDecoder() @@ -1196,6 +1548,47 @@ final class DomainViewModel { } } + private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool { + switch historySortOption { + case .newest: + return lhs.timestamp > rhs.timestamp + case .oldest: + return lhs.timestamp < rhs.timestamp + case .domain: + let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) + if domainOrder != .orderedSame { + return domainOrder == .orderedAscending + } + return lhs.timestamp > rhs.timestamp + } + } + + private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] { + domains.sorted { lhs, rhs in + switch sortOption { + case .pinned: + if lhs.isPinned != rhs.isPinned { + return lhs.isPinned && !rhs.isPinned + } + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending + case .recentlyUpdated: + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending + case .alphabetical: + let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) + if domainOrder != .orderedSame { + return domainOrder == .orderedAscending + } + return lhs.updatedAt > rhs.updatedAt + } + } + } + static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { [ SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), @@ -1369,6 +1762,64 @@ final class DomainViewModel { } } + static func formatBatchExportText( + title: String, + entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])] + ) -> String { + guard !entries.isEmpty else { + return "\(title)\nNo results available." + } + + var lines = [title, String(repeating: "=", count: title.count), ""] + for (index, entry) in entries.enumerated() { + if index > 0 { + lines.append("") + lines.append(String(repeating: "=", count: 48)) + lines.append("") + } + + lines.append( + formatExportText( + from: entry.snapshot, + trackedDomain: entry.trackedDomain, + changeSummary: entry.changeSummary, + diffSections: entry.diffSections + ) + ) + } + return lines.joined(separator: "\n") + } + + static func formatCSV(from snapshots: [LookupSnapshot]) -> String { + let headers = [ + "domain", + "availability", + "primary_ip", + "redirect_target", + "tls_status", + "http_status_grade", + "email_security_summary", + "last_updated" + ] + + let rows = snapshots.map { snapshot in + [ + snapshot.domain, + availabilityLabel(snapshot.availabilityResult?.status), + primaryIPAddress(from: snapshot) ?? "", + finalRedirectTarget(from: snapshot) ?? "", + httpsSummary(from: snapshot), + httpStatusGradeSummary(from: snapshot), + emailSummary(from: snapshot), + csvDateFormatter.string(from: snapshot.timestamp) + ] + } + + return ([headers] + rows) + .map { row in row.map(csvEscaped).joined(separator: ",") } + .joined(separator: "\n") + } + static func formatExportText( from snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, @@ -1404,8 +1855,9 @@ final class DomainViewModel { lines.append(" \(item.label): \(item.value)") } if let changeSummary { - lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")") + lines.append(" Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")") lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") + lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))") } } @@ -1429,7 +1881,7 @@ final class DomainViewModel { for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) { lines.append(" \(section.title)") for item in section.items where item.changeType != .unchanged { - lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")") + lines.append(" [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")") } } } @@ -1596,6 +2048,14 @@ final class DomainViewModel { snapshot.redirectChain.last?.url } + private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String { + let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 } + if !parts.isEmpty { + return parts.joined(separator: " / ") + } + return snapshot.httpHeadersError ?? "Unavailable" + } + private static func httpsSummary(from snapshot: LookupSnapshot) -> String { if snapshot.sslInfo != nil { return "Valid" @@ -1665,6 +2125,17 @@ final class DomainViewModel { formatter.timeStyle = .short return formatter }() + + private static let csvDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static func csvEscaped(_ value: String) -> String { + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } } private extension String { diff --git a/DomainDig/ExportPresenter.swift b/DomainDig/ExportPresenter.swift new file mode 100644 index 0000000..82ec7ae --- /dev/null +++ b/DomainDig/ExportPresenter.swift @@ -0,0 +1,28 @@ +import SwiftUI +import UIKit + +enum ExportPresenter { + static func share(filename: String, contents: String) { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + + do { + try contents.write(to: url, atomically: true, encoding: .utf8) + } catch { + return + } + + let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.keyWindow?.rootViewController else { + return + } + + var presenter = rootViewController + while let presentedViewController = presenter.presentedViewController { + presenter = presentedViewController + } + + activityController.popoverPresentationController?.sourceView = presenter.view + presenter.present(activityController, animated: true) + } +} diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index bb9d255..35ec723 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -14,13 +14,13 @@ struct HistoryView: View { var body: some View { List { - if viewModel.history.isEmpty { + if viewModel.filteredHistory.isEmpty { Text("No lookup history") .font(.system(.callout, design: .monospaced)) .foregroundStyle(.secondary) .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { - ForEach(viewModel.history) { entry in + ForEach(viewModel.filteredHistory) { entry in NavigationLink { HistoryDetailView(viewModel: viewModel, entry: entry) } label: { @@ -28,6 +28,11 @@ struct HistoryView: View { Text(entry.domain) .font(.system(.callout, design: .monospaced)) .foregroundStyle(.primary) + if let summary = entry.changeSummary { + Text(summary.hasChanges ? "Changed" : "Unchanged") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(summary.hasChanges ? .yellow : .green) + } HStack(spacing: 8) { Text(dateFormatter.string(from: entry.timestamp)) Text("Snapshot") @@ -42,23 +47,40 @@ struct HistoryView: View { } .listRowBackground(Color(.systemGray6).opacity(0.5)) } - .onDelete { offsets in - viewModel.removeHistoryEntries(at: offsets) - } + .onDelete(perform: deleteFilteredHistoryEntries) } } .scrollContentBackground(.hidden) .background(Color.black) .navigationTitle("History") + .searchable(text: $viewModel.historySearchText, prompt: "Search domains") .toolbar { if !viewModel.history.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { Menu { + Picker("Date Range", selection: $viewModel.historyDateFilter) { + ForEach(HistoryDateFilter.allCases) { option in + Text(option.title).tag(option) + } + } + + Picker("Change Filter", selection: $viewModel.historyChangeFilter) { + ForEach(ChangeFilterOption.allCases) { option in + Text(option.title).tag(option) + } + } + + Picker("Sort", selection: $viewModel.historySortOption) { + ForEach(HistorySortOption.allCases) { option in + Text(option.title).tag(option) + } + } + Button("Clear All", role: .destructive) { showClearAllConfirmation = true } } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "line.3.horizontal.decrease.circle") } EditButton() @@ -78,6 +100,11 @@ struct HistoryView: View { } .preferredColorScheme(.dark) } + + private func deleteFilteredHistoryEntries(at offsets: IndexSet) { + let ids = offsets.map { viewModel.filteredHistory[$0].id } + viewModel.removeHistoryEntries(withIDs: ids) + } } struct HistoryDetailView: View { diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index e571da9..d11e879 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -54,6 +54,147 @@ struct DomainChangeSummary: Codable, Equatable { let generatedAt: Date } +enum BatchLookupSource: String, Codable { + case manual + case watchlistRefresh +} + +enum BatchLookupStatus: String, Codable { + case pending + case running + case completed + case failed +} + +struct BatchLookupResult: Identifiable, Codable, Equatable { + let id: UUID + let domain: String + let historyEntryID: UUID? + let availability: DomainAvailabilityStatus? + let primaryIP: String? + let quickStatus: String + let timestamp: Date + let status: BatchLookupStatus + let errorMessage: String? + + init( + id: UUID = UUID(), + domain: String, + historyEntryID: UUID?, + availability: DomainAvailabilityStatus?, + primaryIP: String?, + quickStatus: String, + timestamp: Date, + status: BatchLookupStatus, + errorMessage: String? = nil + ) { + self.id = id + self.domain = domain + self.historyEntryID = historyEntryID + self.availability = availability + self.primaryIP = primaryIP + self.quickStatus = quickStatus + self.timestamp = timestamp + self.status = status + self.errorMessage = errorMessage + } +} + +enum HistoryDateFilter: String, CaseIterable, Identifiable { + case today + case last7Days + case all + + var id: String { rawValue } + + var title: String { + switch self { + case .today: + return "Today" + case .last7Days: + return "Last 7 Days" + case .all: + return "All" + } + } +} + +enum ChangeFilterOption: String, CaseIterable, Identifiable { + case all + case changed + case unchanged + + var id: String { rawValue } + + var title: String { + switch self { + case .all: + return "All" + case .changed: + return "Changed" + case .unchanged: + return "Unchanged" + } + } +} + +enum HistorySortOption: String, CaseIterable, Identifiable { + case newest + case oldest + case domain + + var id: String { rawValue } + + var title: String { + switch self { + case .newest: + return "Newest" + case .oldest: + return "Oldest" + case .domain: + return "Domain A-Z" + } + } +} + +enum WatchlistFilterOption: String, CaseIterable, Identifiable { + case all + case pinnedOnly + case changedOnly + + var id: String { rawValue } + + var title: String { + switch self { + case .all: + return "All" + case .pinnedOnly: + return "Pinned Only" + case .changedOnly: + return "Changed Only" + } + } +} + +enum WatchlistSortOption: String, CaseIterable, Identifiable { + case pinned + case recentlyUpdated + case alphabetical + + var id: String { rawValue } + + var title: String { + switch self { + case .pinned: + return "Pinned" + case .recentlyUpdated: + return "Recently Updated" + case .alphabetical: + return "Alphabetical" + } + } +} + enum PremiumCapability: String, Codable { case unlimitedTrackedDomains case automatedMonitoring diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift index 698a2d2..0987dae 100644 --- a/DomainDig/PremiumAccessService.swift +++ b/DomainDig/PremiumAccessService.swift @@ -1,27 +1,15 @@ import Foundation enum PremiumAccessService { - static let freeTrackedDomainLimit = 3 - static func hasAccess(to capability: PremiumCapability) -> Bool { - switch capability { - case .unlimitedTrackedDomains, - .automatedMonitoring, - .pushAlerts, - .batchTracking, - .advancedExports: - return false - } + true } static func trackedDomainLimitMessage(currentCount: Int) -> String? { - guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else { - return nil - } - return "More tracked domains will be available in a future Pro upgrade." + nil } static func canAddTrackedDomain(currentCount: Int) -> Bool { - hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit + true } } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index aea8707..b01b8f9 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -6,7 +6,27 @@ struct WatchlistView: View { var body: some View { List { - if viewModel.sortedTrackedDomains.isEmpty { + if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { + Section("Refresh Progress") { + VStack(alignment: .leading, spacing: 8) { + if viewModel.batchLookupRunning { + ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1))) + .tint(.cyan) + Text(viewModel.batchProgressLabel) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + ForEach(viewModel.batchResults.prefix(5)) { result in + BatchResultRowView(result: result) + } + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + + if viewModel.filteredTrackedDomains.isEmpty { Section { VStack(alignment: .leading, spacing: 8) { Text("No tracked domains yet") @@ -30,7 +50,7 @@ struct WatchlistView: View { } Section { - ForEach(viewModel.sortedTrackedDomains) { trackedDomain in + ForEach(viewModel.filteredTrackedDomains) { trackedDomain in NavigationLink { TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) } label: { @@ -83,7 +103,7 @@ struct WatchlistView: View { } .listRowBackground(Color(.systemGray6).opacity(0.5)) } - .onDelete(perform: viewModel.deleteTrackedDomains) + .onDelete(perform: deleteFilteredTrackedDomains) } header: { Text("Tracked Domains") } @@ -92,9 +112,40 @@ struct WatchlistView: View { .scrollContentBackground(.hidden) .background(Color.black) .navigationTitle("Watchlist") + .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains") .toolbar { - if !viewModel.sortedTrackedDomains.isEmpty { - EditButton() + if !viewModel.filteredTrackedDomains.isEmpty { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Picker("Filter", selection: $viewModel.watchlistFilter) { + ForEach(WatchlistFilterOption.allCases) { option in + Text(option.title).tag(option) + } + } + + Picker("Sort", selection: $viewModel.watchlistSortOption) { + ForEach(WatchlistSortOption.allCases) { option in + Text(option.title).tag(option) + } + } + + Button("Refresh All") { + viewModel.refreshAllTrackedDomains() + } + + Button("Export TXT") { + shareTrackedDomains(asCSV: false) + } + + Button("Export CSV") { + shareTrackedDomains(asCSV: true) + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + + EditButton() + } } } .onChange(of: viewModel.rerunNavigationToken) { _, _ in @@ -102,6 +153,23 @@ struct WatchlistView: View { } .preferredColorScheme(.dark) } + + private func deleteFilteredTrackedDomains(at offsets: IndexSet) { + let domains = offsets.map { viewModel.filteredTrackedDomains[$0] } + domains.forEach(viewModel.deleteTrackedDomain) + } + + private func shareTrackedDomains(asCSV: Bool) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let timestamp = formatter.string(from: Date()) + let fileExtension = asCSV ? "csv" : "txt" + let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)" + let contents = asCSV + ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains) + : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains) + ExportPresenter.share(filename: filename, contents: contents) + } } struct WatchlistRowView: View { -- cgit v1.2.3