Skip to content

Commit ebf2fc2

Browse files
authored
Add "Permalink" to the Slug editor and more (#24949)
* Add PostSlugEditorView * Impove slug editor * Add missing permalinkTemplateURL * Add Copy URL action * Make permalink available only for Dotcom * Update release notes * Use force unwrap
1 parent 4f416c1 commit ebf2fc2

9 files changed

Lines changed: 230 additions & 10 deletions

File tree

MIGRATIONS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ data model as well as any custom migrations.
1010
- Remove unused `AbstactPost` properties: `metaIsLocal`, `metaPublishImmediatelly`, `statusAfterSync`, `confirmedChangesHash`
1111
- Remove unused `Blog` properties: `rawBlockEditorSettings`
1212
- Remove unused `BlobEntity`
13-
- Add `metadata` field to `AbstractPost`
13+
- Add `metadata`, `permalinkTemplateURL` fields to `AbstractPost`
1414
- Add `commentsStatus` and `pingsStatus` to `Post`
1515
- Add `formattedSize` to `Media`
1616

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

2929
- `AbstractPost`:
30-
- Added `foreignID` (optional, no default, `UUID`)
30+
- Added `foreignID` (optional, no default, `UUID`)
3131

3232
## WordPress 153
3333

Modules/Sources/WordPressKitObjC/PostServiceRemoteREST.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ + (RemotePost *)remotePostFromJSONDictionary:(NSDictionary *)jsonPost {
417417
post.excerpt = jsonPost[@"excerpt"];
418418
post.slug = jsonPost[@"slug"];
419419
post.suggestedSlug = [jsonPost stringForKeyPath:@"other_URLs.suggested_slug"];
420+
post.permalinkTemplateURL = [jsonPost stringForKeyPath:@"other_URLs.permalink_URL"];
420421
post.status = jsonPost[@"status"];
421422
post.password = jsonPost[@"password"];
422423
if ([post.password wpkit_isEmpty]) {

Modules/Sources/WordPressKitObjC/include/RemotePost.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ extern NSString * const PostStatusDeleted;
2929
@property (nonatomic, strong) NSString *excerpt;
3030
@property (nonatomic, strong) NSString *slug;
3131
@property (nonatomic, strong) NSString *suggestedSlug;
32+
@property (nonatomic, strong) NSString *permalinkTemplateURL;
3233
@property (nonatomic, strong) NSString *status;
3334
@property (nonatomic, strong) NSString *password;
3435
@property (nonatomic, strong) NSNumber *parentID;

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* [*] Add "Discussion" to "Post Settings" [#24948]
88
* [*] Add "File Size" to Site Media Details [#24947]
99
* [*] Add "Email to Subscribers" row to "Publishing" sheet [#24946]
10+
* [*] Add permalink preview in the slug editor and make other improvements [#24949]
1011

1112
26.4
1213
-----

Sources/WordPressData/Objective-C/PostHelper.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ + (void)updatePost:(AbstractPost *)post withRemotePost:(RemotePost *)remotePost
4848
post.mt_excerpt = remotePost.excerpt;
4949
post.wp_slug = remotePost.slug;
5050
post.suggested_slug = remotePost.suggestedSlug;
51+
post.permalinkTemplateURL = remotePost.permalinkTemplateURL;
5152

5253
if ([remotePost.revisions wp_isValidObject]) {
5354
post.revisions = [remotePost.revisions copy];

Sources/WordPressData/Objective-C/include/AbstractPost.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) {
3535
@property (nonatomic, strong) NSSet *comments;
3636
@property (nonatomic, strong, nullable) Media *featuredImage;
3737
@property (nonatomic, assign) NSInteger order;
38+
@property (nonatomic, strong, nullable) NSString * permalinkTemplateURL;
3839

3940
/// This array will contain a list of revision IDs.
4041
@property (nonatomic, strong, nullable) NSArray *revisions;

Sources/WordPressData/Resources/WordPress.xcdatamodeld/WordPress 156.xcdatamodel/contents

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<attribute name="dateModified" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
1212
<attribute name="foreignID" optional="YES" attributeType="UUID" usesScalarValueType="NO" syncable="YES"/>
1313
<attribute name="order" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES" syncable="YES"/>
14+
<attribute name="permalinkTemplateURL" optional="YES" attributeType="String" syncable="YES"/>
1415
<attribute name="rawMetadata" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" syncable="YES"/>
1516
<attribute name="revisions" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES"/>
1617
<relationship name="blog" minCount="1" maxCount="1" deletionRule="Nullify" destinationEntity="Blog" inverseName="posts" inverseEntity="Blog" syncable="YES"/>

WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -420,14 +420,7 @@ struct PostSettingsFormContentView: View {
420420

421421
private var slugRow: some View {
422422
NavigationLink {
423-
SettingsTextFieldView(
424-
title: Strings.slugLabel,
425-
text: $viewModel.settings.slug,
426-
placeholder: Strings.slugPlaceholder,
427-
hint: Strings.slugHint
428-
)
429-
.autocapitalization(.none)
430-
.autocorrectionDisabled()
423+
PostSlugEditorView(slug: $viewModel.settings.slug, post: viewModel.post)
431424
} label: {
432425
SettingsRow(Strings.slugLabel, value: viewModel.slugText)
433426
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import SwiftUI
2+
import WordPressUI
3+
4+
@MainActor
5+
struct PostSlugEditorView: View {
6+
@Binding var slug: String
7+
let post: AbstractPost
8+
9+
@FocusState private var isFocused: Bool
10+
11+
private var effectiveSlug: String {
12+
if !slug.isEmpty {
13+
return slug
14+
} else if let suggestedSlug = post.suggested_slug, !suggestedSlug.isEmpty {
15+
return suggestedSlug
16+
} else {
17+
return ""
18+
}
19+
}
20+
21+
private var placeholderText: String {
22+
if let suggestedSlug = post.suggested_slug, !suggestedSlug.isEmpty {
23+
return suggestedSlug
24+
}
25+
return Strings.slugPlaceholder
26+
}
27+
28+
var body: some View {
29+
Form {
30+
textFieldSection
31+
previewSection
32+
}
33+
.navigationTitle(Strings.title)
34+
.navigationBarTitleDisplayMode(.inline)
35+
.onAppear {
36+
isFocused = true
37+
}
38+
}
39+
40+
// MARK: - TextField
41+
42+
@ViewBuilder
43+
private var textFieldSection: some View {
44+
Section {
45+
HStack {
46+
TextField(placeholderText, text: $slug)
47+
.focused($isFocused)
48+
.autocapitalization(.none)
49+
.autocorrectionDisabled()
50+
.onChange(of: slug) { _, newValue in
51+
// Sanitize the slug by replacing spaces with dashes and removing other whitespace
52+
let sanitized = sanitizeSlug(newValue)
53+
if sanitized != newValue {
54+
slug = sanitized
55+
}
56+
}
57+
58+
if !slug.isEmpty {
59+
Button(action: {
60+
slug = ""
61+
}) {
62+
Image(systemName: "xmark.circle")
63+
.foregroundColor(.secondary)
64+
}
65+
.buttonStyle(PlainButtonStyle())
66+
}
67+
}
68+
} header: {
69+
VStack(alignment: .leading, spacing: 4) {
70+
Text(Strings.customizeDescription)
71+
.font(.subheadline)
72+
.foregroundColor(.secondary)
73+
74+
Link(destination: URL(string: "https://wordpress.com/support/permalinks-and-slugs/")!) {
75+
(Text(Strings.learnMore) + Text(" ") + Text(Image(systemName: "arrow.up.right.square")))
76+
.font(.subheadline)
77+
.foregroundColor(.accentColor)
78+
}
79+
}
80+
}
81+
}
82+
83+
// MARK: - Preview
84+
85+
@ViewBuilder
86+
private var previewSection: some View {
87+
if let permalinkURL = makePermalinkURL() {
88+
Section(Strings.permalinkSectionTitle) {
89+
Link(destination: permalinkURL) {
90+
HStack {
91+
Text(makeFormattedPermalinkString())
92+
.font(.callout)
93+
.multilineTextAlignment(.leading)
94+
.foregroundColor(.primary)
95+
.animation(.easeInOut(duration: 0.2), value: effectiveSlug)
96+
97+
Spacer()
98+
99+
Image(systemName: "arrow.up.right.square")
100+
.font(.caption)
101+
.foregroundColor(.secondary)
102+
}
103+
}
104+
.contextMenu {
105+
Button(action: {
106+
UIPasteboard.general.string = permalinkURL.absoluteString
107+
}) {
108+
Text(SharedStrings.Button.copyLink)
109+
Image(systemName: "doc.on.doc")
110+
}
111+
}
112+
}
113+
} else if !post.hasRemote() && post.blog.dotComID != nil {
114+
Section(Strings.permalinkSectionTitle) {
115+
Text(Strings.permalinkDraftNotice)
116+
.font(.callout)
117+
.foregroundStyle(.secondary)
118+
}
119+
}
120+
}
121+
122+
private let permalinkSlugPlaceholder = "%postname%"
123+
124+
private func makePermalinkURL() -> URL? {
125+
guard let templateURL = post.permalinkTemplateURL,
126+
!templateURL.isEmpty,
127+
templateURL.firstRange(of: permalinkSlugPlaceholder) != nil else {
128+
return nil
129+
}
130+
let permalinkString = templateURL.replacingOccurrences(of: permalinkSlugPlaceholder, with: effectiveSlug)
131+
return URL(string: permalinkString)
132+
}
133+
134+
private func makeFormattedPermalinkString() -> AttributedString {
135+
guard let templateURL = post.permalinkTemplateURL,
136+
!templateURL.isEmpty else {
137+
return AttributedString(effectiveSlug)
138+
}
139+
140+
var attributedString = AttributedString(templateURL)
141+
142+
// Find the placeholder range and replace it with the slug
143+
if let range = attributedString.range(of: permalinkSlugPlaceholder) {
144+
// Replace the placeholder with the slug
145+
attributedString.replaceSubrange(range, with: AttributedString(effectiveSlug))
146+
147+
// Calculate the new range for the inserted slug
148+
let slugStartIndex = range.lowerBound
149+
let slugEndIndex = attributedString.index(slugStartIndex, offsetByCharacters: effectiveSlug.count)
150+
let slugRange = slugStartIndex..<slugEndIndex
151+
152+
// Make the slug part bold
153+
attributedString[slugRange].font = .body.bold()
154+
}
155+
156+
return attributedString
157+
}
158+
159+
// MARK: - Slug Sanitization
160+
161+
private func sanitizeSlug(_ input: String) -> String {
162+
// Convert to lowercase and replace spaces with dashes
163+
let lowercased = input.lowercased()
164+
.replacingOccurrences(of: " ", with: "-")
165+
166+
// Keep only lowercase letters (supporting all locales), numbers, and hyphens
167+
let allowedCharacters = CharacterSet.lowercaseLetters
168+
.union(.decimalDigits)
169+
.union(CharacterSet(charactersIn: "-"))
170+
171+
let filtered = lowercased.unicodeScalars.compactMap { scalar in
172+
allowedCharacters.contains(scalar) ? Character(scalar) : nil
173+
}
174+
175+
return String(filtered)
176+
}
177+
}
178+
179+
private enum Strings {
180+
static let title = NSLocalizedString(
181+
"postSettings.slug.navigationTitle",
182+
value: "Slug",
183+
comment: "Label for the slug field. Should be the same as WP core."
184+
)
185+
186+
static let slugPlaceholder = NSLocalizedString(
187+
"postSettings.slug.placeholder",
188+
value: "Enter slug",
189+
comment: "Placeholder for the slug field"
190+
)
191+
192+
static let customizeDescription = NSLocalizedString(
193+
"postSettings.slug.customizeDescription",
194+
value: "Customize the last part of the Permalink.",
195+
comment: "Description text explaining what the slug editor does"
196+
)
197+
198+
static let learnMore = NSLocalizedString(
199+
"postSettings.slug.learnMore",
200+
value: "Learn more",
201+
comment: "Button text to learn more about permalinks"
202+
)
203+
204+
static let permalinkSectionTitle = NSLocalizedString(
205+
"postSettings.slug.permalinkSection",
206+
value: "Permalink",
207+
comment: "Section title for the permalink preview"
208+
)
209+
210+
static let permalinkLabel = NSLocalizedString(
211+
"postSettings.slug.permalinkLabel",
212+
value: "Permalink",
213+
comment: "Label for the permalink preview"
214+
)
215+
216+
static let permalinkDraftNotice = NSLocalizedString(
217+
"postSettings.slug.permalinkDraftNotice",
218+
value: "The suggested permalink will appear when the draft is saved on the server",
219+
comment: "Notice shown when the post doesn't have a remote and permalink template is missing"
220+
)
221+
}

0 commit comments

Comments
 (0)