/ RacerTracer / Views / LinksAnalysisView.swift
LinksAnalysisView.swift
  1  //
  2  //  LinksAnalysisView.swift
  3  //  RacerTracer
  4  //
  5  //  Created by Alexander Kunau on 29.09.25.
  6  //
  7  
  8  import SwiftUI
  9  import Charts
 10  
 11  struct LinksAnalysisView: View {
 12      @StateObject private var deepCrawlService = DeepCrawlService()
 13      @State private var websiteURL = ""
 14      @State private var selectedLinkType: LinkAnalysisType = .`internal`
 15      @State private var isAnalyzing = false
 16      
 17      var body: some View {
 18          VStack(spacing: 24) {
 19              // Header with Input
 20              LinksInputSection(
 21                  websiteURL: $websiteURL,
 22                  onAnalyze: startLinksAnalysis,
 23                  isAnalyzing: isAnalyzing
 24              )
 25              
 26              if isAnalyzing {
 27                  CrawlProgressView(
 28                      progress: deepCrawlService.crawlProgress,
 29                      crawledPages: deepCrawlService.crawledPages.count
 30                  )
 31              } else if !deepCrawlService.internalLinks.isEmpty || !deepCrawlService.externalLinks.isEmpty {
 32                  LinksResultsView(
 33                      crawlService: deepCrawlService,
 34                      selectedLinkType: $selectedLinkType
 35                  )
 36              } else {
 37                  LinksStartView()
 38              }
 39          }
 40          .padding(24)
 41          .navigationTitle("Links Analysis")
 42      }
 43      
 44      private func startLinksAnalysis() {
 45          guard !websiteURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
 46          
 47          isAnalyzing = true
 48          
 49          Task {
 50              do {
 51                  try await deepCrawlService.startDeepCrawl(startURL: websiteURL)
 52                  await MainActor.run {
 53                      isAnalyzing = false
 54                  }
 55              } catch {
 56                  await MainActor.run {
 57                      isAnalyzing = false
 58                      deepCrawlService.errorMessage = error.localizedDescription
 59                  }
 60              }
 61          }
 62      }
 63  }
 64  
 65  // MARK: - Input Section
 66  
 67  struct LinksInputSection: View {
 68      @Binding var websiteURL: String
 69      let onAnalyze: () -> Void
 70      let isAnalyzing: Bool
 71      
 72      var body: some View {
 73          VStack(alignment: .leading, spacing: 16) {
 74              Text("Deep Links Analysis")
 75                  .font(.title2)
 76                  .fontWeight(.semibold)
 77              
 78              Text("Perform deep crawling to discover and analyze all internal and external links on a website")
 79                  .font(.subheadline)
 80                  .foregroundColor(.secondary)
 81              
 82              HStack {
 83                  TextField("Website URL for deep links analysis", text: $websiteURL)
 84                      .textFieldStyle(RoundedBorderTextFieldStyle())
 85                      .disabled(isAnalyzing)
 86                  
 87                  Button(action: onAnalyze) {
 88                      HStack {
 89                          if isAnalyzing {
 90                              ProgressView()
 91                                  .scaleEffect(0.8)
 92                          } else {
 93                              Image(systemName: "link")
 94                          }
 95                          Text(isAnalyzing ? "Crawling..." : "Start Analysis")
 96                      }
 97                  }
 98                  .buttonStyle(.borderedProminent)
 99                  .disabled(websiteURL.isEmpty || isAnalyzing)
100              }
101          }
102          .padding(20)
103          .background(Color(NSColor.controlBackgroundColor))
104          .cornerRadius(12)
105      }
106  }
107  
108  // MARK: - Progress View
109  
110  struct CrawlProgressView: View {
111      let progress: Double
112      let crawledPages: Int
113      
114      var body: some View {
115          VStack(spacing: 20) {
116              VStack(spacing: 12) {
117                  ProgressView(value: progress)
118                      .progressViewStyle(LinearProgressViewStyle())
119                      .frame(height: 8)
120                  
121                  Text("Crawling Progress: \(Int(progress * 100))%")
122                      .font(.headline)
123                      .foregroundColor(.secondary)
124              }
125              
126              HStack(spacing: 40) {
127                  VStack(spacing: 4) {
128                      Text("\(crawledPages)")
129                          .font(.title2)
130                          .fontWeight(.bold)
131                          .foregroundColor(.blue)
132                      
133                      Text("Pages Crawled")
134                          .font(.caption)
135                          .foregroundColor(.secondary)
136                  }
137                  
138                  VStack(spacing: 4) {
139                      Text("Depth \(UserDefaults.standard.object(forKey: "CrawlingDepth") as? Int ?? 3)")
140                          .font(.title2)
141                          .fontWeight(.bold)
142                          .foregroundColor(.green)
143                      
144                      Text("Max Depth")
145                          .font(.caption)
146                          .foregroundColor(.secondary)
147                  }
148              }
149              
150              VStack(spacing: 8) {
151                  Text("• Discovering internal links")
152                  Text("• Analyzing external links")
153                  Text("• Processing link relationships")
154                  Text("• Generating link statistics")
155              }
156              .font(.subheadline)
157              .foregroundColor(.secondary)
158          }
159          .frame(maxWidth: .infinity)
160          .frame(height: 250)
161          .background(Color(NSColor.controlBackgroundColor))
162          .cornerRadius(12)
163      }
164  }
165  
166  // MARK: - Start View
167  
168  struct LinksStartView: View {
169      var body: some View {
170          VStack(spacing: 20) {
171              Image(systemName: "link")
172                  .font(.system(size: 48))
173                  .foregroundColor(.accentColor)
174              
175              Text("Deep Links Analysis")
176                  .font(.title2)
177                  .fontWeight(.semibold)
178              
179              Text("Discover and analyze all internal and external links across your website with configurable crawling depth and comprehensive link metrics.")
180                  .font(.subheadline)
181                  .foregroundColor(.secondary)
182                  .multilineTextAlignment(.center)
183          }
184          .frame(maxWidth: .infinity, maxHeight: .infinity)
185          .background(Color(NSColor.controlBackgroundColor))
186          .cornerRadius(12)
187      }
188  }
189  
190  // MARK: - Results View
191  
192  enum LinkAnalysisType: String, CaseIterable {
193      case `internal` = "Internal Links"
194      case external = "External Links"
195      case statistics = "Statistics"
196      case crawlData = "Crawl Data"
197  }
198  
199  struct LinksResultsView: View {
200      @ObservedObject var crawlService: DeepCrawlService
201      @Binding var selectedLinkType: LinkAnalysisType
202      
203      var body: some View {
204          VStack(spacing: 24) {
205              // Overview Cards
206              LinksOverviewCards(crawlService: crawlService)
207              
208              // Link Type Selector
209              Picker("Link Type", selection: $selectedLinkType) {
210                  ForEach(LinkAnalysisType.allCases, id: \.self) { type in
211                      Text(type.rawValue).tag(type)
212                  }
213              }
214              .pickerStyle(SegmentedPickerStyle())
215              
216              // Content based on selection
217              ScrollView {
218                  switch selectedLinkType {
219                  case .`internal`:
220                      InternalLinksSection(links: crawlService.internalLinks)
221                  case .external:
222                      ExternalLinksSection(links: crawlService.externalLinks)
223                  case .statistics:
224                      LinksStatisticsSection(
225                          statistics: crawlService.crawlStatistics,
226                          internalLinks: crawlService.internalLinks,
227                          externalLinks: crawlService.externalLinks
228                      )
229                  case .crawlData:
230                      CrawlDataSection(crawledPages: crawlService.crawledPages)
231                  }
232              }
233              .background(Color(NSColor.controlBackgroundColor))
234              .cornerRadius(12)
235          }
236      }
237  }
238  
239  // MARK: - Overview Cards
240  
241  struct LinksOverviewCards: View {
242      @ObservedObject var crawlService: DeepCrawlService
243      
244      var body: some View {
245          HStack(spacing: 16) {
246              LinkOverviewCard(
247                  title: "Pages Crawled",
248                  value: "\(crawlService.crawledPages.count)",
249                  subtitle: "total pages",
250                  color: .blue
251              )
252              
253              LinkOverviewCard(
254                  title: "Internal Links",
255                  value: "\(crawlService.internalLinks.count)",
256                  subtitle: "within domain",
257                  color: .green
258              )
259              
260              LinkOverviewCard(
261                  title: "External Links", 
262                  value: "\(crawlService.externalLinks.count)",
263                  subtitle: "to other domains",
264                  color: .orange
265              )
266              
267              LinkOverviewCard(
268                  title: "Unique Domains",
269                  value: "\(Set(crawlService.externalLinks.map(\.domain)).count)",
270                  subtitle: "external domains",
271                  color: .purple
272              )
273          }
274      }
275  }
276  
277  struct LinkOverviewCard: View {
278      let title: String
279      let value: String
280      let subtitle: String
281      let color: Color
282      
283      var body: some View {
284          VStack(spacing: 8) {
285              Text(title)
286                  .font(.caption)
287                  .foregroundColor(.secondary)
288              
289              Text(value)
290                  .font(.title2)
291                  .fontWeight(.bold)
292                  .foregroundColor(color)
293              
294              Text(subtitle)
295                  .font(.caption2)
296                  .foregroundColor(.secondary)
297          }
298          .frame(maxWidth: .infinity)
299          .padding(16)
300          .background(Color(NSColor.textBackgroundColor))
301          .cornerRadius(8)
302      }
303  }
304  
305  // MARK: - Internal Links Section
306  
307  struct InternalLinksSection: View {
308      let links: [InternalLink]
309      @State private var searchText = ""
310      @State private var selectedDepth: Int? = nil
311      
312      var body: some View {
313          VStack(alignment: .leading, spacing: 16) {
314              HStack {
315                  Text("Internal Links (\(filteredLinks.count))")
316                      .font(.headline)
317                      .fontWeight(.semibold)
318                  
319                  Spacer()
320                  
321                  Menu("Filter by Depth") {
322                      Button("All Depths") { selectedDepth = nil }
323                      ForEach(1...10, id: \.self) { depth in
324                          Button("Depth \(depth)") { selectedDepth = depth }
325                      }
326                  }
327                  .menuStyle(BorderedButtonMenuStyle())
328              }
329              
330              TextField("Search internal links...", text: $searchText)
331                  .textFieldStyle(RoundedBorderTextFieldStyle())
332              
333              LazyVStack(spacing: 8) {
334                  ForEach(filteredLinks, id: \.id) { link in
335                      LinksAnalysisInternalLinkRow(link: link)
336                  }
337              }
338              .padding(.vertical)
339          }
340          .padding(20)
341      }
342      
343      private var filteredLinks: [InternalLink] {
344          var filtered = links
345          
346          if !searchText.isEmpty {
347              filtered = filtered.filter { 
348                  $0.targetURL.localizedCaseInsensitiveContains(searchText) ||
349                  $0.anchorText.localizedCaseInsensitiveContains(searchText)
350              }
351          }
352          
353          if let depth = selectedDepth {
354              filtered = filtered.filter { $0.depth == depth }
355          }
356          
357          return filtered
358      }
359  }
360  
361  struct LinksAnalysisInternalLinkRow: View {
362      let link: InternalLink
363      
364      var body: some View {
365          VStack(alignment: .leading, spacing: 8) {
366              HStack {
367                  VStack(alignment: .leading, spacing: 4) {
368                      Text(link.targetURL)
369                          .font(.subheadline)
370                          .fontWeight(.medium)
371                          .lineLimit(1)
372                      
373                      if !link.anchorText.isEmpty {
374                          Text("Anchor: \(link.anchorText)")
375                              .font(.caption)
376                              .foregroundColor(.secondary)
377                              .lineLimit(1)
378                      }
379                      
380                      Text("From: \(URL(string: link.sourceURL)?.path ?? link.sourceURL)")
381                          .font(.caption)
382                          .foregroundColor(.blue)
383                          .lineLimit(1)
384                  }
385                  
386                  Spacer()
387                  
388                  VStack(alignment: .trailing, spacing: 4) {
389                      Text("Depth \(link.depth)")
390                          .font(.caption)
391                          .padding(.horizontal, 6)
392                          .padding(.vertical, 2)
393                          .background(Color.blue.opacity(0.2))
394                          .foregroundColor(.blue)
395                          .cornerRadius(4)
396                      
397                      Text(link.linkType.description)
398                          .font(.caption2)
399                          .foregroundColor(.secondary)
400                  }
401              }
402          }
403          .padding(12)
404          .background(Color(NSColor.textBackgroundColor))
405          .cornerRadius(8)
406      }
407  }
408  
409  // MARK: - External Links Section
410  
411  struct ExternalLinksSection: View {
412      let links: [ExternalLink]
413      @State private var searchText = ""
414      @State private var selectedDomain: String? = nil
415      
416      var body: some View {
417          VStack(alignment: .leading, spacing: 16) {
418              HStack {
419                  Text("External Links (\(filteredLinks.count))")
420                      .font(.headline)
421                      .fontWeight(.semibold)
422                  
423                  Spacer()
424                  
425                  Menu("Filter by Domain") {
426                      Button("All Domains") { selectedDomain = nil }
427                      ForEach(topDomains, id: \.self) { domain in
428                          Button(domain) { selectedDomain = domain }
429                      }
430                  }
431                  .menuStyle(BorderedButtonMenuStyle())
432              }
433              
434              TextField("Search external links...", text: $searchText)
435                  .textFieldStyle(RoundedBorderTextFieldStyle())
436              
437              // Domain Statistics
438              DomainStatisticsChart(links: filteredLinks)
439              
440              LazyVStack(spacing: 8) {
441                  ForEach(filteredLinks, id: \.id) { link in
442                      LinksAnalysisExternalLinkRow(link: link)
443                  }
444              }
445              .padding(.vertical)
446          }
447          .padding(20)
448      }
449      
450      private var filteredLinks: [ExternalLink] {
451          var filtered = links
452          
453          if !searchText.isEmpty {
454              filtered = filtered.filter { 
455                  $0.targetURL.localizedCaseInsensitiveContains(searchText) ||
456                  $0.anchorText.localizedCaseInsensitiveContains(searchText) ||
457                  $0.domain.localizedCaseInsensitiveContains(searchText)
458              }
459          }
460          
461          if let domain = selectedDomain {
462              filtered = filtered.filter { $0.domain == domain }
463          }
464          
465          return filtered
466      }
467      
468      private var topDomains: [String] {
469          let domainCounts = Dictionary(grouping: links, by: \.domain)
470              .mapValues { $0.count }
471          
472          return domainCounts.sorted { $0.value > $1.value }
473              .prefix(10)
474              .map(\.key)
475      }
476  }
477  
478  struct LinksAnalysisExternalLinkRow: View {
479      let link: ExternalLink
480      
481      var body: some View {
482          VStack(alignment: .leading, spacing: 8) {
483              HStack {
484                  VStack(alignment: .leading, spacing: 4) {
485                      Text(link.targetURL)
486                          .font(.subheadline)
487                          .fontWeight(.medium)
488                          .lineLimit(1)
489                      
490                      if !link.anchorText.isEmpty {
491                          Text("Anchor: \(link.anchorText)")
492                              .font(.caption)
493                              .foregroundColor(.secondary)
494                              .lineLimit(1)
495                      }
496                      
497                      Text("From: \(URL(string: link.sourceURL)?.path ?? link.sourceURL)")
498                          .font(.caption)
499                          .foregroundColor(.blue)
500                          .lineLimit(1)
501                  }
502                  
503                  Spacer()
504                  
505                  VStack(alignment: .trailing, spacing: 4) {
506                      Text(link.domain)
507                          .font(.caption)
508                          .padding(.horizontal, 6)
509                          .padding(.vertical, 2)
510                          .background(Color.orange.opacity(0.2))
511                          .foregroundColor(.orange)
512                          .cornerRadius(4)
513                      
514                      Text(link.linkType.description)
515                          .font(.caption2)
516                          .foregroundColor(.secondary)
517                  }
518              }
519          }
520          .padding(12)
521          .background(Color(NSColor.textBackgroundColor))
522          .cornerRadius(8)
523      }
524  }
525  
526  // MARK: - Domain Statistics Chart
527  
528  struct DomainStatisticsChart: View {
529      let links: [ExternalLink]
530      
531      var body: some View {
532          VStack(alignment: .leading, spacing: 12) {
533              Text("Top External Domains")
534                  .font(.subheadline)
535                  .fontWeight(.medium)
536              
537              if !domainData.isEmpty {
538                  Chart(domainData, id: \.domain) { data in
539                      BarMark(
540                          x: .value("Count", data.count),
541                          y: .value("Domain", data.domain)
542                      )
543                      .foregroundStyle(Color.orange.gradient)
544                  }
545                  .frame(height: 200)
546              } else {
547                  Text("No external links found")
548                      .font(.caption)
549                      .foregroundColor(.secondary)
550              }
551          }
552      }
553      
554      private var domainData: [DomainCount] {
555          let domainCounts = Dictionary(grouping: links, by: \.domain)
556              .mapValues { $0.count }
557          
558          return domainCounts.sorted { $0.value > $1.value }
559              .prefix(10)
560              .map { DomainCount(domain: $0.key, count: $0.value) }
561      }
562  }
563  
564  struct DomainCount {
565      let domain: String
566      let count: Int
567  }
568  
569  // MARK: - Statistics Section
570  
571  struct LinksStatisticsSection: View {
572      let statistics: CrawlStatistics?
573      let internalLinks: [InternalLink]
574      let externalLinks: [ExternalLink]
575      
576      var body: some View {
577          VStack(alignment: .leading, spacing: 20) {
578              Text("Crawl Statistics")
579                  .font(.headline)
580                  .fontWeight(.semibold)
581              
582              if let stats = statistics {
583                  LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
584                      StatisticCard(title: "Total Pages", value: "\(stats.totalPagesCrawled)", color: .blue)
585                      StatisticCard(title: "Total Links", value: "\(stats.totalLinksFound)", color: .green)
586                      StatisticCard(title: "Internal Links", value: "\(stats.internalLinksCount)", color: .orange)
587                      StatisticCard(title: "External Links", value: "\(stats.externalLinksCount)", color: .purple)
588                      StatisticCard(title: "Crawl Duration", value: String(format: "%.1fs", stats.crawlDuration), color: .red)
589                      StatisticCard(title: "Avg Page Size", value: formatBytes(stats.averagePageSize), color: .cyan)
590                      StatisticCard(title: "Errors", value: "\(stats.errorCount)", color: .red)
591                      StatisticCard(title: "Max Depth", value: "\(stats.deepestLevelReached)", color: .indigo)
592                  }
593              }
594              
595              // Link Distribution Chart
596              LinkDistributionChart(internalLinks: internalLinks, externalLinks: externalLinks)
597          }
598          .padding(20)
599      }
600      
601      private func formatBytes(_ bytes: Double) -> String {
602          let kb = bytes / 1024
603          if kb < 1024 {
604              return String(format: "%.1f KB", kb)
605          } else {
606              return String(format: "%.1f MB", kb / 1024)
607          }
608      }
609  }
610  
611  struct StatisticCard: View {
612      let title: String
613      let value: String
614      let color: Color
615      
616      var body: some View {
617          VStack(spacing: 8) {
618              Text(title)
619                  .font(.caption)
620                  .foregroundColor(.secondary)
621              
622              Text(value)
623                  .font(.title3)
624                  .fontWeight(.bold)
625                  .foregroundColor(color)
626          }
627          .frame(maxWidth: .infinity)
628          .padding(16)
629          .background(Color(NSColor.textBackgroundColor))
630          .cornerRadius(8)
631      }
632  }
633  
634  struct LinkDistributionChart: View {
635      let internalLinks: [InternalLink]
636      let externalLinks: [ExternalLink]
637      
638      var body: some View {
639          VStack(alignment: .leading, spacing: 12) {
640              Text("Link Distribution by Depth")
641                  .font(.subheadline)
642                  .fontWeight(.medium)
643              
644              Chart(linksByDepth, id: \.depth) { data in
645                  BarMark(
646                      x: .value("Depth", "Level \(data.depth)"),
647                      y: .value("Count", data.count)
648                  )
649                  .foregroundStyle(Color.blue.gradient)
650              }
651              .frame(height: 150)
652          }
653      }
654      
655      private var linksByDepth: [DepthCount] {
656          let internalByDepth = Dictionary(grouping: internalLinks, by: \.depth)
657              .mapValues { $0.count }
658          
659          let maxDepth = internalByDepth.keys.max() ?? 1
660          
661          return (1...maxDepth).map { depth in
662              DepthCount(depth: depth, count: internalByDepth[depth] ?? 0)
663          }
664      }
665  }
666  
667  struct DepthCount {
668      let depth: Int
669      let count: Int
670  }
671  
672  // MARK: - Crawl Data Section
673  
674  struct CrawlDataSection: View {
675      let crawledPages: [CrawledPage]
676      @State private var searchText = ""
677      
678      var body: some View {
679          VStack(alignment: .leading, spacing: 16) {
680              Text("Crawled Pages (\(filteredPages.count))")
681                  .font(.headline)
682                  .fontWeight(.semibold)
683              
684              TextField("Search crawled pages...", text: $searchText)
685                  .textFieldStyle(RoundedBorderTextFieldStyle())
686              
687              LazyVStack(spacing: 8) {
688                  ForEach(filteredPages, id: \.id) { page in
689                      CrawledPageRow(page: page)
690                  }
691              }
692          }
693          .padding(20)
694      }
695      
696      private var filteredPages: [CrawledPage] {
697          if searchText.isEmpty {
698              return crawledPages
699          } else {
700              return crawledPages.filter { 
701                  $0.url.localizedCaseInsensitiveContains(searchText) ||
702                  ($0.title?.localizedCaseInsensitiveContains(searchText) ?? false)
703              }
704          }
705      }
706  }
707  
708  struct CrawledPageRow: View {
709      let page: CrawledPage
710      
711      var body: some View {
712          VStack(alignment: .leading, spacing: 8) {
713              HStack {
714                  VStack(alignment: .leading, spacing: 4) {
715                      Text(page.title ?? "No Title")
716                          .font(.subheadline)
717                          .fontWeight(.medium)
718                          .lineLimit(1)
719                      
720                      Text(page.url)
721                          .font(.caption)
722                          .foregroundColor(.blue)
723                          .lineLimit(1)
724                      
725                      if let error = page.crawlError {
726                          Text("Error: \(error)")
727                              .font(.caption)
728                              .foregroundColor(.red)
729                              .lineLimit(1)
730                      }
731                  }
732                  
733                  Spacer()
734                  
735                  VStack(alignment: .trailing, spacing: 4) {
736                      Text("Depth \(page.depth)")
737                          .font(.caption)
738                          .padding(.horizontal, 6)
739                          .padding(.vertical, 2)
740                          .background(Color.blue.opacity(0.2))
741                          .foregroundColor(.blue)
742                          .cornerRadius(4)
743                      
744                      if let size = page.contentSize {
745                          Text(formatBytes(Double(size)))
746                              .font(.caption2)
747                              .foregroundColor(.secondary)
748                      }
749                      
750                      Text(page.crawlDate, style: .time)
751                          .font(.caption2)
752                          .foregroundColor(.secondary)
753                  }
754              }
755          }
756          .padding(12)
757          .background(Color(NSColor.textBackgroundColor))
758          .cornerRadius(8)
759          .overlay(
760              RoundedRectangle(cornerRadius: 8)
761                  .strokeBorder(page.crawlError != nil ? Color.red.opacity(0.3) : Color.clear, lineWidth: 1)
762          )
763      }
764      
765      private func formatBytes(_ bytes: Double) -> String {
766          let kb = bytes / 1024
767          if kb < 1024 {
768              return String(format: "%.1f KB", kb)
769          } else {
770              return String(format: "%.1f MB", kb / 1024)
771          }
772      }
773  }
774  
775  #Preview {
776      LinksAnalysisView()
777  }