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
239 changes: 234 additions & 5 deletions Sources/LatrKit/Library/BookmarkLibrary.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import Foundation

public extension SavedLibrary {
func bookmarks(limit: Int = 50, startingAfter cursor: String? = nil) async throws -> BookmarkList {
func bookmarks(
limit: Int = 50,
startingAfter cursor: String? = nil,
taggedWith rawTag: String? = nil
) async throws -> BookmarkList {
let tag = try rawTag.map(BookmarkTags.normalized)
let page: RecordList<CommunityBookmark> = try await repository.listRecords(
in: repositoryDID,
collection: .bookmark,
Expand All @@ -10,7 +15,10 @@ public extension SavedLibrary {
)
let metadataByKey = try await bookmarkMetadataByKey()
var views: [BookmarkView] = []
for record in page.records {
let matchingRecords = tag.map { selectedTag in
page.records.filter { $0.value.tags?.contains(selectedTag) == true }
} ?? page.records
for record in matchingRecords {
guard let key = LexiconURI.recordKey(from: record.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: record.uri)
}
Expand All @@ -20,6 +28,23 @@ public extension SavedLibrary {
return BookmarkList(records: views, cursor: page.cursor)
}

func bookmarkTags(limit: Int = 100, startingAfter cursor: String? = nil) async throws -> BookmarkTagList {
let page: RecordList<CommunityBookmark> = try await repository.listRecords(
in: repositoryDID,
collection: .bookmark,
limit: min(max(limit, 1), 100),
startingAfter: cursor
)
var counts: [String: Int] = [:]
for record in page.records {
for tag in Set(record.value.tags ?? []) {
counts[tag, default: 0] += 1
}
}
let tagCounts = counts.keys.sorted().map { BookmarkTagCount(tag: $0, count: counts[$0] ?? 0) }
return BookmarkTagList(tagCounts: tagCounts, scanned: page.records.count, cursor: page.cursor)
}

func bookmark(subject rawSubject: String) async throws -> BookmarkView? {
let subject = try validatedBookmarkSubject(rawSubject)
var matches: [RepositoryRecord<CommunityBookmark>] = []
Expand Down Expand Up @@ -89,15 +114,16 @@ public extension SavedLibrary {
@discardableResult
func saveBookmark(subject rawSubject: String, tags: [String]? = nil) async throws -> BookmarkView {
let subject = try validatedBookmarkSubject(rawSubject)
let stableTags = tags.map { Array(Set($0)).sorted() }
let stableTags = try tags.map(BookmarkTags.normalized)
if let existing = try await bookmark(subject: subject) {
guard let key = LexiconURI.recordKey(from: existing.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: existing.uri)
}
var writes: [RepositoryWrite] = []
var nextBookmark = existing.value
if let stableTags {
nextBookmark.tags = Array(Set((nextBookmark.tags ?? []) + stableTags)).sorted()
let mergedTags = try BookmarkTags.merging(nextBookmark.tags ?? [], with: stableTags)
nextBookmark.tags = mergedTags.isEmpty ? nil : mergedTags
if nextBookmark.tags != existing.value.tags {
writes.append(try .updating(collection: .bookmark, key: key, value: nextBookmark, swapRecord: existing.cid))
}
Expand All @@ -112,7 +138,11 @@ public extension SavedLibrary {

let key = TID.now()
let uri = "at://\(repositoryDID)/\(LexiconCollection.bookmark.identifier)/\(key)"
let bookmark = CommunityBookmark(subject: subject, createdAt: Timestamp.iso8601Now(), tags: stableTags)
let bookmark = CommunityBookmark(
subject: subject,
createdAt: Timestamp.iso8601Now(),
tags: stableTags?.isEmpty == false ? stableTags : nil
)
let metadata = BookmarkMetadata(bookmarkUri: uri, subject: subject, state: .unread)
try await repository.applyWrites(
in: repositoryDID,
Expand All @@ -127,6 +157,73 @@ public extension SavedLibrary {
return created
}

@discardableResult
func setTags(ofBookmarkURI uri: String, to rawTags: [String]) async throws -> BookmarkView {
let tags = try BookmarkTags.normalized(rawTags)
guard let key = LexiconURI.recordKey(from: uri),
let current: RepositoryRecord<CommunityBookmark> = try await repository.record(
in: repositoryDID,
collection: .bookmark,
withKey: key
),
current.uri == uri
else {
throw SavedLibraryError.bookmarkNotFound
}

var next = current.value
next.tags = tags.isEmpty ? nil : tags
if next.tags != current.value.tags {
do {
try await repository.applyWrites(in: repositoryDID, writes: [
try .updating(collection: .bookmark, key: key, value: next, swapRecord: current.cid),
])
} catch RepositoryClientError.conflict {
throw SavedLibraryError.conflict
}
}

guard let updated: RepositoryRecord<CommunityBookmark> = try await repository.record(
in: repositoryDID,
collection: .bookmark,
withKey: key
) else {
throw SavedLibraryError.bookmarkNotFound
}
return try await bookmarkView(for: updated)
}

func renameTag(
_ rawSource: String,
to rawTarget: String,
limit: Int = 25,
continuingFrom cursor: String? = nil
) async throws -> BookmarkTagMutationSummary {
let rename = try BookmarkTags.normalizedRename(source: rawSource, target: rawTarget)
return try await mutateTag(
rename.source,
limit: limit,
continuingFrom: cursor,
transform: { tags in
deduplicatedTags(tags.map { $0 == rename.source ? rename.target : $0 })
}
)
}

func deleteTag(
_ rawTag: String,
limit: Int = 25,
continuingFrom cursor: String? = nil
) async throws -> BookmarkTagMutationSummary {
let tag = try BookmarkTags.normalized(rawTag)
return try await mutateTag(
tag,
limit: limit,
continuingFrom: cursor,
transform: { tags in deduplicatedTags(tags.filter { $0 != tag }) }
)
}

func setState(ofBookmarkURI uri: String, to state: SavedItemState) async throws {
guard let key = LexiconURI.recordKey(from: uri) else { throw SavedLibraryError.bookmarkNotFound }
let bookmark: RepositoryRecord<CommunityBookmark>? = try await repository.record(
Expand Down Expand Up @@ -183,6 +280,102 @@ public extension SavedLibrary {
return value
}

private func bookmarkView(for bookmark: RepositoryRecord<CommunityBookmark>) async throws -> BookmarkView {
guard let key = LexiconURI.recordKey(from: bookmark.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: bookmark.uri)
}
let metadata: RepositoryRecord<BookmarkMetadata>? = try await repository.record(
in: repositoryDID,
collection: .bookmarkMetadata,
withKey: key
)
return BookmarkView(
record: bookmark,
metadataRecord: metadata.flatMap { bookmarkMetadata($0, matches: bookmark) ? $0 : nil }
)
}

private func mutateTag(
_ source: String,
limit: Int,
continuingFrom rawCursor: String?,
transform: ([String]) -> [String]
) async throws -> BookmarkTagMutationSummary {
let cursor = try BookmarkTagMutationCursor(rawValue: rawCursor)
let page: RecordList<CommunityBookmark> = try await repository.listRecords(
in: repositoryDID,
collection: .bookmark,
limit: min(max(limit, 1), 25),
startingAfter: cursor.repositoryCursor
)
let matches = page.records.filter { $0.value.tags?.contains(source) == true }
var writes: [RepositoryWrite] = []
writes.reserveCapacity(matches.count)

for record in matches {
guard let key = LexiconURI.recordKey(from: record.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: record.uri)
}
var next = record.value
let tags = transform(record.value.tags ?? [])
next.tags = tags.isEmpty ? nil : tags
writes.append(try .updating(
collection: .bookmark,
key: key,
value: next,
swapRecord: record.cid
))
}

if !writes.isEmpty {
do {
try await repository.applyWrites(in: repositoryDID, writes: writes)
} catch RepositoryClientError.conflict {
throw SavedLibraryError.conflict
}
}

switch cursor {
case .mutation:
let nextCursor = page.cursor.map(BookmarkTagMutationCursor.mutation)
?? .verification(nil)
return BookmarkTagMutationSummary(
scanned: page.records.count,
matched: matches.count,
updated: writes.count,
cursor: nextCursor.rawValue
)
case .verification:
if !writes.isEmpty {
return BookmarkTagMutationSummary(
scanned: page.records.count,
matched: matches.count,
updated: writes.count,
cursor: BookmarkTagMutationCursor.verification(nil).rawValue
)
}
if let next = page.cursor {
return BookmarkTagMutationSummary(
scanned: page.records.count,
matched: 0,
updated: 0,
cursor: BookmarkTagMutationCursor.verification(next).rawValue
)
}
return BookmarkTagMutationSummary(
scanned: page.records.count,
matched: 0,
updated: 0,
cursor: nil
)
}
}

private func deduplicatedTags(_ tags: [String]) -> [String] {
var seen: Set<String> = []
return tags.filter { seen.insert($0).inserted }
}

private func canonicalBookmark(from records: [RepositoryRecord<CommunityBookmark>]) -> RepositoryRecord<CommunityBookmark>? {
records.sorted {
if $0.value.createdAt != $1.value.createdAt { return $0.value.createdAt < $1.value.createdAt }
Expand Down Expand Up @@ -217,3 +410,39 @@ public extension SavedLibrary {
metadata.value.bookmarkUri == bookmark.uri && metadata.value.subject == bookmark.value.subject
}
}

private enum BookmarkTagMutationCursor {
case mutation(String?)
case verification(String?)

init(rawValue: String?) throws {
guard let rawValue else {
self = .mutation(nil)
return
}
if rawValue.hasPrefix("m:") {
let value = String(rawValue.dropFirst(2))
self = .mutation(value.isEmpty ? nil : value)
return
}
if rawValue.hasPrefix("v:") {
let value = String(rawValue.dropFirst(2))
self = .verification(value.isEmpty ? nil : value)
return
}
throw SavedLibraryError.invalidTagMutationCursor
}

var repositoryCursor: String? {
switch self {
case let .mutation(cursor), let .verification(cursor): cursor
}
}

var rawValue: String {
switch self {
case let .mutation(cursor): "m:\(cursor ?? "")"
case let .verification(cursor): "v:\(cursor ?? "")"
}
}
}
1 change: 1 addition & 0 deletions Sources/LatrKit/Library/SavedLibraryError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public enum SavedLibraryError: Error, Sendable {
case conflict
case invalidStoredRecord(uri: String)
case bookmarkNotFound
case invalidTagMutationCursor
}
Loading
Loading