PostDraftValidator.swift 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. import Foundation
  2. enum PostDraftValidationError: LocalizedError {
  3. case emptyTopic
  4. case emptySubreddit
  5. case invalidSubreddit
  6. case emptyTitle
  7. case titleTooLong
  8. case bodyTooLong
  9. case missingImage
  10. case invalidLinkURL
  11. case emptyLinkURL
  12. case invalidVideoURL
  13. case emptyVideoURL
  14. case insufficientPollOptions
  15. case emptyPollOption
  16. var errorDescription: String? {
  17. switch self {
  18. case .emptyTopic:
  19. "Enter a topic or keywords for AI generation."
  20. case .emptySubreddit:
  21. "Enter a subreddit to tailor the post."
  22. case .invalidSubreddit:
  23. "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
  24. case .emptyTitle:
  25. "Add a post title before exporting or submitting."
  26. case .titleTooLong:
  27. "Title must be 300 characters or fewer."
  28. case .bodyTooLong:
  29. "Post body must be 40,000 characters or fewer."
  30. case .missingImage:
  31. "Upload an image for image posts."
  32. case .invalidLinkURL:
  33. "Enter a valid http or https URL for the link."
  34. case .emptyLinkURL:
  35. "Enter a URL for link posts."
  36. case .invalidVideoURL:
  37. "Enter a valid http or https URL for the video."
  38. case .emptyVideoURL:
  39. "Enter a video URL for video posts."
  40. case .insufficientPollOptions:
  41. "Polls need at least two non-empty options."
  42. case .emptyPollOption:
  43. "Fill in all poll options or remove empty ones."
  44. }
  45. }
  46. }
  47. enum PostDraftValidator {
  48. static let maxTitleLength = 300
  49. static let maxBodyLength = 40_000
  50. static func normalizedSubreddit(_ raw: String) -> String {
  51. raw.trimmingCharacters(in: .whitespaces)
  52. .replacingOccurrences(of: "^r/", with: "", options: .regularExpression)
  53. .lowercased()
  54. }
  55. static func isValidSubreddit(_ raw: String) -> Bool {
  56. let name = normalizedSubreddit(raw)
  57. guard name.count >= 3, name.count <= 21 else { return false }
  58. let pattern = "^[a-z0-9_]+$"
  59. return name.range(of: pattern, options: .regularExpression) != nil
  60. }
  61. static func isValidHTTPURL(_ string: String) -> Bool {
  62. let trimmed = string.trimmingCharacters(in: .whitespaces)
  63. guard let url = URL(string: trimmed),
  64. let scheme = url.scheme?.lowercased(),
  65. scheme == "http" || scheme == "https",
  66. url.host != nil
  67. else { return false }
  68. return true
  69. }
  70. static func validateForGeneration(topic: String, subreddit: String) throws {
  71. guard !topic.trimmingCharacters(in: .whitespaces).isEmpty else {
  72. throw PostDraftValidationError.emptyTopic
  73. }
  74. guard !subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
  75. throw PostDraftValidationError.emptySubreddit
  76. }
  77. guard isValidSubreddit(subreddit) else {
  78. throw PostDraftValidationError.invalidSubreddit
  79. }
  80. }
  81. static func validateForExport(_ draft: PostDraft) throws {
  82. try validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
  83. let title = draft.title.trimmingCharacters(in: .whitespaces)
  84. guard !title.isEmpty else { throw PostDraftValidationError.emptyTitle }
  85. guard title.count <= maxTitleLength else { throw PostDraftValidationError.titleTooLong }
  86. guard draft.body.count <= maxBodyLength else { throw PostDraftValidationError.bodyTooLong }
  87. switch draft.postType {
  88. case .text:
  89. break
  90. case .image:
  91. guard draft.imageFileURL != nil else { throw PostDraftValidationError.missingImage }
  92. case .link:
  93. let url = draft.linkURL.trimmingCharacters(in: .whitespaces)
  94. guard !url.isEmpty else { throw PostDraftValidationError.emptyLinkURL }
  95. guard isValidHTTPURL(url) else { throw PostDraftValidationError.invalidLinkURL }
  96. case .video:
  97. let url = draft.videoURL.trimmingCharacters(in: .whitespaces)
  98. guard !url.isEmpty else { throw PostDraftValidationError.emptyVideoURL }
  99. guard isValidHTTPURL(url) else { throw PostDraftValidationError.invalidVideoURL }
  100. case .poll:
  101. let filled = draft.pollOptions.map(\.text).map {
  102. $0.trimmingCharacters(in: .whitespaces)
  103. }.filter { !$0.isEmpty }
  104. guard filled.count >= 2 else { throw PostDraftValidationError.insufficientPollOptions }
  105. }
  106. }
  107. static func canGenerate(from draft: PostDraft, isGenerating: Bool) -> Bool {
  108. guard !isGenerating else { return false }
  109. return (try? validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)) != nil
  110. }
  111. static func canExport(_ draft: PostDraft) -> Bool {
  112. (try? validateForExport(draft)) != nil
  113. }
  114. }