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

public extension SavedLibrary {
func bookmarks(limit: Int = 50, startingAfter cursor: String? = nil) async throws -> BookmarkList {
let page: RecordList<CommunityBookmark> = try await repository.listRecords(
in: repositoryDID,
collection: .bookmark,
limit: min(max(limit, 1), 100),
startingAfter: cursor
)
let metadataByKey = try await bookmarkMetadataByKey()
var views: [BookmarkView] = []
for record in page.records {
guard let key = LexiconURI.recordKey(from: record.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: record.uri)
}
let metadata = metadataByKey[key].flatMap { bookmarkMetadata($0, matches: record) ? $0 : nil }
views.append(BookmarkView(record: record, metadataRecord: metadata))
}
return BookmarkList(records: views, cursor: page.cursor)
}

func bookmark(subject rawSubject: String) async throws -> BookmarkView? {
let subject = try validatedBookmarkSubject(rawSubject)
var matches: [RepositoryRecord<CommunityBookmark>] = []
var cursor: String?
repeat {
let page: RecordList<CommunityBookmark> = try await repository.listRecords(
in: repositoryDID, collection: .bookmark, limit: 100, startingAfter: cursor
)
matches.append(contentsOf: page.records.filter { $0.value.subject == subject })
cursor = page.cursor
} while cursor != nil
guard let selected = canonicalBookmark(from: matches) else { return nil }
guard let key = LexiconURI.recordKey(from: selected.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: selected.uri)
}
let metadata: RepositoryRecord<BookmarkMetadata>? = try await repository.record(
in: repositoryDID, collection: .bookmarkMetadata, withKey: key
)
return BookmarkView(
record: selected,
metadataRecord: metadata.flatMap { bookmarkMetadata($0, matches: selected) ? $0 : nil }
)
}

func syncBookmarkMetadata(
limit rawLimit: Int = 50,
startingAfter cursor: String? = nil
) async throws -> BookmarkMetadataSyncSummary {
let page: RecordList<CommunityBookmark> = try await repository.listRecords(
in: repositoryDID,
collection: .bookmark,
limit: min(max(rawLimit, 1), 100),
startingAfter: cursor
)
let metadataByKey = try await bookmarkMetadataByKey()
var summary = BookmarkMetadataSyncSummary(scanned: page.records.count, cursor: page.cursor)
var writes: [RepositoryWrite] = []

for bookmark in page.records {
guard let key = LexiconURI.recordKey(from: bookmark.uri) else {
throw SavedLibraryError.invalidStoredRecord(uri: bookmark.uri)
}
if let metadata = metadataByKey[key] {
if bookmarkMetadata(metadata, matches: bookmark) {
summary.reused += 1
} else {
summary.skippedConflict += 1
}
continue
}

let metadata = BookmarkMetadata(
bookmarkUri: bookmark.uri,
subject: bookmark.value.subject,
state: .unread
)
writes.append(try .creating(collection: .bookmarkMetadata, key: key, value: metadata))
}

if !writes.isEmpty {
try await repository.applyWrites(in: repositoryDID, writes: writes)
summary.created = writes.count
}
return summary
}

@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() }
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()
if nextBookmark.tags != existing.value.tags {
writes.append(try .updating(collection: .bookmark, key: key, value: nextBookmark, swapRecord: existing.cid))
}
}
if existing.metadataRecord == nil {
let metadata = BookmarkMetadata(bookmarkUri: existing.uri, subject: subject, state: .unread)
writes.append(try .creating(collection: .bookmarkMetadata, key: key, value: metadata))
}
if !writes.isEmpty { try await repository.applyWrites(in: repositoryDID, writes: writes) }
return try await bookmark(subject: subject) ?? existing
}

let key = TID.now()
let uri = "at://\(repositoryDID)/\(LexiconCollection.bookmark.identifier)/\(key)"
let bookmark = CommunityBookmark(subject: subject, createdAt: Timestamp.iso8601Now(), tags: stableTags)
let metadata = BookmarkMetadata(bookmarkUri: uri, subject: subject, state: .unread)
try await repository.applyWrites(
in: repositoryDID,
writes: [
try .creating(collection: .bookmark, key: key, value: bookmark),
try .creating(collection: .bookmarkMetadata, key: key, value: metadata),
]
)
guard let created = try await self.bookmark(subject: subject) else {
throw SavedLibraryError.invalidStoredRecord(uri: uri)
}
return created
}

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(
in: repositoryDID, collection: .bookmark, withKey: key
)
guard let bookmark else { throw SavedLibraryError.bookmarkNotFound }
let current: RepositoryRecord<BookmarkMetadata>? = try await repository.record(
in: repositoryDID, collection: .bookmarkMetadata, withKey: key
)
if let current {
guard bookmarkMetadata(current, matches: bookmark) else {
throw SavedLibraryError.invalidStoredRecord(uri: current.uri)
}
var next = current.value
next.state = state
try await repository.applyWrites(in: repositoryDID, writes: [
try .updating(collection: .bookmarkMetadata, key: key, value: next, swapRecord: current.cid),
])
} else {
let metadata = BookmarkMetadata(bookmarkUri: bookmark.uri, subject: bookmark.value.subject, state: state)
try await repository.applyWrites(in: repositoryDID, writes: [
try .creating(collection: .bookmarkMetadata, key: key, value: metadata),
])
}
}

