Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ data model as well as any custom migrations.
- Remove unused `AbstactPost` properties: `metaIsLocal`, `metaPublishImmediatelly`, `statusAfterSync`, `confirmedChangesHash`
- Remove unused `Blog` properties: `rawBlockEditorSettings`
- Remove unused `BlobEntity`
- Add `metadata` field to `AbstractPost`
- Add `metadata`, `permalinkTemplateURL` fields to `AbstractPost`
- Add `commentsStatus` and `pingsStatus` to `Post`
- Add `formattedSize` to `Media`

Expand All @@ -27,7 +27,7 @@ data model as well as any custom migrations.
@momozw 2024-05-07

- `AbstractPost`:
- Added `foreignID` (optional, no default, `UUID`)
- Added `foreignID` (optional, no default, `UUID`)

## WordPress 153

Expand Down
1 change: 1 addition & 0 deletions Modules/Sources/WordPressKitObjC/PostServiceRemoteREST.m
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ + (RemotePost *)remotePostFromJSONDictionary:(NSDictionary *)jsonPost {
post.excerpt = jsonPost[@"excerpt"];
post.slug = jsonPost[@"slug"];
post.suggestedSlug = [jsonPost stringForKeyPath:@"other_URLs.suggested_slug"];
post.permalinkTemplateURL = [jsonPost stringForKeyPath:@"other_URLs.permalink_URL"];
post.status = jsonPost[@"status"];
post.password = jsonPost[@"password"];
if ([post.password wpkit_isEmpty]) {
Expand Down
1 change: 1 addition & 0 deletions Modules/Sources/WordPressKitObjC/include/RemotePost.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extern NSString * const PostStatusDeleted;
@property (nonatomic, strong) NSString *excerpt;
@property (nonatomic, strong) NSString *slug;
@property (nonatomic, strong) NSString *suggestedSlug;
@property (nonatomic, strong) NSString *permalinkTemplateURL;
@property (nonatomic, strong) NSString *status;
@property (nonatomic, strong) NSString *password;
@property (nonatomic, strong) NSNumber *parentID;
Expand Down
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* [*] Add "Discussion" to "Post Settings" [#24948]
* [*] Add "File Size" to Site Media Details [#24947]
* [*] Add "Email to Subscribers" row to "Publishing" sheet [#24946]
* [*] Add permalink preview in the slug editor and make other improvements [#24949]

26.4
-----
Expand Down
1 change: 1 addition & 0 deletions Sources/WordPressData/Objective-C/PostHelper.m
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ + (void)updatePost:(AbstractPost *)post withRemotePost:(RemotePost *)remotePost
post.mt_excerpt = remotePost.excerpt;
post.wp_slug = remotePost.slug;
post.suggested_slug = remotePost.suggestedSlug;
post.permalinkTemplateURL = remotePost.permalinkTemplateURL;

if ([remotePost.revisions wp_isValidObject]) {
post.revisions = [remotePost.revisions copy];
Expand Down
1 change: 1 addition & 0 deletions Sources/WordPressData/Objective-C/include/AbstractPost.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) {
@property (nonatomic, strong) NSSet *comments;
@property (nonatomic, strong, nullable) Media *featuredImage;
@property (nonatomic, assign) NSInteger order;
@property (nonatomic, strong, nullable) NSString * permalinkTemplateURL;

/// This array will contain a list of revision IDs.
@property (nonatomic, strong, nullable) NSArray *revisions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<attribute name="dateModified" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
<attribute name="foreignID" optional="YES" attributeType="UUID" usesScalarValueType="NO" syncable="YES"/>
<attribute name="order" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
<attribute name="permalinkTemplateURL" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="rawMetadata" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" syncable="YES"/>
<attribute name="revisions" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES"/>
<relationship name="blog" minCount="1" maxCount="1" deletionRule="Nullify" destinationEntity="Blog" inverseName="posts" inverseEntity="Blog" syncable="YES"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,14 +420,7 @@ struct PostSettingsFormContentView: View {

private var slugRow: some View {
NavigationLink {
SettingsTextFieldView(
title: Strings.slugLabel,
text: $viewModel.settings.slug,
placeholder: Strings.slugPlaceholder,
hint: Strings.slugHint
)
.autocapitalization(.none)
.autocorrectionDisabled()
PostSlugEditorView(slug: $viewModel.settings.slug, post: viewModel.post)
} label: {
SettingsRow(Strings.slugLabel, value: viewModel.slugText)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import SwiftUI
import WordPressUI

@MainActor
struct PostSlugEditorView: View {
@Binding var slug: String
let post: AbstractPost

@FocusState private var isFocused: Bool

private var effectiveSlug: String {
if !slug.isEmpty {
return slug
} else if let suggestedSlug = post.suggested_slug, !suggestedSlug.isEmpty {
return suggestedSlug
} else {
return ""
}
}

private var placeholderText: String {
if let suggestedSlug = post.suggested_slug, !suggestedSlug.isEmpty {
return suggestedSlug
}
return Strings.slugPlaceholder
}

var body: some View {
Form {
textFieldSection
previewSection
}
.navigationTitle(Strings.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
isFocused = true
}
}

// MARK: - TextField

@ViewBuilder
private var textFieldSection: some View {
Section {
HStack {
TextField(placeholderText, text: $slug)
.focused($isFocused)
.autocapitalization(.none)
.autocorrectionDisabled()
.onChange(of: slug) { _, newValue in
// Sanitize the slug by replacing spaces with dashes and removing other whitespace
let sanitized = sanitizeSlug(newValue)
if sanitized != newValue {
slug = sanitized
}
}

if !slug.isEmpty {
Button(action: {
slug = ""
}) {
Image(systemName: "xmark.circle")
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
}
}
} header: {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.customizeDescription)
.font(.subheadline)
.foregroundColor(.secondary)

Link(destination: URL(string: "https://wordpress.com/support/permalinks-and-slugs/")!) {
(Text(Strings.learnMore) + Text(" ") + Text(Image(systemName: "arrow.up.right.square")))
.font(.subheadline)
.foregroundColor(.accentColor)
}
}
}
}

// MARK: - Preview

@ViewBuilder
private var previewSection: some View {
if let permalinkURL = makePermalinkURL() {
Section(Strings.permalinkSectionTitle) {
Link(destination: permalinkURL) {
HStack {
Text(makeFormattedPermalinkString())
.font(.callout)
.multilineTextAlignment(.leading)
.foregroundColor(.primary)
.animation(.easeInOut(duration: 0.2), value: effectiveSlug)

Spacer()

Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundColor(.secondary)
}
}
.contextMenu {
Button(action: {
UIPasteboard.general.string = permalinkURL.absoluteString
}) {
Text(SharedStrings.Button.copyLink)
Image(systemName: "doc.on.doc")
}
}
}
} else if !post.hasRemote() && post.blog.dotComID != nil {
Section(Strings.permalinkSectionTitle) {
Text(Strings.permalinkDraftNotice)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}

private let permalinkSlugPlaceholder = "%postname%"

private func makePermalinkURL() -> URL? {
guard let templateURL = post.permalinkTemplateURL,
!templateURL.isEmpty,
templateURL.firstRange(of: permalinkSlugPlaceholder) != nil else {
return nil
}
let permalinkString = templateURL.replacingOccurrences(of: permalinkSlugPlaceholder, with: effectiveSlug)
return URL(string: permalinkString)
}

private func makeFormattedPermalinkString() -> AttributedString {
guard let templateURL = post.permalinkTemplateURL,
!templateURL.isEmpty else {
return AttributedString(effectiveSlug)
}

var attributedString = AttributedString(templateURL)

// Find the placeholder range and replace it with the slug
if let range = attributedString.range(of: permalinkSlugPlaceholder) {
// Replace the placeholder with the slug
attributedString.replaceSubrange(range, with: AttributedString(effectiveSlug))

// Calculate the new range for the inserted slug
let slugStartIndex = range.lowerBound
let slugEndIndex = attributedString.index(slugStartIndex, offsetByCharacters: effectiveSlug.count)
let slugRange = slugStartIndex..<slugEndIndex

// Make the slug part bold
attributedString[slugRange].font = .body.bold()
}

return attributedString
}

// MARK: - Slug Sanitization

private func sanitizeSlug(_ input: String) -> String {
// Convert to lowercase and replace spaces with dashes
let lowercased = input.lowercased()
.replacingOccurrences(of: " ", with: "-")

// Keep only lowercase letters (supporting all locales), numbers, and hyphens
let allowedCharacters = CharacterSet.lowercaseLetters
.union(.decimalDigits)
.union(CharacterSet(charactersIn: "-"))

let filtered = lowercased.unicodeScalars.compactMap { scalar in
allowedCharacters.contains(scalar) ? Character(scalar) : nil
}

return String(filtered)
}
}

private enum Strings {
static let title = NSLocalizedString(
"postSettings.slug.navigationTitle",
value: "Slug",
comment: "Label for the slug field. Should be the same as WP core."
)

static let slugPlaceholder = NSLocalizedString(
"postSettings.slug.placeholder",
value: "Enter slug",
comment: "Placeholder for the slug field"
)

static let customizeDescription = NSLocalizedString(
"postSettings.slug.customizeDescription",
value: "Customize the last part of the Permalink.",
comment: "Description text explaining what the slug editor does"
)

static let learnMore = NSLocalizedString(
"postSettings.slug.learnMore",
value: "Learn more",
comment: "Button text to learn more about permalinks"
)

static let permalinkSectionTitle = NSLocalizedString(
"postSettings.slug.permalinkSection",
value: "Permalink",
comment: "Section title for the permalink preview"
)

static let permalinkLabel = NSLocalizedString(
"postSettings.slug.permalinkLabel",
value: "Permalink",
comment: "Label for the permalink preview"
)

static let permalinkDraftNotice = NSLocalizedString(
"postSettings.slug.permalinkDraftNotice",
value: "The suggested permalink will appear when the draft is saved on the server",
comment: "Notice shown when the post doesn't have a remote and permalink template is missing"
)
}