Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,28 @@ import Foundation
public struct SearchIdentifierGenerator {
internal static let separator = "|~~~|"

internal static func composeUniqueIdentifier(itemType: SearchItemType, domain: String, identifier: String) -> String {
return "\(itemType.stringValue())\(separator)\(domain)\(separator)\(identifier)"
internal static func composeUniqueIdentifier(
itemType: SearchItemType,
domain: String,
identifier: String
) -> String {
"\(itemType.stringValue())\(separator)\(domain)\(separator)\(identifier)"
}

public static func decomposeFromUniqueIdentifier(_ combined: String) -> (itemType: SearchItemType, domain: String, identifier: String) {
/// Returns `nil` for identifiers that are not in the composite format or
/// name an unknown item type. Identifiers arrive from the system (Spotlight
/// activities), so the format cannot be assumed.
public static func decomposeFromUniqueIdentifier(
_ combined: String
) -> (itemType: SearchItemType, domain: String, identifier: String)? {
let components = combined.components(separatedBy: separator)

return (SearchItemType(index: components[0]), components[1], components[2])
guard components.count == 3 else {
return nil
}
let itemType = SearchItemType(index: components[0])
guard itemType != .none else {
return nil
}
return (itemType, components[1], components[2])
}
}
112 changes: 112 additions & 0 deletions Tests/KeystoneTests/Tests/Models/PostSearchIdentifierTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation
import Testing

@testable import WordPress
@testable import WordPressData

@Suite("Spotlight identifier decomposition")
struct SearchIdentifierDecompositionTests {
@Test("a well-formed identifier decomposes")
func wellFormedIdentifierDecomposes() {
let parts = SearchIdentifierGenerator.decomposeFromUniqueIdentifier("readerPost|~~~|111|~~~|42")

#expect(parts?.itemType == .readerPost)
#expect(parts?.domain == "111")
#expect(parts?.identifier == "42")
}

@Test("malformed identifiers do not decompose")
func malformedIdentifiersDoNotDecompose() {
#expect(SearchIdentifierGenerator.decomposeFromUniqueIdentifier("abstractPost|~~~|111") == nil)
#expect(SearchIdentifierGenerator.decomposeFromUniqueIdentifier("unknown|~~~|111|~~~|42") == nil)
#expect(SearchIdentifierGenerator.decomposeFromUniqueIdentifier("garbage") == nil)
}
}

@Suite("Post search identifier parsing")
struct PostSearchIdentifierParsingTests {
@Test("a post identifier with a numeric domain parses")
func postIdentifierWithSiteIDParses() {
let identifier = AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|111|~~~|42")

#expect(identifier?.domain == "111")
#expect(identifier?.postID == 42)
#expect(identifier?.isDotCom == true)
}

@Test("a post identifier with an xmlrpc domain parses")
func postIdentifierWithXMLRPCDomainParses() {
let identifier = AbstractPost.SearchIdentifier(
identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7"
)

#expect(identifier?.domain == "https://example.com/xmlrpc.php")
#expect(identifier?.postID == 7)
#expect(identifier?.isDotCom == false)
}

@Test("invalid post identifiers do not parse")
func invalidPostIdentifiersDoNotParse() {
#expect(AbstractPost.SearchIdentifier(identifier: "readerPost|~~~|111|~~~|42") == nil)
#expect(AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|111|~~~|not-a-number") == nil)
#expect(AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|111") == nil)
#expect(AbstractPost.SearchIdentifier(identifier: "garbage") == nil)
}
}

@MainActor
@Suite("Post search identifier resolution")
struct PostSearchIdentifierResolutionTests {
@Test("a WP.com post identifier resolves the matching post")
func dotComPostResolves() {
let context = ContextManager.forTesting().mainContext
let blog = BlogBuilder(context).with(dotComID: 111).build()
let post = PostBuilder(context, blog: blog).published().build()
post.postID = 42

#expect(AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|111|~~~|42")?.post(in: context) == post)
}

@Test("a self-hosted post identifier resolves via the xmlrpc domain")
func selfHostedPostResolves() {
let context = ContextManager.forTesting().mainContext
let blog = BlogBuilder(context, dotComID: nil).build()
blog.xmlrpc = "https://example.com/xmlrpc.php"
let post = PostBuilder(context, blog: blog).published().build()
post.postID = 7

#expect(
AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7")?
.post(in: context)
== post
)
}

@Test("a page identifier resolves the matching page")
func pageResolves() {
let context = ContextManager.forTesting().mainContext
let blog = BlogBuilder(context).with(dotComID: 333).build()
let page = PageBuilder(context).build()
page.blog = blog
page.postID = 9

#expect(AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|333|~~~|9")?.post(in: context) == page)
}

@Test("an identifier for an unknown post resolves nothing")
func unknownPostResolvesNil() {
let context = ContextManager.forTesting().mainContext
BlogBuilder(context).with(dotComID: 111).build()

#expect(AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|111|~~~|42")?.post(in: context) == nil)
}

@Test("an identifier for an unknown site resolves nothing")
func unknownSiteResolvesNil() {
let context = ContextManager.forTesting().mainContext
let post = PostBuilder(context, blog: BlogBuilder(context).with(dotComID: 111).build()).build()
post.postID = 42

#expect(AbstractPost.SearchIdentifier(identifier: "abstractPost|~~~|999|~~~|42")?.post(in: context) == nil)
}
}
46 changes: 46 additions & 0 deletions WordPress/Classes/Models/AbstractPost+SearchIdentifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import WordPressData

extension AbstractPost {
/// The parsed components of a post's composite Spotlight identifier
/// (`abstractPost|~~~|<dotComID or xmlrpc>|~~~|<postID>`).
struct SearchIdentifier {
/// A WP.com site ID when numeric, the site's xmlrpc URL otherwise,
/// matching the Spotlight identifier convention.
let domain: String
let postID: Int

init?(identifier: String) {
guard
let (itemType, domain, postIDString) = SearchIdentifierGenerator.decomposeFromUniqueIdentifier(
identifier
),
itemType == .abstractPost,
let postID = Int(postIDString)
else {
return nil
}
self.domain = domain
self.postID = postID
}

/// Whether the post lives on a WP.com site.
var isDotCom: Bool {
Int(domain) != nil
}

/// The site the identifier belongs to, if it is in the local store.
func blog(in context: NSManagedObjectContext) -> Blog? {
if let siteID = Int(domain) {
return try? Blog.lookup(withID: siteID, in: context)
}
return try? BlogQuery().hostedByWPCom(false).xmlrpc(matching: domain).blog(in: context)
}

/// The post the identifier refers to, if it is in the local store.
/// Resolution never falls back to another post.
func post(in context: NSManagedObjectContext) -> AbstractPost? {
blog(in: context)?.lookupPost(withID: postID, in: context)
}
}
}
Loading