func removeBookmark(uri: String) async throws {
guard let key = LexiconURI.recordKey(from: uri) else { throw SavedLibraryError.bookmarkNotFound }
guard let bookmark: RepositoryRecord<CommunityBookmark> = try await repository.record(
in: repositoryDID, collection: .bookmark, withKey: key
) else { throw SavedLibraryError.bookmarkNotFound }
let metadata: RepositoryRecord<BookmarkMetadata>? = try await repository.record(
in: repositoryDID, collection: .bookmarkMetadata, withKey: key
)
var writes: [RepositoryWrite] = [
.delete(collection: .bookmark, key: key, swapRecord: bookmark.cid),
]
if let metadata, bookmarkMetadata(metadata, matches: bookmark) {
writes.append(.delete(collection: .bookmarkMetadata, key: key, swapRecord: metadata.cid))
}
try await repository.applyWrites(in: repositoryDID, writes: writes)
}

private func validatedBookmarkSubject(_ raw: String) throws -> String {
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.utf8.count <= 8192 else { throw SavedLibraryError.invalidURL }
if value.hasPrefix("at://") {
guard value.split(separator: "/").count >= 4 else { throw SavedLibraryError.invalidURL }
return value
}
guard let scheme = URLComponents(string: value)?.scheme?.lowercased(), ["http", "https"].contains(scheme) else {
throw SavedLibraryError.invalidURL
}
return value
}

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 }
return $0.uri < $1.uri
}.first
}

private func bookmarkMetadataByKey() async throws -> [String: RepositoryRecord<BookmarkMetadata>] {
var metadataByKey: [String: RepositoryRecord<BookmarkMetadata>] = [:]
var cursor: String?
repeat {
let page: RecordList<BookmarkMetadata> = try await repository.listRecords(
in: repositoryDID,
collection: .bookmarkMetadata,
limit: 100,
startingAfter: cursor
)
for metadata in page.records {
if let key = LexiconURI.recordKey(from: metadata.uri) {
metadataByKey[key] = metadata
}
}
cursor = page.cursor
} while cursor != nil
return metadataByKey
}

private func bookmarkMetadata(
_ metadata: RepositoryRecord<BookmarkMetadata>,
matches bookmark: RepositoryRecord<CommunityBookmark>
) -> Bool {
metadata.value.bookmarkUri == bookmark.uri && metadata.value.subject == bookmark.value.subject
}
}
162 changes: 162 additions & 0 deletions Sources/LatrKit/Library/BookmarkMigration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import Foundation

public struct BookmarkMigrationSummary: Codable, Sendable, Equatable {
public var ok = true
public var scanned = 0
public var created = 0
public var reused = 0
public var duplicates = 0
public var skippedConflict = 0
public var cached = 0
public var retired = 0
public var cursor: String?

public init() {}
}

private struct LegacyItemSource: Sendable {
let collection: LexiconCollection
let record: RepositoryRecord<SavedItem>
}

private struct LegacyExternalSource: Sendable {
let collection: LexiconCollection
let record: RepositoryRecord<ExternalSave>
}

public extension SavedLibrary {
func migrateBookmarks(limit rawLimit: Int = 25, cursor rawCursor: String? = nil) async throws -> BookmarkMigrationSummary {
let limit = min(max(rawLimit, 1), 100)
let subjectCursor = rawCursor?.trimmingCharacters(in: .whitespacesAndNewlines)
let externalSources = try await allExternalMigrationSources()
let externalByURI = Dictionary(uniqueKeysWithValues: externalSources.map { ($0.record.uri, $0) })
let itemSources = try await allItemMigrationSources().sorted { $0.record.uri < $1.record.uri }

let candidates = itemSources.compactMap { source -> (LegacyItemSource, String)? in
guard let subject = migrationSubject(for: source.record.value, externalByURI: externalByURI) else { return nil }
return (source, subject)
}
let grouped = Dictionary(grouping: candidates, by: { $0.1 })
.sorted { $0.key < $1.key }
let remaining = grouped.filter { subjectCursor == nil || $0.key > subjectCursor! }
let page = Array(remaining.prefix(limit))
var summary = BookmarkMigrationSummary()

for (subject, entries) in page {
summary.scanned += entries.count
if entries.count > 1 { summary.duplicates += entries.count - 1 }
let items = entries.map(\.0)
let wrappers = Set(items.map(\.record.value.subjectUri)).compactMap { externalByURI[$0] }
let notes = Set(items.compactMap { $0.record.value.note?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })
let hasUnknownFields = items.contains { !$0.record.value.unknownFields.isEmpty }
|| wrappers.contains { !$0.record.value.unknownFields.isEmpty }
if notes.count > 1 || hasUnknownFields {
summary.skippedConflict += entries.count
continue
}

let existing = try await bookmark(subject: subject)
let key = existing.flatMap { LexiconURI.recordKey(from: $0.uri) } ?? TID.now()
let bookmarkURI = existing?.uri ?? "at://\(repositoryDID)/\(LexiconCollection.bookmark.identifier)/\(key)"
let tags = Array(Set(items.flatMap { $0.record.value.tags ?? [] })).sorted()
let createdAt = items.map(\.record.value.savedAt).min() ?? Timestamp.iso8601Now()
let state: SavedItemState = items.contains { $0.record.value.state != .archived } ? .unread : .archived
let lastOpenedAt = items.compactMap(\.record.value.lastOpenedAt).max()
let legacyURIs = items.map(\.record.uri).sorted()
let note = notes.first
let nextBookmark = CommunityBookmark(
subject: subject,
createdAt: min(existing?.value.createdAt ?? createdAt, createdAt),
tags: Array(Set((existing?.value.tags ?? []) + tags)).sorted(),
unknownFields: existing?.value.unknownFields ?? [:]
)
let currentMetadata = existing?.metadataRecord
if let currentNote = currentMetadata?.value.note?.trimmingCharacters(in: .whitespacesAndNewlines),
!currentNote.isEmpty, let note, currentNote != note {
summary.skippedConflict += entries.count
continue
}
var nextMetadata = currentMetadata?.value ?? BookmarkMetadata(bookmarkUri: bookmarkURI, subject: subject)
nextMetadata.state = currentMetadata?.value.state ?? state
nextMetadata.note = currentMetadata?.value.note ?? note
nextMetadata.lastOpenedAt = max(currentMetadata?.value.lastOpenedAt ?? "", lastOpenedAt ?? "").nilIfEmpty
nextMetadata.legacyItemUris = Array(Set((nextMetadata.legacyItemUris ?? []) + legacyURIs)).sorted()

var writes: [RepositoryWrite] = []
if let existing {
writes.append(try .updating(collection: .bookmark, key: key, value: nextBookmark, swapRecord: existing.cid))
summary.reused += 1
} else {
writes.append(try .creating(collection: .bookmark, key: key, value: nextBookmark))
summary.created += 1
}
if let currentMetadata {
writes.append(try .updating(collection: .bookmarkMetadata, key: key, value: nextMetadata, swapRecord: currentMetadata.cid))
} else {
writes.append(try .creating(collection: .bookmarkMetadata, key: key, value: nextMetadata))
}
for item in items {
guard let itemKey = LexiconURI.recordKey(from: item.record.uri) else { continue }
writes.append(.delete(collection: item.collection, key: itemKey, swapRecord: item.record.cid))
}
for wrapper in wrappers {
guard let wrapperKey = LexiconURI.recordKey(from: wrapper.record.uri) else { continue }
writes.append(.delete(collection: wrapper.collection, key: wrapperKey, swapRecord: wrapper.record.cid))
}
do {
try await repository.applyWrites(in: repositoryDID, writes: writes)
summary.retired += items.count + wrappers.count
} catch RepositoryClientError.conflict {
summary.skippedConflict += entries.count
if existing == nil { summary.created -= 1 } else { summary.reused -= 1 }
}
}

summary.cursor = page.count < remaining.count ? page.last?.key : nil
return summary
}

private func migrationSubject(
for item: SavedItem,
externalByURI: [String: LegacyExternalSource]
) -> String? {
if let wrapper = externalByURI[item.subjectUri] {
let original = wrapper.record.value.url.trimmingCharacters(in: .whitespacesAndNewlines)
if original.hasPrefix("https://") || original.hasPrefix("http://") { return original }
return wrapper.record.value.normalizedUrl
}
if let linked = item.linkedWebUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
linked.hasPrefix("https://") || linked.hasPrefix("http://") { return linked }
return item.subjectUri
}

private func allItemMigrationSources() async throws -> [LegacyItemSource] {
var result: [LegacyItemSource] = []
for collection in [LexiconCollection.savedItem, .legacySavedItem] {
var cursor: String?
repeat {
let page: RecordList<SavedItem> = try await repository.listRecords(in: repositoryDID, collection: collection, limit: 100, startingAfter: cursor)
result.append(contentsOf: page.records.map { LegacyItemSource(collection: collection, record: $0) })
cursor = page.cursor
} while cursor != nil
}
return result
}

private func allExternalMigrationSources() async throws -> [LegacyExternalSource] {
var result: [LegacyExternalSource] = []
for collection in [LexiconCollection.external, .legacyExternal] {
var cursor: String?
repeat {
let page: RecordList<ExternalSave> = try await repository.listRecords(in: repositoryDID, collection: collection, limit: 100, startingAfter: cursor)
result.append(contentsOf: page.records.map { LegacyExternalSource(collection: collection, record: $0) })
cursor = page.cursor
} while cursor != nil
}
return result
}
}

private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
1 change: 1 addition & 0 deletions Sources/LatrKit/Library/SavedLibraryError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public enum SavedLibraryError: Error, Sendable {
case itemNotFound
case conflict
case invalidStoredRecord(uri: String)
case bookmarkNotFound
}
Loading
Loading