mirror of
https://github.com/otaviocc/Triton.git
synced 2026-01-29 19:54:27 +00:00
Add tag support to Weblog entries
This commit is contained in:
parent
d840caa026
commit
1392ad0cc0
29 changed files with 361 additions and 33 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Data, CreateOrUpdateWeblogEntryResponse> {
|
||||
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<Data, CreateOrUpdateWeblogEntryResponse> {
|
||||
let body = content.weblogEntryBody(
|
||||
date: date,
|
||||
status: status
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
return .init(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
date: .init(),
|
||||
entryID: nil,
|
||||
status: .draft,
|
||||
tags: .init(),
|
||||
repository: WeblogRepositoryMother.makeWeblogRepository()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
timestamp: 12_312_312,
|
||||
address: "otaviocc",
|
||||
location: "/2022/12/my-weblog-post",
|
||||
tags: .init(),
|
||||
repository: WeblogRepositoryMother.makeWeblogRepository(),
|
||||
clipboardService: ClipboardServiceMother.makeClipboardService()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WeblogTag>` configured for alphabetical title sorting.
|
||||
static func fetchDescriptor() -> FetchDescriptor<WeblogTag> {
|
||||
.init(
|
||||
sortBy: [.init(\.title)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
extension WeblogTag {
|
||||
|
||||
static func makeTag(
|
||||
title: String
|
||||
) -> WeblogTag {
|
||||
.init(title: title)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WeblogTag>([\.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
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ extension StorableEntry {
|
|||
date: entryResponse.date,
|
||||
status: entryResponse.status,
|
||||
location: entryResponse.location,
|
||||
address: entryResponse.address
|
||||
address: entryResponse.address,
|
||||
tags: entryResponse.tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue