mirror of
https://github.com/otaviocc/Triton.git
synced 2026-01-30 04:04: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.
|
/// Creates a weblog entry body with frontmatter from the string content.
|
||||||
///
|
///
|
||||||
/// This method formats the string as a weblog entry by adding frontmatter
|
/// 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.
|
/// OMG.LOL weblog API requirements with ISO 8601 date formatting.
|
||||||
///
|
///
|
||||||
/// The output format is:
|
/// The output format is:
|
||||||
|
|
@ -13,6 +13,7 @@ public extension String {
|
||||||
/// ---
|
/// ---
|
||||||
/// Date: YYYY-MM-DD HH:MM
|
/// Date: YYYY-MM-DD HH:MM
|
||||||
/// Status: [status value]
|
/// Status: [status value]
|
||||||
|
/// Tags: Tag1, Tag2, Tag3
|
||||||
/// ---
|
/// ---
|
||||||
///
|
///
|
||||||
/// [string content]
|
/// [string content]
|
||||||
|
|
@ -22,20 +23,27 @@ public extension String {
|
||||||
/// - date: The publication date to include in the frontmatter
|
/// - 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
|
/// - status: The publication status to include in the frontmatter (e.g., "Draft", "Live", "Feed Only", "Web
|
||||||
/// Only", "Unlisted")
|
/// 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
|
/// - Returns: UTF-8 encoded data containing the formatted weblog entry body
|
||||||
func weblogEntryBody(
|
func weblogEntryBody(
|
||||||
date: Date,
|
date: Date,
|
||||||
status: String
|
status: String,
|
||||||
|
tags: [String]
|
||||||
) -> Data {
|
) -> Data {
|
||||||
let formattedString = DateFormatter.iso8601WithShortTime.string(from: date)
|
let formattedString = DateFormatter.iso8601WithShortTime.string(from: date)
|
||||||
let body = """
|
var frontmatter = """
|
||||||
---
|
---
|
||||||
Date: \(formattedString)
|
Date: \(formattedString)
|
||||||
Status: \(status)
|
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
|
/// - address: The user address (username) to create the entry for
|
||||||
/// - content: The markdown content body of the weblog entry
|
/// - 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")
|
/// - 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
|
/// - date: The publication date for the entry
|
||||||
/// - Returns: A configured network request for creating a weblog entry
|
/// - Returns: A configured network request for creating a weblog entry
|
||||||
public static func makeCreateWeblogEntryRequest(
|
public static func makeCreateWeblogEntryRequest(
|
||||||
address: String,
|
address: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) -> NetworkRequest<Data, CreateOrUpdateWeblogEntryResponse> {
|
) -> NetworkRequest<Data, CreateOrUpdateWeblogEntryResponse> {
|
||||||
let body = content.weblogEntryBody(
|
let body = content.weblogEntryBody(
|
||||||
date: date,
|
date: date,
|
||||||
status: status
|
status: status,
|
||||||
|
tags: tags
|
||||||
)
|
)
|
||||||
|
|
||||||
return .init(
|
return .init(
|
||||||
|
|
@ -152,6 +155,7 @@ public enum WeblogRequestFactory {
|
||||||
/// - content: The updated markdown content body of the weblog entry
|
/// - 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",
|
/// - status: The updated publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only",
|
||||||
/// "Unlisted")
|
/// "Unlisted")
|
||||||
|
/// - tags: An array of tags associated with the entry
|
||||||
/// - date: The updated publication date for the entry
|
/// - date: The updated publication date for the entry
|
||||||
/// - Returns: A configured network request for updating the weblog entry
|
/// - Returns: A configured network request for updating the weblog entry
|
||||||
public static func makeUpdateWeblogEntryRequest(
|
public static func makeUpdateWeblogEntryRequest(
|
||||||
|
|
@ -159,11 +163,13 @@ public enum WeblogRequestFactory {
|
||||||
entryID: String,
|
entryID: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) -> NetworkRequest<Data, CreateOrUpdateWeblogEntryResponse> {
|
) -> NetworkRequest<Data, CreateOrUpdateWeblogEntryResponse> {
|
||||||
let body = content.weblogEntryBody(
|
let body = content.weblogEntryBody(
|
||||||
date: date,
|
date: date,
|
||||||
status: status
|
status: status,
|
||||||
|
tags: tags
|
||||||
)
|
)
|
||||||
|
|
||||||
return .init(
|
return .init(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
public struct WeblogEntryResponse: Decodable, Identifiable, Sendable {
|
public struct WeblogEntryResponse: Decodable, Identifiable, Sendable {
|
||||||
|
|
||||||
// MARK: - Nested types
|
// MARK: - Nested types
|
||||||
|
|
@ -30,4 +32,23 @@ public struct WeblogEntryResponse: Decodable, Identifiable, Sendable {
|
||||||
public let body: String
|
public let body: String
|
||||||
public let output: String
|
public let output: String
|
||||||
public let metadata: 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 {
|
VStack {
|
||||||
makeEditorView()
|
makeEditorView()
|
||||||
}
|
}
|
||||||
.frame(minWidth: 400, idealWidth: 640, maxWidth: 640)
|
.frame(minWidth: 640, idealWidth: 640, maxWidth: 800)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
makeToolbarContent()
|
makeToolbarContent()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ struct UploadView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
makeContentView()
|
makeContentView()
|
||||||
.frame(minWidth: 400, idealWidth: 640, maxWidth: 640)
|
.frame(minWidth: 640, idealWidth: 640, maxWidth: 800)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
makeToolbarContent()
|
makeToolbarContent()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ public struct EditWeblogEntry: Codable, Hashable {
|
||||||
/// The publication status of the weblog entry.
|
/// The publication status of the weblog entry.
|
||||||
public let status: String?
|
public let status: String?
|
||||||
|
|
||||||
|
/// An array of tags associated with the weblog entry.
|
||||||
|
public let tags: [String]
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|
@ -40,12 +43,14 @@ public struct EditWeblogEntry: Codable, Hashable {
|
||||||
body: String,
|
body: String,
|
||||||
date: Date,
|
date: Date,
|
||||||
entryID: String?,
|
entryID: String?,
|
||||||
status: String?
|
status: String?,
|
||||||
|
tags: [String]
|
||||||
) {
|
) {
|
||||||
self.address = address
|
self.address = address
|
||||||
self.body = body
|
self.body = body
|
||||||
self.date = date
|
self.date = date
|
||||||
self.entryID = entryID
|
self.entryID = entryID
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.tags = tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ final class ViewModelFactory: Sendable {
|
||||||
timestamp: entry.date,
|
timestamp: entry.date,
|
||||||
address: entry.address,
|
address: entry.address,
|
||||||
location: entry.location,
|
location: entry.location,
|
||||||
|
tags: entry.tags ?? [],
|
||||||
repository: container.resolve(),
|
repository: container.resolve(),
|
||||||
clipboardService: container.resolve()
|
clipboardService: container.resolve()
|
||||||
)
|
)
|
||||||
|
|
@ -64,7 +65,8 @@ final class ViewModelFactory: Sendable {
|
||||||
body: String,
|
body: String,
|
||||||
date: Date,
|
date: Date,
|
||||||
entryID: String?,
|
entryID: String?,
|
||||||
status: WeblogEntryStatus
|
status: WeblogEntryStatus,
|
||||||
|
tags: [String]
|
||||||
) -> EditorViewModel {
|
) -> EditorViewModel {
|
||||||
.init(
|
.init(
|
||||||
address: address,
|
address: address,
|
||||||
|
|
@ -72,6 +74,7 @@ final class ViewModelFactory: Sendable {
|
||||||
date: date,
|
date: date,
|
||||||
entryID: entryID,
|
entryID: entryID,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
repository: container.resolve()
|
repository: container.resolve()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
date: .init(),
|
date: .init(),
|
||||||
entryID: nil,
|
entryID: nil,
|
||||||
status: .draft,
|
status: .draft,
|
||||||
|
tags: .init(),
|
||||||
repository: WeblogRepositoryMother.makeWeblogRepository()
|
repository: WeblogRepositoryMother.makeWeblogRepository()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
timestamp: 12_312_312,
|
timestamp: 12_312_312,
|
||||||
address: "otaviocc",
|
address: "otaviocc",
|
||||||
location: "/2022/12/my-weblog-post",
|
location: "/2022/12/my-weblog-post",
|
||||||
|
tags: .init(),
|
||||||
repository: WeblogRepositoryMother.makeWeblogRepository(),
|
repository: WeblogRepositoryMother.makeWeblogRepository(),
|
||||||
clipboardService: ClipboardServiceMother.makeClipboardService()
|
clipboardService: ClipboardServiceMother.makeClipboardService()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
address: String,
|
address: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws -> EntryResponse {
|
) async throws -> EntryResponse {
|
||||||
EntryResponseMother.makeEntryResponse()
|
EntryResponseMother.makeEntryResponse()
|
||||||
|
|
@ -34,6 +35,7 @@
|
||||||
entryID: String,
|
entryID: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws -> EntryResponse {
|
) async throws -> EntryResponse {
|
||||||
EntryResponseMother.makeEntryResponse()
|
EntryResponseMother.makeEntryResponse()
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,16 @@
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
func fetchEntries() async throws {}
|
func fetchEntries() async throws {}
|
||||||
|
func deleteEntry(address: String, entryID: String) async throws {}
|
||||||
|
|
||||||
func createOrUpdateEntry(
|
func createOrUpdateEntry(
|
||||||
address: String,
|
address: String,
|
||||||
entryID: String?,
|
entryID: String?,
|
||||||
body: String,
|
body: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws {}
|
) async throws {}
|
||||||
func deleteEntry(address: String, entryID: String) async throws {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ struct EditWeblogEntryScene: Scene {
|
||||||
date: entry?.date ?? .init(),
|
date: entry?.date ?? .init(),
|
||||||
entryID: entry?.entryID,
|
entryID: entry?.entryID,
|
||||||
status: entry?.status.flatMap(WeblogEntryStatus.init) ?? .draft,
|
status: entry?.status.flatMap(WeblogEntryStatus.init) ?? .draft,
|
||||||
|
tags: entry?.tags ?? .init(),
|
||||||
address: entry?.address ?? ""
|
address: entry?.address ?? ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +47,7 @@ struct EditWeblogEntryScene: Scene {
|
||||||
date: Date,
|
date: Date,
|
||||||
entryID: String?,
|
entryID: String?,
|
||||||
status: WeblogEntryStatus,
|
status: WeblogEntryStatus,
|
||||||
|
tags: [String],
|
||||||
address: String
|
address: String
|
||||||
) -> some View {
|
) -> some View {
|
||||||
let viewModel = environment.viewModelFactory
|
let viewModel = environment.viewModelFactory
|
||||||
|
|
@ -54,12 +56,14 @@ struct EditWeblogEntryScene: Scene {
|
||||||
body: body,
|
body: body,
|
||||||
date: date,
|
date: date,
|
||||||
entryID: entryID,
|
entryID: entryID,
|
||||||
status: status
|
status: status,
|
||||||
|
tags: tags
|
||||||
)
|
)
|
||||||
|
|
||||||
EditorView(
|
EditorView(
|
||||||
viewModel: viewModel
|
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...",
|
body: "# Title of your post\n\nThis is the body of your post...",
|
||||||
date: .init(),
|
date: .init(),
|
||||||
entryID: nil,
|
entryID: nil,
|
||||||
status: nil
|
status: nil,
|
||||||
|
tags: .init()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import DesignSystem
|
import DesignSystem
|
||||||
|
import FoundationExtensions
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WeblogPersistenceService
|
||||||
|
|
||||||
struct EditorView: View {
|
struct EditorView: View {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@State private var viewModel: EditorViewModel
|
@State private var viewModel: EditorViewModel
|
||||||
@State private var isPopoverPresented = false
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Query(WeblogTag.fetchDescriptor()) private var existingTags: [WeblogTag]
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
|
@ -20,10 +23,14 @@ struct EditorView: View {
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
makeComposeView()
|
makeComposeView()
|
||||||
makeSidebarView()
|
makeSidebarView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeTagsView()
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
makeToolbarContent()
|
makeToolbarContent()
|
||||||
}
|
}
|
||||||
|
|
@ -48,8 +55,8 @@ struct EditorView: View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func makeSidebarView() -> some View {
|
private func makeSidebarView() -> some View {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
|
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
|
||||||
GridRow {
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
Text("Date")
|
Text("Date")
|
||||||
.gridColumnAlignment(.trailing)
|
.gridColumnAlignment(.trailing)
|
||||||
|
|
||||||
|
|
@ -60,7 +67,7 @@ struct EditorView: View {
|
||||||
.help("Select publication date")
|
.help("Select publication date")
|
||||||
}
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
Text("Time")
|
Text("Time")
|
||||||
.gridColumnAlignment(.trailing)
|
.gridColumnAlignment(.trailing)
|
||||||
|
|
||||||
|
|
@ -71,7 +78,7 @@ struct EditorView: View {
|
||||||
.help("Select publication time")
|
.help("Select publication time")
|
||||||
}
|
}
|
||||||
|
|
||||||
GridRow {
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
Text("Status")
|
Text("Status")
|
||||||
.gridColumnAlignment(.trailing)
|
.gridColumnAlignment(.trailing)
|
||||||
|
|
||||||
|
|
@ -87,10 +94,82 @@ struct EditorView: View {
|
||||||
.pickerStyle(.radioGroup)
|
.pickerStyle(.radioGroup)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GridRow(alignment: .firstTextBaseline) {
|
||||||
|
Text("Tags")
|
||||||
|
.gridColumnAlignment(.trailing)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
makeTagInputView()
|
||||||
|
makeTagSuggestionsView()
|
||||||
|
makeTagInputDescription()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 200)
|
||||||
.padding()
|
.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
|
@ViewBuilder
|
||||||
private func makePublishToolbarItem() -> some View {
|
private func makePublishToolbarItem() -> some View {
|
||||||
Button {
|
Button {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import FoundationExtensions
|
||||||
import Observation
|
import Observation
|
||||||
import WeblogRepository
|
import WeblogRepository
|
||||||
|
|
||||||
|
|
@ -12,7 +13,10 @@ final class EditorViewModel {
|
||||||
var entryID: String?
|
var entryID: String?
|
||||||
var date: Date
|
var date: Date
|
||||||
var status: WeblogEntryStatus
|
var status: WeblogEntryStatus
|
||||||
|
var tagInput = ""
|
||||||
var shouldDismiss = false
|
var shouldDismiss = false
|
||||||
|
private(set) var tags: [String] = []
|
||||||
|
private(set) var suggestedTags: [String] = []
|
||||||
private(set) var isSubmitting = false
|
private(set) var isSubmitting = false
|
||||||
|
|
||||||
private let address: String
|
private let address: String
|
||||||
|
|
@ -40,6 +44,7 @@ final class EditorViewModel {
|
||||||
date: Date,
|
date: Date,
|
||||||
entryID: String?,
|
entryID: String?,
|
||||||
status: WeblogEntryStatus,
|
status: WeblogEntryStatus,
|
||||||
|
tags: [String],
|
||||||
repository: any WeblogRepositoryProtocol
|
repository: any WeblogRepositoryProtocol
|
||||||
) {
|
) {
|
||||||
self.address = address
|
self.address = address
|
||||||
|
|
@ -47,6 +52,7 @@ final class EditorViewModel {
|
||||||
self.date = date
|
self.date = date
|
||||||
self.entryID = entryID
|
self.entryID = entryID
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.tags = tags
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +70,7 @@ final class EditorViewModel {
|
||||||
entryID: entryID,
|
entryID: entryID,
|
||||||
body: body,
|
body: body,
|
||||||
status: status.rawValue,
|
status: status.rawValue,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
shouldDismiss = true
|
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,
|
body: viewModel.body,
|
||||||
date: viewModel.publishedDate,
|
date: viewModel.publishedDate,
|
||||||
entryID: viewModel.id,
|
entryID: viewModel.id,
|
||||||
status: viewModel.status
|
status: viewModel.status,
|
||||||
|
tags: viewModel.tags
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ final class WeblogEntryViewModel: Identifiable {
|
||||||
let timestamp: Double
|
let timestamp: Double
|
||||||
let address: String
|
let address: String
|
||||||
let location: String
|
let location: String
|
||||||
|
let tags: [String]
|
||||||
|
|
||||||
var publishedDate: Date {
|
var publishedDate: Date {
|
||||||
Date(timeIntervalSince1970: timestamp)
|
Date(timeIntervalSince1970: timestamp)
|
||||||
|
|
@ -51,6 +52,7 @@ final class WeblogEntryViewModel: Identifiable {
|
||||||
timestamp: Double,
|
timestamp: Double,
|
||||||
address: String,
|
address: String,
|
||||||
location: String,
|
location: String,
|
||||||
|
tags: [String],
|
||||||
repository: any WeblogRepositoryProtocol,
|
repository: any WeblogRepositoryProtocol,
|
||||||
clipboardService: ClipboardServiceProtocol
|
clipboardService: ClipboardServiceProtocol
|
||||||
) {
|
) {
|
||||||
|
|
@ -61,6 +63,7 @@ final class WeblogEntryViewModel: Identifiable {
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp
|
||||||
self.address = address
|
self.address = address
|
||||||
self.location = location
|
self.location = location
|
||||||
|
self.tags = tags
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.clipboardService = clipboardService
|
self.clipboardService = clipboardService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,14 @@ public struct EntryResponse: Identifiable, Equatable, Sendable {
|
||||||
/// multi-address support within the same application instance.
|
/// multi-address support within the same application instance.
|
||||||
public let address: String
|
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
|
// MARK: - Public
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|
@ -61,7 +69,8 @@ public struct EntryResponse: Identifiable, Equatable, Sendable {
|
||||||
status: String,
|
status: String,
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
address: String
|
address: String,
|
||||||
|
tags: [String] = []
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.location = location
|
self.location = location
|
||||||
|
|
@ -70,5 +79,6 @@ public struct EntryResponse: Identifiable, Equatable, Sendable {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.body = body
|
self.body = body
|
||||||
self.address = address
|
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
|
/// - address: The user address (username) to create the entry for
|
||||||
/// - content: The markdown content body of the weblog entry
|
/// - 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")
|
/// - 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
|
/// - date: The publication date for the entry
|
||||||
/// - Returns: The created weblog entry with metadata and generated ID
|
/// - Returns: The created weblog entry with metadata and generated ID
|
||||||
/// - Throws: Network errors, authentication errors, or API-specific errors.
|
/// - Throws: Network errors, authentication errors, or API-specific errors.
|
||||||
|
|
@ -59,6 +60,7 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable {
|
||||||
address: String,
|
address: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws -> EntryResponse
|
) async throws -> EntryResponse
|
||||||
|
|
||||||
|
|
@ -78,6 +80,7 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable {
|
||||||
/// - content: The updated markdown content body of the weblog entry
|
/// - 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",
|
/// - status: The updated publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only",
|
||||||
/// "Unlisted")
|
/// "Unlisted")
|
||||||
|
/// - tags: An array of tags associated with the entry
|
||||||
/// - date: The updated publication date for the entry
|
/// - date: The updated publication date for the entry
|
||||||
/// - Returns: The updated weblog entry with new content and metadata
|
/// - 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.
|
/// - 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,
|
entryID: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws -> EntryResponse
|
) async throws -> EntryResponse
|
||||||
|
|
||||||
|
|
@ -149,6 +153,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
||||||
address: String,
|
address: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws -> EntryResponse {
|
) async throws -> EntryResponse {
|
||||||
let response = try await networkClient.run(
|
let response = try await networkClient.run(
|
||||||
|
|
@ -156,6 +161,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
||||||
address: address,
|
address: address,
|
||||||
content: content,
|
content: content,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -171,6 +177,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
||||||
entryID: String,
|
entryID: String,
|
||||||
content: String,
|
content: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws -> EntryResponse {
|
) async throws -> EntryResponse {
|
||||||
let response = try await networkClient.run(
|
let response = try await networkClient.run(
|
||||||
|
|
@ -179,6 +186,7 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
||||||
entryID: entryID,
|
entryID: entryID,
|
||||||
content: content,
|
content: content,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -220,6 +228,7 @@ private extension EntryResponse {
|
||||||
title = weblogEntryResponse.title
|
title = weblogEntryResponse.title
|
||||||
body = weblogEntryResponse.body
|
body = weblogEntryResponse.body
|
||||||
address = weblogEntryResponse.address
|
address = weblogEntryResponse.address
|
||||||
|
tags = weblogEntryResponse.tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +237,8 @@ private extension EntryResponse {
|
||||||
/// Initializes the `CreateWeblogEntryResponse` model from the network response
|
/// Initializes the `CreateWeblogEntryResponse` model from the network response
|
||||||
/// model, so that the client doesn't depend on network models.
|
/// 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.
|
/// - Parameter createWeblogEntryResponse: The network model to be mapped.
|
||||||
init(
|
init(
|
||||||
address: String,
|
address: String,
|
||||||
|
|
@ -239,6 +250,7 @@ private extension EntryResponse {
|
||||||
status = createWeblogEntryResponse.status
|
status = createWeblogEntryResponse.status
|
||||||
title = createWeblogEntryResponse.title
|
title = createWeblogEntryResponse.title
|
||||||
body = createWeblogEntryResponse.body
|
body = createWeblogEntryResponse.body
|
||||||
|
tags = .init()
|
||||||
self.address = address
|
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,
|
date: storableEntry.date,
|
||||||
status: storableEntry.status,
|
status: storableEntry.status,
|
||||||
location: storableEntry.location,
|
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 {
|
) -> any WeblogPersistenceServiceProtocol {
|
||||||
let configuration = ModelConfiguration(
|
let configuration = ModelConfiguration(
|
||||||
"entries",
|
"entries",
|
||||||
schema: .init([WeblogEntry.self]),
|
schema: .init([WeblogEntry.self, WeblogTag.self]),
|
||||||
isStoredInMemoryOnly: inMemory,
|
isStoredInMemoryOnly: inMemory,
|
||||||
allowsSave: true
|
allowsSave: true
|
||||||
)
|
)
|
||||||
|
|
||||||
let container = try? ModelContainer(
|
let container = try? ModelContainer(
|
||||||
for: WeblogEntry.self,
|
for: WeblogEntry.self, WeblogTag.self,
|
||||||
configurations: configuration
|
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.
|
/// proper data organization and multi-address support in local storage.
|
||||||
let address: String
|
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
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
/// Initializes a new storable entry with all required weblog entry data.
|
/// 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.
|
/// - status: The publication status of the entry.
|
||||||
/// - location: The URL slug for the entry.
|
/// - location: The URL slug for the entry.
|
||||||
/// - address: The domain where the entry is hosted.
|
/// - address: The domain where the entry is hosted.
|
||||||
|
/// - tags: An array of tags associated with the entry. Defaults to empty array.
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -76,7 +85,8 @@ public struct StorableEntry {
|
||||||
date: Double,
|
date: Double,
|
||||||
status: String,
|
status: String,
|
||||||
location: String,
|
location: String,
|
||||||
address: String
|
address: String,
|
||||||
|
tags: [String]
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
|
|
@ -85,5 +95,6 @@ public struct StorableEntry {
|
||||||
self.status = status
|
self.status = status
|
||||||
self.location = location
|
self.location = location
|
||||||
self.address = address
|
self.address = address
|
||||||
|
self.tags = tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,17 @@ public final class WeblogEntry {
|
||||||
/// for multiple weblogs within the same app instance.
|
/// for multiple weblogs within the same app instance.
|
||||||
public private(set) var address: String
|
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
|
// MARK: - Unique constraints
|
||||||
|
|
||||||
/// Ensures each entry has a unique identifier in the database.
|
/// 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.
|
/// - status: The publication status of the entry.
|
||||||
/// - location: The URL slug for the entry.
|
/// - location: The URL slug for the entry.
|
||||||
/// - address: The domain where the entry is hosted.
|
/// - 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(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -94,7 +106,8 @@ public final class WeblogEntry {
|
||||||
date: Double,
|
date: Double,
|
||||||
status: String,
|
status: String,
|
||||||
location: String,
|
location: String,
|
||||||
address: String
|
address: String,
|
||||||
|
tags: [String]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
|
|
@ -103,5 +116,6 @@ public final class WeblogEntry {
|
||||||
self.status = status
|
self.status = status
|
||||||
self.location = location
|
self.location = location
|
||||||
self.address = address
|
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() {
|
for await _ in authSessionService.observeLogoutEvents() {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
try self?.container.mainContext.delete(model: WeblogEntry.self)
|
try self?.container.mainContext.delete(model: WeblogEntry.self)
|
||||||
|
try self?.container.mainContext.delete(model: WeblogTag.self)
|
||||||
try self?.container.mainContext.save()
|
try self?.container.mainContext.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -129,9 +130,25 @@ actor WeblogPersistenceService: WeblogPersistenceServiceProtocol {
|
||||||
) throws {
|
) throws {
|
||||||
let model = WeblogEntry.makeEntry(storableEntry: entry)
|
let model = WeblogEntry.makeEntry(storableEntry: entry)
|
||||||
container.mainContext.insert(model)
|
container.mainContext.insert(model)
|
||||||
|
try storeTags(entry.tags)
|
||||||
try container.mainContext.save()
|
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
|
@MainActor
|
||||||
private func removeDeletedEntries(
|
private func removeDeletedEntries(
|
||||||
_ entries: [StorableEntry]
|
_ entries: [StorableEntry]
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ extension StorableEntry {
|
||||||
date: entryResponse.date,
|
date: entryResponse.date,
|
||||||
status: entryResponse.status,
|
status: entryResponse.status,
|
||||||
location: entryResponse.location,
|
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.
|
/// - 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
|
/// - 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")
|
/// - 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
|
/// - date: The publication date for the entry
|
||||||
/// - Throws: Network errors from the remote operation or persistence errors
|
/// - Throws: Network errors from the remote operation or persistence errors
|
||||||
/// from the local storage operations.
|
/// from the local storage operations.
|
||||||
|
|
@ -69,6 +70,7 @@ public protocol WeblogRepositoryProtocol: Sendable {
|
||||||
entryID: String?,
|
entryID: String?,
|
||||||
body: String,
|
body: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws
|
) async throws
|
||||||
|
|
||||||
|
|
@ -142,6 +144,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
entryID: String? = nil,
|
entryID: String? = nil,
|
||||||
body: String,
|
body: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws {
|
) async throws {
|
||||||
if let entryID {
|
if let entryID {
|
||||||
|
|
@ -150,6 +153,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
entryID: entryID,
|
entryID: entryID,
|
||||||
body: body,
|
body: body,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -157,6 +161,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
address: address,
|
address: address,
|
||||||
body: body,
|
body: body,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +173,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
address: String,
|
address: String,
|
||||||
body: String,
|
body: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws {
|
) async throws {
|
||||||
guard await authSessionService.isLoggedIn else {
|
guard await authSessionService.isLoggedIn else {
|
||||||
|
|
@ -178,6 +184,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
address: address,
|
address: address,
|
||||||
content: body,
|
content: body,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -189,6 +196,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
entryID: String,
|
entryID: String,
|
||||||
body: String,
|
body: String,
|
||||||
status: String,
|
status: String,
|
||||||
|
tags: [String],
|
||||||
date: Date
|
date: Date
|
||||||
) async throws {
|
) async throws {
|
||||||
guard await authSessionService.isLoggedIn else {
|
guard await authSessionService.isLoggedIn else {
|
||||||
|
|
@ -200,6 +208,7 @@ actor WeblogRepository: WeblogRepositoryProtocol {
|
||||||
entryID: entryID,
|
entryID: entryID,
|
||||||
content: body,
|
content: body,
|
||||||
status: status,
|
status: status,
|
||||||
|
tags: tags,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue