diff --git a/Packages/FoundationExtensions/Sources/FoundationExtensions/String+Weblog.swift b/Packages/FoundationExtensions/Sources/FoundationExtensions/String+Weblog.swift index 7593b5c..a4d4a6b 100644 --- a/Packages/FoundationExtensions/Sources/FoundationExtensions/String+Weblog.swift +++ b/Packages/FoundationExtensions/Sources/FoundationExtensions/String+Weblog.swift @@ -5,7 +5,7 @@ public extension String { /// Creates a weblog entry body with frontmatter from the string content. /// /// This method formats the string as a weblog entry by adding frontmatter - /// with the specified publication date and status. The resulting format follows the + /// with the specified publication date, status, and tags. The resulting format follows the /// OMG.LOL weblog API requirements with ISO 8601 date formatting. /// /// The output format is: @@ -13,6 +13,7 @@ public extension String { /// --- /// Date: YYYY-MM-DD HH:MM /// Status: [status value] + /// Tags: Tag1, Tag2, Tag3 /// --- /// /// [string content] @@ -22,20 +23,27 @@ public extension String { /// - date: The publication date to include in the frontmatter /// - status: The publication status to include in the frontmatter (e.g., "Draft", "Live", "Feed Only", "Web /// Only", "Unlisted") + /// - tags: An array of tags to include in the frontmatter. Tags are comma-separated. /// - Returns: UTF-8 encoded data containing the formatted weblog entry body func weblogEntryBody( date: Date, - status: String + status: String, + tags: [String] ) -> Data { let formattedString = DateFormatter.iso8601WithShortTime.string(from: date) - let body = """ + var frontmatter = """ --- Date: \(formattedString) Status: \(status) - --- - - \(self) """ - return body.data(using: .utf8) ?? Data() + + if !tags.isEmpty { + let tagsString = tags.joined(separator: ", ") + frontmatter += "\nTags: \(tagsString)" + } + + frontmatter += "\n---\n\n\(self)" + + return frontmatter.data(using: .utf8) ?? Data() } } diff --git a/Packages/OMGAPI/Sources/OMGAPI/Requests/WeblogRequestFactory.swift b/Packages/OMGAPI/Sources/OMGAPI/Requests/WeblogRequestFactory.swift index b32a128..c404e89 100644 --- a/Packages/OMGAPI/Sources/OMGAPI/Requests/WeblogRequestFactory.swift +++ b/Packages/OMGAPI/Sources/OMGAPI/Requests/WeblogRequestFactory.swift @@ -105,17 +105,20 @@ public enum WeblogRequestFactory { /// - address: The user address (username) to create the entry for /// - content: The markdown content body of the weblog entry /// - status: The publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", "Unlisted") + /// - tags: An array of tags associated with the entry /// - date: The publication date for the entry /// - Returns: A configured network request for creating a weblog entry public static func makeCreateWeblogEntryRequest( address: String, content: String, status: String, + tags: [String], date: Date ) -> NetworkRequest { let body = content.weblogEntryBody( date: date, - status: status + status: status, + tags: tags ) return .init( @@ -152,6 +155,7 @@ public enum WeblogRequestFactory { /// - content: The updated markdown content body of the weblog entry /// - status: The updated publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", /// "Unlisted") + /// - tags: An array of tags associated with the entry /// - date: The updated publication date for the entry /// - Returns: A configured network request for updating the weblog entry public static func makeUpdateWeblogEntryRequest( @@ -159,11 +163,13 @@ public enum WeblogRequestFactory { entryID: String, content: String, status: String, + tags: [String], date: Date ) -> NetworkRequest { let body = content.weblogEntryBody( date: date, - status: status + status: status, + tags: tags ) return .init( diff --git a/Packages/OMGAPI/Sources/OMGAPI/Responses/WeblogEntryResponse.swift b/Packages/OMGAPI/Sources/OMGAPI/Responses/WeblogEntryResponse.swift index 319ed78..1f673b4 100644 --- a/Packages/OMGAPI/Sources/OMGAPI/Responses/WeblogEntryResponse.swift +++ b/Packages/OMGAPI/Sources/OMGAPI/Responses/WeblogEntryResponse.swift @@ -1,3 +1,5 @@ +import Foundation + public struct WeblogEntryResponse: Decodable, Identifiable, Sendable { // MARK: - Nested types @@ -30,4 +32,23 @@ public struct WeblogEntryResponse: Decodable, Identifiable, Sendable { public let body: String public let output: String public let metadata: String + + /// Extracts tags from the metadata JSON string. + /// + /// The metadata field contains a JSON string with entry metadata which might include tags. + /// Tags are stored as a dictionary where keys are lowercase slugs and values + /// preserve the original case (e.g., `{"tags":{"foo":"Foo","bar":"Bar"}}`). + /// + /// - Returns: An array of tag strings, preserving original case. Returns empty array if parsing fails or no tags + /// exist. + public var tags: [String] { + guard let data = metadata.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tagsDict = json["tags"] as? [String: String] + else { + return [] + } + + return .init(tagsDict.values).sorted() + } } diff --git a/Packages/Pics/Sources/Pics/Views/Edit Picture/EditPictureView.swift b/Packages/Pics/Sources/Pics/Views/Edit Picture/EditPictureView.swift index 627e21b..7548100 100644 --- a/Packages/Pics/Sources/Pics/Views/Edit Picture/EditPictureView.swift +++ b/Packages/Pics/Sources/Pics/Views/Edit Picture/EditPictureView.swift @@ -26,7 +26,7 @@ struct EditPictureView: View { VStack { makeEditorView() } - .frame(minWidth: 400, idealWidth: 640, maxWidth: 640) + .frame(minWidth: 640, idealWidth: 640, maxWidth: 800) .toolbar { makeToolbarContent() } diff --git a/Packages/Pics/Sources/Pics/Views/Upload/UploadView.swift b/Packages/Pics/Sources/Pics/Views/Upload/UploadView.swift index 16ec9c4..24f003f 100644 --- a/Packages/Pics/Sources/Pics/Views/Upload/UploadView.swift +++ b/Packages/Pics/Sources/Pics/Views/Upload/UploadView.swift @@ -26,7 +26,7 @@ struct UploadView: View { var body: some View { makeContentView() - .frame(minWidth: 400, idealWidth: 640, maxWidth: 640) + .frame(minWidth: 640, idealWidth: 640, maxWidth: 800) .toolbar { makeToolbarContent() } diff --git a/Packages/Route/Sources/Route/Windows/EditWeblogEntryWindow.swift b/Packages/Route/Sources/Route/Windows/EditWeblogEntryWindow.swift index 634754b..5dc6b2f 100644 --- a/Packages/Route/Sources/Route/Windows/EditWeblogEntryWindow.swift +++ b/Packages/Route/Sources/Route/Windows/EditWeblogEntryWindow.swift @@ -33,6 +33,9 @@ public struct EditWeblogEntry: Codable, Hashable { /// The publication status of the weblog entry. public let status: String? + /// An array of tags associated with the weblog entry. + public let tags: [String] + // MARK: - Lifecycle public init( @@ -40,12 +43,14 @@ public struct EditWeblogEntry: Codable, Hashable { body: String, date: Date, entryID: String?, - status: String? + status: String?, + tags: [String] ) { self.address = address self.body = body self.date = date self.entryID = entryID self.status = status + self.tags = tags } } diff --git a/Packages/Weblog/Sources/Weblog/Factories/ViewModelFactory.swift b/Packages/Weblog/Sources/Weblog/Factories/ViewModelFactory.swift index de299ad..bcdf059 100644 --- a/Packages/Weblog/Sources/Weblog/Factories/ViewModelFactory.swift +++ b/Packages/Weblog/Sources/Weblog/Factories/ViewModelFactory.swift @@ -41,6 +41,7 @@ final class ViewModelFactory: Sendable { timestamp: entry.date, address: entry.address, location: entry.location, + tags: entry.tags ?? [], repository: container.resolve(), clipboardService: container.resolve() ) @@ -64,7 +65,8 @@ final class ViewModelFactory: Sendable { body: String, date: Date, entryID: String?, - status: WeblogEntryStatus + status: WeblogEntryStatus, + tags: [String] ) -> EditorViewModel { .init( address: address, @@ -72,6 +74,7 @@ final class ViewModelFactory: Sendable { date: date, entryID: entryID, status: status, + tags: tags, repository: container.resolve() ) } diff --git a/Packages/Weblog/Sources/Weblog/Fixtures/EditorViewModelMother.swift b/Packages/Weblog/Sources/Weblog/Fixtures/EditorViewModelMother.swift index aa11e58..0ef32e3 100644 --- a/Packages/Weblog/Sources/Weblog/Fixtures/EditorViewModelMother.swift +++ b/Packages/Weblog/Sources/Weblog/Fixtures/EditorViewModelMother.swift @@ -14,6 +14,7 @@ date: .init(), entryID: nil, status: .draft, + tags: .init(), repository: WeblogRepositoryMother.makeWeblogRepository() ) } diff --git a/Packages/Weblog/Sources/Weblog/Fixtures/WeblogEntryViewModelMother.swift b/Packages/Weblog/Sources/Weblog/Fixtures/WeblogEntryViewModelMother.swift index 45db3e3..ba1c1c4 100644 --- a/Packages/Weblog/Sources/Weblog/Fixtures/WeblogEntryViewModelMother.swift +++ b/Packages/Weblog/Sources/Weblog/Fixtures/WeblogEntryViewModelMother.swift @@ -16,6 +16,7 @@ timestamp: 12_312_312, address: "otaviocc", location: "/2022/12/my-weblog-post", + tags: .init(), repository: WeblogRepositoryMother.makeWeblogRepository(), clipboardService: ClipboardServiceMother.makeClipboardService() ) diff --git a/Packages/Weblog/Sources/Weblog/Fixtures/WeblogNetworkServiceMother.swift b/Packages/Weblog/Sources/Weblog/Fixtures/WeblogNetworkServiceMother.swift index c1f6be6..f0d2127 100644 --- a/Packages/Weblog/Sources/Weblog/Fixtures/WeblogNetworkServiceMother.swift +++ b/Packages/Weblog/Sources/Weblog/Fixtures/WeblogNetworkServiceMother.swift @@ -24,6 +24,7 @@ address: String, content: String, status: String, + tags: [String], date: Date ) async throws -> EntryResponse { EntryResponseMother.makeEntryResponse() @@ -34,6 +35,7 @@ entryID: String, content: String, status: String, + tags: [String], date: Date ) async throws -> EntryResponse { EntryResponseMother.makeEntryResponse() diff --git a/Packages/Weblog/Sources/Weblog/Fixtures/WeblogRepositoryMother.swift b/Packages/Weblog/Sources/Weblog/Fixtures/WeblogRepositoryMother.swift index af0969c..e70e7dd 100644 --- a/Packages/Weblog/Sources/Weblog/Fixtures/WeblogRepositoryMother.swift +++ b/Packages/Weblog/Sources/Weblog/Fixtures/WeblogRepositoryMother.swift @@ -39,14 +39,16 @@ // MARK: - Public func fetchEntries() async throws {} + func deleteEntry(address: String, entryID: String) async throws {} + func createOrUpdateEntry( address: String, entryID: String?, body: String, status: String, + tags: [String], date: Date ) async throws {} - func deleteEntry(address: String, entryID: String) async throws {} } // MARK: - Public diff --git a/Packages/Weblog/Sources/Weblog/Scenes/EditWeblogEntryScene.swift b/Packages/Weblog/Sources/Weblog/Scenes/EditWeblogEntryScene.swift index dfcb011..0c10e53 100644 --- a/Packages/Weblog/Sources/Weblog/Scenes/EditWeblogEntryScene.swift +++ b/Packages/Weblog/Sources/Weblog/Scenes/EditWeblogEntryScene.swift @@ -28,6 +28,7 @@ struct EditWeblogEntryScene: Scene { date: entry?.date ?? .init(), entryID: entry?.entryID, status: entry?.status.flatMap(WeblogEntryStatus.init) ?? .draft, + tags: entry?.tags ?? .init(), address: entry?.address ?? "" ) } @@ -46,6 +47,7 @@ struct EditWeblogEntryScene: Scene { date: Date, entryID: String?, status: WeblogEntryStatus, + tags: [String], address: String ) -> some View { let viewModel = environment.viewModelFactory @@ -54,12 +56,14 @@ struct EditWeblogEntryScene: Scene { body: body, date: date, entryID: entryID, - status: status + status: status, + tags: tags ) EditorView( viewModel: viewModel ) - .frame(minWidth: 420, idealWidth: 420, maxWidth: 640) + .modelContainer(environment.modelContainer) + .frame(minWidth: 640, idealWidth: 640, maxWidth: 800) } } diff --git a/Packages/Weblog/Sources/Weblog/Views/App/WeblogApp.swift b/Packages/Weblog/Sources/Weblog/Views/App/WeblogApp.swift index 91443de..1e45eff 100644 --- a/Packages/Weblog/Sources/Weblog/Views/App/WeblogApp.swift +++ b/Packages/Weblog/Sources/Weblog/Views/App/WeblogApp.swift @@ -89,7 +89,8 @@ struct WeblogApp: View { body: "# Title of your post\n\nThis is the body of your post...", date: .init(), entryID: nil, - status: nil + status: nil, + tags: .init() ) ) } label: { diff --git a/Packages/Weblog/Sources/Weblog/Views/Editor/EditorView.swift b/Packages/Weblog/Sources/Weblog/Views/Editor/EditorView.swift index 6a9eda7..647d681 100644 --- a/Packages/Weblog/Sources/Weblog/Views/Editor/EditorView.swift +++ b/Packages/Weblog/Sources/Weblog/Views/Editor/EditorView.swift @@ -1,13 +1,16 @@ import DesignSystem +import FoundationExtensions +import SwiftData import SwiftUI +import WeblogPersistenceService struct EditorView: View { // MARK: - Properties @State private var viewModel: EditorViewModel - @State private var isPopoverPresented = false @Environment(\.dismiss) private var dismiss + @Query(WeblogTag.fetchDescriptor()) private var existingTags: [WeblogTag] // MARK: - Lifecycle @@ -20,9 +23,13 @@ struct EditorView: View { // MARK: - Public var body: some View { - HStack(alignment: .top) { - makeComposeView() - makeSidebarView() + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + makeComposeView() + makeSidebarView() + } + + makeTagsView() } .toolbar { makeToolbarContent() @@ -48,8 +55,8 @@ struct EditorView: View { @ViewBuilder private func makeSidebarView() -> some View { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { - GridRow { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { + GridRow(alignment: .firstTextBaseline) { Text("Date") .gridColumnAlignment(.trailing) @@ -60,7 +67,7 @@ struct EditorView: View { .help("Select publication date") } - GridRow { + GridRow(alignment: .firstTextBaseline) { Text("Time") .gridColumnAlignment(.trailing) @@ -71,7 +78,7 @@ struct EditorView: View { .help("Select publication time") } - GridRow { + GridRow(alignment: .firstTextBaseline) { Text("Status") .gridColumnAlignment(.trailing) @@ -87,10 +94,82 @@ struct EditorView: View { .pickerStyle(.radioGroup) .labelsHidden() } + + GridRow(alignment: .firstTextBaseline) { + Text("Tags") + .gridColumnAlignment(.trailing) + + VStack { + makeTagInputView() + makeTagSuggestionsView() + makeTagInputDescription() + } + } } + .frame(width: 200) .padding() } + @ViewBuilder + private func makeTagsView() -> some View { + makeSelectedTagsView() + } + + @ViewBuilder + private func makeTagInputView() -> some View { + TextField("Add tag", text: $viewModel.tagInput) + .autocorrectionDisabled(true) + .font(.body.monospaced()) + .textFieldCard() + .help("Enter a tag and press the return key to add it") + .onSubmit { + withAnimation { + viewModel.addTag(viewModel.tagInput) + } + } + .onChange(of: viewModel.tagInput) { + viewModel.updateTagSuggestions(from: existingTags.map(\.title)) + } + } + + @ViewBuilder + private func makeTagSuggestionsView() -> some View { + if !viewModel.suggestedTags.isEmpty { + TagListView( + tags: viewModel.suggestedTags, + helpText: { "Add existing tag '\($0)'" } + ) { tag in + withAnimation { + viewModel.addTag(tag) + } + } + } + } + + @ViewBuilder + private func makeTagInputDescription() -> some View { + if viewModel.suggestedTags.isEmpty { + Text("Type a tag and press Return") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func makeSelectedTagsView() -> some View { + if !viewModel.tags.isEmpty { + TagListView( + tags: viewModel.tags, + style: .remove, + helpText: { "Remove tag '\($0)'" } + ) { tag in + withAnimation { + viewModel.removeTag(tag) + } + } + } + } + @ViewBuilder private func makePublishToolbarItem() -> some View { Button { diff --git a/Packages/Weblog/Sources/Weblog/Views/Editor/EditorViewModel.swift b/Packages/Weblog/Sources/Weblog/Views/Editor/EditorViewModel.swift index 9751b07..29e6476 100644 --- a/Packages/Weblog/Sources/Weblog/Views/Editor/EditorViewModel.swift +++ b/Packages/Weblog/Sources/Weblog/Views/Editor/EditorViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import FoundationExtensions import Observation import WeblogRepository @@ -12,7 +13,10 @@ final class EditorViewModel { var entryID: String? var date: Date var status: WeblogEntryStatus + var tagInput = "" var shouldDismiss = false + private(set) var tags: [String] = [] + private(set) var suggestedTags: [String] = [] private(set) var isSubmitting = false private let address: String @@ -40,6 +44,7 @@ final class EditorViewModel { date: Date, entryID: String?, status: WeblogEntryStatus, + tags: [String], repository: any WeblogRepositoryProtocol ) { self.address = address @@ -47,6 +52,7 @@ final class EditorViewModel { self.date = date self.entryID = entryID self.status = status + self.tags = tags self.repository = repository } @@ -64,6 +70,7 @@ final class EditorViewModel { entryID: entryID, body: body, status: status.rawValue, + tags: tags, date: date ) shouldDismiss = true @@ -72,4 +79,40 @@ final class EditorViewModel { } } } + + func updateTagSuggestions( + from existingTags: [String] + ) { + let trimmedInput = tagInput + .slugified() + .lowercased() + + guard !trimmedInput.isEmpty else { + suggestedTags = [] + return + } + + suggestedTags = existingTags + .filter { tag in + tag.lowercased().contains(trimmedInput) && !tags.contains(tag) + } + .prefix(5) + .map(\.self) + } + + func addTag(_ tag: String) { + let trimmedTag = tag.slugified() + + guard !trimmedTag.isEmpty, !tags.contains(trimmedTag) else { + tagInput = "" + return + } + + tags.append(trimmedTag) + tagInput = "" + } + + func removeTag(_ tag: String) { + tags.removeAll { $0 == tag } + } } diff --git a/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryView.swift b/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryView.swift index a077a10..19f01ae 100644 --- a/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryView.swift +++ b/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryView.swift @@ -172,7 +172,8 @@ struct WeblogEntryView: View { body: viewModel.body, date: viewModel.publishedDate, entryID: viewModel.id, - status: viewModel.status + status: viewModel.status, + tags: viewModel.tags ) ) } diff --git a/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryViewModel.swift b/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryViewModel.swift index 0d65186..38e4cc1 100644 --- a/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryViewModel.swift +++ b/Packages/Weblog/Sources/Weblog/Views/Weblog Entry/WeblogEntryViewModel.swift @@ -15,6 +15,7 @@ final class WeblogEntryViewModel: Identifiable { let timestamp: Double let address: String let location: String + let tags: [String] var publishedDate: Date { Date(timeIntervalSince1970: timestamp) @@ -51,6 +52,7 @@ final class WeblogEntryViewModel: Identifiable { timestamp: Double, address: String, location: String, + tags: [String], repository: any WeblogRepositoryProtocol, clipboardService: ClipboardServiceProtocol ) { @@ -61,6 +63,7 @@ final class WeblogEntryViewModel: Identifiable { self.timestamp = timestamp self.address = address self.location = location + self.tags = tags self.repository = repository self.clipboardService = clipboardService } diff --git a/Packages/Weblog/Sources/WeblogNetworkService/Models/EntryResponse.swift b/Packages/Weblog/Sources/WeblogNetworkService/Models/EntryResponse.swift index fd48fc4..1461a4c 100644 --- a/Packages/Weblog/Sources/WeblogNetworkService/Models/EntryResponse.swift +++ b/Packages/Weblog/Sources/WeblogNetworkService/Models/EntryResponse.swift @@ -52,6 +52,14 @@ public struct EntryResponse: Identifiable, Equatable, Sendable { /// multi-address support within the same application instance. public let address: String + /// An array of tags associated with the weblog entry for categorization and discovery. + /// + /// These user-defined tags enable content organization, filtering, and search + /// functionality. Tags can be used to group related entries or identify + /// specific themes, events, or characteristics of the content. The array may + /// be empty if no tags have been assigned to the entry. + public let tags: [String] + // MARK: - Public public init( @@ -61,7 +69,8 @@ public struct EntryResponse: Identifiable, Equatable, Sendable { status: String, title: String, body: String, - address: String + address: String, + tags: [String] = [] ) { self.id = id self.location = location @@ -70,5 +79,6 @@ public struct EntryResponse: Identifiable, Equatable, Sendable { self.title = title self.body = body self.address = address + self.tags = tags } } diff --git a/Packages/Weblog/Sources/WeblogNetworkService/WeblogNetworkService.swift b/Packages/Weblog/Sources/WeblogNetworkService/WeblogNetworkService.swift index 4f3be20..8009b88 100644 --- a/Packages/Weblog/Sources/WeblogNetworkService/WeblogNetworkService.swift +++ b/Packages/Weblog/Sources/WeblogNetworkService/WeblogNetworkService.swift @@ -52,6 +52,7 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable { /// - address: The user address (username) to create the entry for /// - content: The markdown content body of the weblog entry /// - status: The publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", "Unlisted") + /// - tags: An array of tags associated with the entry /// - date: The publication date for the entry /// - Returns: The created weblog entry with metadata and generated ID /// - Throws: Network errors, authentication errors, or API-specific errors. @@ -59,6 +60,7 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable { address: String, content: String, status: String, + tags: [String], date: Date ) async throws -> EntryResponse @@ -78,6 +80,7 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable { /// - content: The updated markdown content body of the weblog entry /// - status: The updated publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", /// "Unlisted") + /// - tags: An array of tags associated with the entry /// - date: The updated publication date for the entry /// - Returns: The updated weblog entry with new content and metadata /// - Throws: Network errors, authentication errors, or API-specific errors if the entry is not found. @@ -86,6 +89,7 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable { entryID: String, content: String, status: String, + tags: [String], date: Date ) async throws -> EntryResponse @@ -149,6 +153,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol { address: String, content: String, status: String, + tags: [String], date: Date ) async throws -> EntryResponse { let response = try await networkClient.run( @@ -156,6 +161,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol { address: address, content: content, status: status, + tags: tags, date: date ) ) @@ -171,6 +177,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol { entryID: String, content: String, status: String, + tags: [String], date: Date ) async throws -> EntryResponse { let response = try await networkClient.run( @@ -179,6 +186,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol { entryID: entryID, content: content, status: status, + tags: tags, date: date ) ) @@ -220,6 +228,7 @@ private extension EntryResponse { title = weblogEntryResponse.title body = weblogEntryResponse.body address = weblogEntryResponse.address + tags = weblogEntryResponse.tags } } @@ -228,6 +237,8 @@ private extension EntryResponse { /// Initializes the `CreateWeblogEntryResponse` model from the network response /// model, so that the client doesn't depend on network models. /// + /// Tags not returned in create/update response, will be synced on next fetch. + /// /// - Parameter createWeblogEntryResponse: The network model to be mapped. init( address: String, @@ -239,6 +250,7 @@ private extension EntryResponse { status = createWeblogEntryResponse.status title = createWeblogEntryResponse.title body = createWeblogEntryResponse.body + tags = .init() self.address = address } } diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Extensions/WeblogTag+FetchDescriptor.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Extensions/WeblogTag+FetchDescriptor.swift new file mode 100644 index 0000000..38726d4 --- /dev/null +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Extensions/WeblogTag+FetchDescriptor.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftData + +public extension WeblogTag { + + /// Creates a fetch descriptor for retrieving tags sorted alphabetically by title. + /// + /// This method provides a pre-configured fetch descriptor that sorts tags + /// by title in ascending order (A-Z). This is the standard sorting order + /// for displaying tag collections, presenting them in a predictable alphabetical + /// sequence for easy browsing and selection. + /// + /// - Returns: A `FetchDescriptor` configured for alphabetical title sorting. + static func fetchDescriptor() -> FetchDescriptor { + .init( + sortBy: [.init(\.title)] + ) + } +} diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogEntry+Factory.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogEntry+Factory.swift index 6393109..eec7cca 100644 --- a/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogEntry+Factory.swift +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogEntry+Factory.swift @@ -10,7 +10,8 @@ extension WeblogEntry { date: storableEntry.date, status: storableEntry.status, location: storableEntry.location, - address: storableEntry.address + address: storableEntry.address, + tags: storableEntry.tags.isEmpty ? nil : storableEntry.tags ) } } diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogPersistenceServiceFactory.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogPersistenceServiceFactory.swift index c54d0d7..eb68b26 100644 --- a/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogPersistenceServiceFactory.swift +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogPersistenceServiceFactory.swift @@ -64,13 +64,13 @@ public struct WeblogPersistenceServiceFactory: WeblogPersistenceServiceFactoryPr ) -> any WeblogPersistenceServiceProtocol { let configuration = ModelConfiguration( "entries", - schema: .init([WeblogEntry.self]), + schema: .init([WeblogEntry.self, WeblogTag.self]), isStoredInMemoryOnly: inMemory, allowsSave: true ) let container = try? ModelContainer( - for: WeblogEntry.self, + for: WeblogEntry.self, WeblogTag.self, configurations: configuration ) diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogTag+Factory.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogTag+Factory.swift new file mode 100644 index 0000000..b549d7d --- /dev/null +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Factories/WeblogTag+Factory.swift @@ -0,0 +1,8 @@ +extension WeblogTag { + + static func makeTag( + title: String + ) -> WeblogTag { + .init(title: title) + } +} diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Models/StorableEntry.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Models/StorableEntry.swift index 8c5be59..230f274 100644 --- a/Packages/Weblog/Sources/WeblogPersistenceService/Models/StorableEntry.swift +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Models/StorableEntry.swift @@ -54,6 +54,14 @@ public struct StorableEntry { /// proper data organization and multi-address support in local storage. let address: String + /// An array of tags associated with the weblog entry for categorization and discovery. + /// + /// These user-defined tags enable content organization, filtering, and search + /// functionality. Tags can be used to group related entries or identify + /// specific themes, events, or characteristics of the content. The array may + /// be empty if no tags have been assigned to the entry. + let tags: [String] + // MARK: - Lifecycle /// Initializes a new storable entry with all required weblog entry data. @@ -69,6 +77,7 @@ public struct StorableEntry { /// - status: The publication status of the entry. /// - location: The URL slug for the entry. /// - address: The domain where the entry is hosted. + /// - tags: An array of tags associated with the entry. Defaults to empty array. public init( id: String, title: String, @@ -76,7 +85,8 @@ public struct StorableEntry { date: Double, status: String, location: String, - address: String + address: String, + tags: [String] ) { self.id = id self.title = title @@ -85,5 +95,6 @@ public struct StorableEntry { self.status = status self.location = location self.address = address + self.tags = tags } } diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogEntry.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogEntry.swift index 72a2ce5..38251f8 100644 --- a/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogEntry.swift +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogEntry.swift @@ -64,6 +64,17 @@ public final class WeblogEntry { /// for multiple weblogs within the same app instance. public private(set) var address: String + /// An array of tags associated with the weblog entry for categorization and discovery. + /// + /// These user-defined tags enable content organization, filtering, and search + /// functionality. Tags can be used to group related entries or identify + /// specific themes, events, or characteristics of the content. The array may + /// be empty if no tags have been assigned to the entry. + /// + /// This property is optional to support migration from older database schemas + /// that didn't include tags. When nil, it should be treated as an empty array. + public private(set) var tags: [String]? + // MARK: - Unique constraints /// Ensures each entry has a unique identifier in the database. @@ -87,6 +98,7 @@ public final class WeblogEntry { /// - status: The publication status of the entry. /// - location: The URL slug for the entry. /// - address: The domain where the entry is hosted. + /// - tags: An optional array of tags associated with the entry. Defaults to nil (treated as empty array). public init( id: String, title: String, @@ -94,7 +106,8 @@ public final class WeblogEntry { date: Double, status: String, location: String, - address: String + address: String, + tags: [String]? = nil ) { self.id = id self.title = title @@ -103,5 +116,6 @@ public final class WeblogEntry { self.status = status self.location = location self.address = address + self.tags = tags } } diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogTag.swift b/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogTag.swift new file mode 100644 index 0000000..78fde5d --- /dev/null +++ b/Packages/Weblog/Sources/WeblogPersistenceService/Models/WeblogTag.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData + +/// A SwiftData model representing a tag for categorizing and organizing weblog entries. +/// +/// This model stores unique tag titles that can be associated with weblog entries +/// for organization, filtering, and discovery purposes. Tags enable users to +/// group related content and facilitate content navigation through categorization. +/// +/// The model uses SwiftData's `@Model` macro for automatic persistence capabilities +/// and includes unique constraints on the tag title to prevent duplicate tags. +/// Tags are designed to be reusable across multiple weblog entries. +/// +/// Usage example: +/// ```swift +/// let tag = WeblogTag(title: "swift") +/// modelContext.insert(tag) +/// ``` +@Model +public final class WeblogTag { + + /// The title or label of the tag. + /// + /// This is the display text for the tag and serves as its unique identifier + /// within the local database. Tag titles are case-sensitive and should be + /// user-friendly labels that describe the category or characteristic they represent. + public private(set) var title: String + + // MARK: - Unique constraints + + /// Ensures each tag title is unique in the database. + /// + /// This constraint prevents duplicate tags from being created and ensures + /// that tag titles remain unique across all stored tags. This enables + /// consistent tag reuse and prevents fragmentation of categorization. + #Unique([\.title]) + + /// Initializes a new tag with the specified title. + /// + /// - Parameter title: The display text and unique identifier for the tag. + public init( + title: String + ) { + self.title = title + } +} diff --git a/Packages/Weblog/Sources/WeblogPersistenceService/WeblogPersistenceService.swift b/Packages/Weblog/Sources/WeblogPersistenceService/WeblogPersistenceService.swift index cca8bbd..a348836 100644 --- a/Packages/Weblog/Sources/WeblogPersistenceService/WeblogPersistenceService.swift +++ b/Packages/Weblog/Sources/WeblogPersistenceService/WeblogPersistenceService.swift @@ -110,6 +110,7 @@ actor WeblogPersistenceService: WeblogPersistenceServiceProtocol { for await _ in authSessionService.observeLogoutEvents() { Task { @MainActor [weak self] in try self?.container.mainContext.delete(model: WeblogEntry.self) + try self?.container.mainContext.delete(model: WeblogTag.self) try self?.container.mainContext.save() } } @@ -129,9 +130,25 @@ actor WeblogPersistenceService: WeblogPersistenceServiceProtocol { ) throws { let model = WeblogEntry.makeEntry(storableEntry: entry) container.mainContext.insert(model) + try storeTags(entry.tags) try container.mainContext.save() } + @MainActor + private func storeTags( + _ tags: [String] + ) throws { + try tags.forEach(storeTag) + } + + @MainActor + private func storeTag( + _ title: String + ) throws { + let newTag = WeblogTag.makeTag(title: title) + container.mainContext.insert(newTag) + } + @MainActor private func removeDeletedEntries( _ entries: [StorableEntry] diff --git a/Packages/Weblog/Sources/WeblogRepository/Extensions/StorableEntry+Repository.swift b/Packages/Weblog/Sources/WeblogRepository/Extensions/StorableEntry+Repository.swift index 6df4adb..86107f6 100644 --- a/Packages/Weblog/Sources/WeblogRepository/Extensions/StorableEntry+Repository.swift +++ b/Packages/Weblog/Sources/WeblogRepository/Extensions/StorableEntry+Repository.swift @@ -13,7 +13,8 @@ extension StorableEntry { date: entryResponse.date, status: entryResponse.status, location: entryResponse.location, - address: entryResponse.address + address: entryResponse.address, + tags: entryResponse.tags ) } } diff --git a/Packages/Weblog/Sources/WeblogRepository/WeblogRepository.swift b/Packages/Weblog/Sources/WeblogRepository/WeblogRepository.swift index 9850a7f..513c476 100644 --- a/Packages/Weblog/Sources/WeblogRepository/WeblogRepository.swift +++ b/Packages/Weblog/Sources/WeblogRepository/WeblogRepository.swift @@ -61,6 +61,7 @@ public protocol WeblogRepositoryProtocol: Sendable { /// - entryID: The unique identifier of the entry to update. If nil, creates a new entry. /// - body: The content body of the weblog entry in markdown format /// - status: The publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", "Unlisted") + /// - tags: An array of tags associated with the entry /// - date: The publication date for the entry /// - Throws: Network errors from the remote operation or persistence errors /// from the local storage operations. @@ -69,6 +70,7 @@ public protocol WeblogRepositoryProtocol: Sendable { entryID: String?, body: String, status: String, + tags: [String], date: Date ) async throws @@ -142,6 +144,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { entryID: String? = nil, body: String, status: String, + tags: [String], date: Date ) async throws { if let entryID { @@ -150,6 +153,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { entryID: entryID, body: body, status: status, + tags: tags, date: date ) } else { @@ -157,6 +161,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { address: address, body: body, status: status, + tags: tags, date: date ) } @@ -168,6 +173,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { address: String, body: String, status: String, + tags: [String], date: Date ) async throws { guard await authSessionService.isLoggedIn else { @@ -178,6 +184,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { address: address, content: body, status: status, + tags: tags, date: date ) @@ -189,6 +196,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { entryID: String, body: String, status: String, + tags: [String], date: Date ) async throws { guard await authSessionService.isLoggedIn else { @@ -200,6 +208,7 @@ actor WeblogRepository: WeblogRepositoryProtocol { entryID: entryID, content: body, status: status, + tags: tags, date: date )