|
|
@@ -0,0 +1,412 @@
|
|
|
+import Foundation
|
|
|
+
|
|
|
+enum AIHistoryToolKind: String, Codable, CaseIterable, Identifiable {
|
|
|
+ case postGenerator
|
|
|
+ case titleOptimizer
|
|
|
+ case commentWriter
|
|
|
+
|
|
|
+ var id: String { rawValue }
|
|
|
+
|
|
|
+ var title: String {
|
|
|
+ switch self {
|
|
|
+ case .postGenerator: "Post Generator"
|
|
|
+ case .titleOptimizer: "Title Optimizer"
|
|
|
+ case .commentWriter: "Comment Writer"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var iconKind: SidebarIconKind {
|
|
|
+ switch self {
|
|
|
+ case .postGenerator: .postGenerator
|
|
|
+ case .titleOptimizer: .titleOptimizer
|
|
|
+ case .commentWriter: .commentWriter
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+enum AIHistoryFilter: String, CaseIterable, Identifiable {
|
|
|
+ case all
|
|
|
+ case postGenerator
|
|
|
+ case titleOptimizer
|
|
|
+ case commentWriter
|
|
|
+
|
|
|
+ var id: String { rawValue }
|
|
|
+
|
|
|
+ var title: String {
|
|
|
+ switch self {
|
|
|
+ case .all: "All"
|
|
|
+ case .postGenerator: "Posts"
|
|
|
+ case .titleOptimizer: "Titles"
|
|
|
+ case .commentWriter: "Comments"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var toolKind: AIHistoryToolKind? {
|
|
|
+ switch self {
|
|
|
+ case .all: nil
|
|
|
+ case .postGenerator: .postGenerator
|
|
|
+ case .titleOptimizer: .titleOptimizer
|
|
|
+ case .commentWriter: .commentWriter
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredPollOption: Codable, Equatable {
|
|
|
+ let id: UUID
|
|
|
+ var text: String
|
|
|
+
|
|
|
+ init(id: UUID = UUID(), text: String = "") {
|
|
|
+ self.id = id
|
|
|
+ self.text = text
|
|
|
+ }
|
|
|
+
|
|
|
+ init(from option: PollOption) {
|
|
|
+ id = option.id
|
|
|
+ text = option.text
|
|
|
+ }
|
|
|
+
|
|
|
+ var pollOption: PollOption {
|
|
|
+ PollOption(id: id, text: text)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredPostDraft: Codable, Equatable {
|
|
|
+ var postType: RedditPostType
|
|
|
+ var subreddit: String
|
|
|
+ var topic: String
|
|
|
+ var tone: PostTone
|
|
|
+ var title: String
|
|
|
+ var body: String
|
|
|
+ var linkURL: String
|
|
|
+ var videoURL: String
|
|
|
+ var pollOptions: [StoredPollOption]
|
|
|
+ var pollDuration: PollDuration
|
|
|
+ var isNSFW: Bool
|
|
|
+ var isSpoiler: Bool
|
|
|
+ var isOC: Bool
|
|
|
+ var flair: String
|
|
|
+
|
|
|
+ init(from draft: PostDraft) {
|
|
|
+ postType = draft.postType
|
|
|
+ subreddit = draft.subreddit
|
|
|
+ topic = draft.topic
|
|
|
+ tone = draft.tone
|
|
|
+ title = draft.title
|
|
|
+ body = draft.body
|
|
|
+ linkURL = draft.linkURL
|
|
|
+ videoURL = draft.videoURL
|
|
|
+ pollOptions = draft.pollOptions.map(StoredPollOption.init)
|
|
|
+ pollDuration = draft.pollDuration
|
|
|
+ isNSFW = draft.isNSFW
|
|
|
+ isSpoiler = draft.isSpoiler
|
|
|
+ isOC = draft.isOC
|
|
|
+ flair = draft.flair
|
|
|
+ }
|
|
|
+
|
|
|
+ var postDraft: PostDraft {
|
|
|
+ PostDraft(
|
|
|
+ postType: postType,
|
|
|
+ subreddit: subreddit,
|
|
|
+ topic: topic,
|
|
|
+ tone: tone,
|
|
|
+ title: title,
|
|
|
+ body: body,
|
|
|
+ linkURL: linkURL,
|
|
|
+ videoURL: videoURL,
|
|
|
+ imageFileURL: nil,
|
|
|
+ pollOptions: pollOptions.map(\.pollOption),
|
|
|
+ pollDuration: pollDuration,
|
|
|
+ isNSFW: isNSFW,
|
|
|
+ isSpoiler: isSpoiler,
|
|
|
+ isOC: isOC,
|
|
|
+ flair: flair
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredTitleDraft: Codable, Equatable {
|
|
|
+ var subreddit: String
|
|
|
+ var topic: String
|
|
|
+ var tone: PostTone
|
|
|
+ var titleGoal: TitleGoal
|
|
|
+ var originalTitle: String
|
|
|
+ var postType: RedditPostType
|
|
|
+ var isNSFW: Bool
|
|
|
+ var isSpoiler: Bool
|
|
|
+ var isOC: Bool
|
|
|
+ var flair: String
|
|
|
+ var variantCount: TitleVariantCount
|
|
|
+
|
|
|
+ init(from draft: TitleDraft) {
|
|
|
+ subreddit = draft.subreddit
|
|
|
+ topic = draft.topic
|
|
|
+ tone = draft.tone
|
|
|
+ titleGoal = draft.titleGoal
|
|
|
+ originalTitle = draft.originalTitle
|
|
|
+ postType = draft.postType
|
|
|
+ isNSFW = draft.isNSFW
|
|
|
+ isSpoiler = draft.isSpoiler
|
|
|
+ isOC = draft.isOC
|
|
|
+ flair = draft.flair
|
|
|
+ variantCount = draft.variantCount
|
|
|
+ }
|
|
|
+
|
|
|
+ var titleDraft: TitleDraft {
|
|
|
+ TitleDraft(
|
|
|
+ subreddit: subreddit,
|
|
|
+ topic: topic,
|
|
|
+ tone: tone,
|
|
|
+ titleGoal: titleGoal,
|
|
|
+ originalTitle: originalTitle,
|
|
|
+ postType: postType,
|
|
|
+ isNSFW: isNSFW,
|
|
|
+ isSpoiler: isSpoiler,
|
|
|
+ isOC: isOC,
|
|
|
+ flair: flair,
|
|
|
+ variantCount: variantCount
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredTitleVariant: Codable, Equatable {
|
|
|
+ let id: UUID
|
|
|
+ var title: String
|
|
|
+ var score: Int
|
|
|
+ var reasoning: String
|
|
|
+
|
|
|
+ init(from variant: TitleVariant) {
|
|
|
+ id = variant.id
|
|
|
+ title = variant.title
|
|
|
+ score = variant.score
|
|
|
+ reasoning = variant.reasoning
|
|
|
+ }
|
|
|
+
|
|
|
+ var titleVariant: TitleVariant {
|
|
|
+ TitleVariant(id: id, title: title, score: score, reasoning: reasoning)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredTitleAnalysis: Codable, Equatable {
|
|
|
+ var overallScore: Int
|
|
|
+ var lengthScore: Int
|
|
|
+ var engagementScore: Int
|
|
|
+ var clarityScore: Int
|
|
|
+ var suggestions: [String]
|
|
|
+
|
|
|
+ init(from analysis: TitleAnalysis) {
|
|
|
+ overallScore = analysis.overallScore
|
|
|
+ lengthScore = analysis.lengthScore
|
|
|
+ engagementScore = analysis.engagementScore
|
|
|
+ clarityScore = analysis.clarityScore
|
|
|
+ suggestions = analysis.suggestions
|
|
|
+ }
|
|
|
+
|
|
|
+ var titleAnalysis: TitleAnalysis {
|
|
|
+ TitleAnalysis(
|
|
|
+ overallScore: overallScore,
|
|
|
+ lengthScore: lengthScore,
|
|
|
+ engagementScore: engagementScore,
|
|
|
+ clarityScore: clarityScore,
|
|
|
+ suggestions: suggestions
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredCommentDraft: Codable, Equatable {
|
|
|
+ var commentType: CommentType
|
|
|
+ var subreddit: String
|
|
|
+ var postTitle: String
|
|
|
+ var postBody: String
|
|
|
+ var postURL: String
|
|
|
+ var parentComment: String
|
|
|
+ var topic: String
|
|
|
+ var tone: PostTone
|
|
|
+ var intent: CommentIntent
|
|
|
+ var length: CommentLength
|
|
|
+ var body: String
|
|
|
+ var includeQuote: Bool
|
|
|
+ var useMarkdown: Bool
|
|
|
+ var variantCount: CommentVariantCount
|
|
|
+
|
|
|
+ init(from draft: CommentDraft) {
|
|
|
+ commentType = draft.commentType
|
|
|
+ subreddit = draft.subreddit
|
|
|
+ postTitle = draft.postTitle
|
|
|
+ postBody = draft.postBody
|
|
|
+ postURL = draft.postURL
|
|
|
+ parentComment = draft.parentComment
|
|
|
+ topic = draft.topic
|
|
|
+ tone = draft.tone
|
|
|
+ intent = draft.intent
|
|
|
+ length = draft.length
|
|
|
+ body = draft.body
|
|
|
+ includeQuote = draft.includeQuote
|
|
|
+ useMarkdown = draft.useMarkdown
|
|
|
+ variantCount = draft.variantCount
|
|
|
+ }
|
|
|
+
|
|
|
+ var commentDraft: CommentDraft {
|
|
|
+ CommentDraft(
|
|
|
+ commentType: commentType,
|
|
|
+ subreddit: subreddit,
|
|
|
+ postTitle: postTitle,
|
|
|
+ postBody: postBody,
|
|
|
+ postURL: postURL,
|
|
|
+ parentComment: parentComment,
|
|
|
+ topic: topic,
|
|
|
+ tone: tone,
|
|
|
+ intent: intent,
|
|
|
+ length: length,
|
|
|
+ body: body,
|
|
|
+ includeQuote: includeQuote,
|
|
|
+ useMarkdown: useMarkdown,
|
|
|
+ variantCount: variantCount
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct StoredCommentVariant: Codable, Equatable {
|
|
|
+ let id: UUID
|
|
|
+ var body: String
|
|
|
+ var score: Int
|
|
|
+ var reasoning: String
|
|
|
+
|
|
|
+ init(from variant: CommentVariant) {
|
|
|
+ id = variant.id
|
|
|
+ body = variant.body
|
|
|
+ score = variant.score
|
|
|
+ reasoning = variant.reasoning
|
|
|
+ }
|
|
|
+
|
|
|
+ var commentVariant: CommentVariant {
|
|
|
+ CommentVariant(id: id, body: body, score: score, reasoning: reasoning)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+enum AIHistoryPayload: Codable, Equatable {
|
|
|
+ case postGenerator(StoredPostDraft)
|
|
|
+ case titleOptimizer(
|
|
|
+ draft: StoredTitleDraft,
|
|
|
+ analysis: StoredTitleAnalysis?,
|
|
|
+ variants: [StoredTitleVariant],
|
|
|
+ selectedVariantID: UUID?
|
|
|
+ )
|
|
|
+ case commentWriter(
|
|
|
+ draft: StoredCommentDraft,
|
|
|
+ variants: [StoredCommentVariant],
|
|
|
+ selectedVariantID: UUID?
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+struct AIHistoryEntry: Identifiable, Codable, Equatable {
|
|
|
+ let id: UUID
|
|
|
+ let toolKind: AIHistoryToolKind
|
|
|
+ let createdAt: Date
|
|
|
+ let displayTitle: String
|
|
|
+ let displaySubtitle: String
|
|
|
+ let payload: AIHistoryPayload
|
|
|
+
|
|
|
+ init(
|
|
|
+ id: UUID = UUID(),
|
|
|
+ toolKind: AIHistoryToolKind,
|
|
|
+ createdAt: Date = .now,
|
|
|
+ displayTitle: String,
|
|
|
+ displaySubtitle: String,
|
|
|
+ payload: AIHistoryPayload
|
|
|
+ ) {
|
|
|
+ self.id = id
|
|
|
+ self.toolKind = toolKind
|
|
|
+ self.createdAt = createdAt
|
|
|
+ self.displayTitle = displayTitle
|
|
|
+ self.displaySubtitle = displaySubtitle
|
|
|
+ self.payload = payload
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension AIHistoryEntry {
|
|
|
+ static func fromPostGenerator(draft: PostDraft) -> AIHistoryEntry {
|
|
|
+ let title = draft.title.trimmingCharacters(in: .whitespaces)
|
|
|
+ let displayTitle = title.isEmpty ? "Untitled post" : title
|
|
|
+ let subreddit = formattedSubredditName(draft.subreddit)
|
|
|
+
|
|
|
+ return AIHistoryEntry(
|
|
|
+ toolKind: .postGenerator,
|
|
|
+ displayTitle: displayTitle,
|
|
|
+ displaySubtitle: subreddit,
|
|
|
+ payload: .postGenerator(StoredPostDraft(from: draft))
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ static func fromTitleOptimizer(
|
|
|
+ draft: TitleDraft,
|
|
|
+ analysis: TitleAnalysis?,
|
|
|
+ variants: [TitleVariant],
|
|
|
+ selectedVariantID: UUID?
|
|
|
+ ) -> AIHistoryEntry {
|
|
|
+ let activeTitle: String
|
|
|
+ if let selectedVariantID,
|
|
|
+ let variant = variants.first(where: { $0.id == selectedVariantID }) {
|
|
|
+ activeTitle = variant.title
|
|
|
+ } else {
|
|
|
+ activeTitle = draft.originalTitle
|
|
|
+ }
|
|
|
+
|
|
|
+ let title = activeTitle.trimmingCharacters(in: .whitespaces)
|
|
|
+ let displayTitle = title.isEmpty ? "Untitled optimization" : title
|
|
|
+ let subreddit = formattedSubredditName(draft.subreddit)
|
|
|
+
|
|
|
+ return AIHistoryEntry(
|
|
|
+ toolKind: .titleOptimizer,
|
|
|
+ displayTitle: displayTitle,
|
|
|
+ displaySubtitle: subreddit,
|
|
|
+ payload: .titleOptimizer(
|
|
|
+ draft: StoredTitleDraft(from: draft),
|
|
|
+ analysis: analysis.map(StoredTitleAnalysis.init),
|
|
|
+ variants: variants.map(StoredTitleVariant.init),
|
|
|
+ selectedVariantID: selectedVariantID
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ static func fromCommentWriter(
|
|
|
+ draft: CommentDraft,
|
|
|
+ variants: [CommentVariant],
|
|
|
+ selectedVariantID: UUID?
|
|
|
+ ) -> AIHistoryEntry {
|
|
|
+ let activeBody: String
|
|
|
+ if let selectedVariantID,
|
|
|
+ let variant = variants.first(where: { $0.id == selectedVariantID }) {
|
|
|
+ activeBody = variant.body
|
|
|
+ } else {
|
|
|
+ activeBody = draft.body
|
|
|
+ }
|
|
|
+
|
|
|
+ let trimmed = activeBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let displayTitle: String
|
|
|
+ if trimmed.isEmpty {
|
|
|
+ displayTitle = "Untitled comment"
|
|
|
+ } else {
|
|
|
+ let firstLine = trimmed.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? trimmed
|
|
|
+ displayTitle = firstLine.count > 80 ? String(firstLine.prefix(77)) + "…" : firstLine
|
|
|
+ }
|
|
|
+
|
|
|
+ let subreddit = formattedSubredditName(draft.subreddit)
|
|
|
+
|
|
|
+ return AIHistoryEntry(
|
|
|
+ toolKind: .commentWriter,
|
|
|
+ displayTitle: displayTitle,
|
|
|
+ displaySubtitle: subreddit,
|
|
|
+ payload: .commentWriter(
|
|
|
+ draft: StoredCommentDraft(from: draft),
|
|
|
+ variants: variants.map(StoredCommentVariant.init),
|
|
|
+ selectedVariantID: selectedVariantID
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func formattedSubredditName(_ subreddit: String) -> String {
|
|
|
+ let name = PostDraftValidator.normalizedSubreddit(subreddit)
|
|
|
+ guard !name.isEmpty else { return "r/subreddit" }
|
|
|
+ return "r/\(name)"
|
|
|
+ }
|
|
|
+}
|