Compare commits

..

No commits in common. "aeb0bcad2e918f54aea5d1a33793cdace5aba7e3" and "eddc8a35ff82990b8fb5bde9f8d81937f4823ed3" have entirely different histories.

50 changed files with 360 additions and 2070 deletions

View File

@ -49,16 +49,14 @@ class OfflineBookmarkManager: @unchecked Sendable {
}
}
func getTags() async -> [String] {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
func getTags() -> [String] {
do {
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
return try context.safePerform { [weak self] in
guard let self = self else { return [] }
let tagEntities = try backgroundContext.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
let tagEntities = try self.context.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }.sorted()
}
} catch {
print("Failed to fetch tags: \(error)")
@ -66,37 +64,4 @@ class OfflineBookmarkManager: @unchecked Sendable {
}
}
func saveTags(_ tags: [String]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
let existingNames = Set(existingEntities.compactMap { $0.name })
// Only insert new tags
var insertCount = 0
for tag in tags {
if !existingNames.contains(tag) {
let entity = TagEntity(context: backgroundContext)
entity.name = tag
insertCount += 1
}
}
// Only save if there are new tags
if insertCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags to Core Data")
}
}
} catch {
print("Failed to save tags: \(error)")
}
}
}

View File

@ -0,0 +1,62 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {}
// Check if the Readeck server endpoint is reachable
static func isServerReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint + "/api/health") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 5.0 // 5 second timeout
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
print("Server connectivity check failed: \(error)")
}
return false
}
// Alternative check using ping-style endpoint
static func isServerReachableSync() -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint) else {
return false
}
let semaphore = DispatchSemaphore(value: 0)
var isReachable = false
var request = URLRequest(url: url)
request.httpMethod = "HEAD" // Just check if server responds
request.timeoutInterval = 3.0
let task = URLSession.shared.dataTask(with: request) { _, response, error in
if let httpResponse = response as? HTTPURLResponse {
isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error
}
semaphore.signal()
}
task.resume()
_ = semaphore.wait(timeout: .now() + 3.0)
return isReachable
}
}

View File

@ -15,7 +15,6 @@ class ShareBookmarkViewModel: ObservableObject {
let extensionContext: NSExtensionContext?
private let logger = Logger.viewModel
private let serverCheck = ShareExtensionServerCheck.shared
var availableLabels: [BookmarkLabelDto] {
return labels.filter { !selectedLabels.contains($0.name) }
@ -51,9 +50,17 @@ class ShareBookmarkViewModel: ObservableObject {
func onAppear() {
logger.debug("ShareBookmarkViewModel appeared")
checkServerReachability()
loadLabels()
}
private func checkServerReachability() {
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
isServerReachable = ServerConnectivity.isServerReachableSync()
logger.info("Server reachability checked: \(isServerReachable)")
measurement.end()
}
private func extractSharedContent() {
logger.debug("Starting to extract shared content")
guard let extensionContext = extensionContext else {
@ -124,43 +131,30 @@ class ShareBookmarkViewModel: ObservableObject {
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
logger.debug("Starting to load labels")
Task {
// 1. First, load from Core Data (instant response)
let localTags = await OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
}
await MainActor.run {
self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local cache")
}
// 2. Then check server and sync in background
let serverReachable = await serverCheck.checkServerReachability()
await MainActor.run {
self.isServerReachable = serverReachable
}
let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server reachable for labels: \(serverReachable)")
if serverReachable {
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
if error {
self?.logger.error("Failed to sync labels from API: \(message)")
}
self?.statusMessage = (message, error, error ? "" : "")
} ?? []
// Save new labels to Core Data
let tagNames = loaded.map { $0.name }
await OfflineBookmarkManager.shared.saveTags(tagNames)
let sorted = loaded.sorted { $0.count > $1.count }
await MainActor.run {
self.labels = Array(sorted)
self.logger.info("Synced \(loaded.count) labels from API and updated cache")
self.logger.info("Loaded \(loaded.count) labels from API")
measurement.end()
}
} else {
measurement.end()
let localTags = OfflineBookmarkManager.shared.getTags()
let localLabels = localTags.enumerated().map { index, tagName in
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
await MainActor.run {
self.labels = localLabels
self.logger.info("Loaded \(localLabels.count) labels from local database")
measurement.end()
}
}
}
}
@ -176,12 +170,12 @@ class ShareBookmarkViewModel: ObservableObject {
logger.debug("Set saving state to true")
// Check server connectivity
Task {
let serverReachable = await serverCheck.checkServerReachability()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API
logger.info("Attempting to save bookmark via API")
let serverReachable = ServerConnectivity.isServerReachableSync()
logger.debug("Server connectivity for save: \(serverReachable)")
if serverReachable {
// Online - try to save via API
logger.info("Attempting to save bookmark via API")
Task {
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
self?.statusMessage = (message, error, error ? "" : "")
@ -195,28 +189,28 @@ class ShareBookmarkViewModel: ObservableObject {
self?.logger.error("Failed to save bookmark via API: \(message)")
}
}
} else {
// Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url,
title: title,
tags: Array(selectedLabels)
)
logger.info("Local save result: \(success)")
}
} else {
// Server not reachable - save locally
logger.info("Server not reachable, attempting local save")
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
url: url,
title: title,
tags: Array(selectedLabels)
)
logger.info("Local save result: \(success)")
DispatchQueue.main.async {
self.isSaving = false
if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.completeExtensionRequest()
}
} else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "")
DispatchQueue.main.async {
self.isSaving = false
if success {
self.logger.info("Bookmark saved locally successfully")
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.completeExtensionRequest()
}
} else {
self.logger.error("Failed to save bookmark locally")
self.statusMessage = ("Failed to save locally.", true, "")
}
}
}

View File

@ -1,41 +0,0 @@
import Foundation
/// Simple server check manager for Share Extension with caching
class ShareExtensionServerCheck {
static let shared = ShareExtensionServerCheck()
// Cache properties
private var cachedResult: Bool?
private var lastCheckTime: Date?
private let cacheTTL: TimeInterval = 30.0
private init() {}
func checkServerReachability() async -> Bool {
// Check cache first
if let cached = getCachedResult() {
return cached
}
// Use SimpleAPI for actual check
let result = await SimpleAPI.checkServerReachability()
updateCache(result: result)
return result
}
// MARK: - Cache Management
private func getCachedResult() -> Bool? {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedResult else {
return nil
}
return cached
}
private func updateCache(result: Bool) {
cachedResult = result
lastCheckTime = Date()
}
}

View File

@ -3,39 +3,6 @@ import Foundation
class SimpleAPI {
private static let logger = Logger.network
// MARK: - Server Info
static func checkServerReachability() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: "\(endpoint)/api/info") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
request.timeoutInterval = 5.0
if let token = KeychainHelper.shared.loadToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
200...299 ~= httpResponse.statusCode {
logger.info("Server is reachable")
return true
}
} catch {
logger.error("Server reachability check failed: \(error.localizedDescription)")
return false
}
return false
}
// MARK: - API Methods
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
logger.info("Adding bookmark: \(url)")

View File

@ -1,17 +1,5 @@
import Foundation
public struct ServerInfoDto: Codable {
public let version: String
public let buildDate: String?
public let userAgent: String?
public enum CodingKeys: String, CodingKey {
case version
case buildDate = "build_date"
case userAgent = "user_agent"
}
}
public struct CreateBookmarkRequestDto: Codable {
public let labels: [String]?
public let title: String?
@ -45,3 +33,4 @@ public struct BookmarkLabelDto: Codable, Identifiable {
self.href = href
}
}

View File

@ -437,7 +437,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -470,7 +470,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
@ -625,7 +625,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;
@ -669,7 +669,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES;

View File

@ -18,8 +18,6 @@ protocol PAPI {
func deleteBookmark(id: String) async throws
func searchBookmarks(search: String) async throws -> BookmarksPageDto
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
}
class API: PAPI {
@ -446,46 +444,6 @@ class API: PAPI {
logger.info("Successfully fetched \(result.count) bookmark labels")
return result
}
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto] {
logger.debug("Fetching annotations for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
let result = try await makeJSONRequest(
endpoint: endpoint,
responseType: [AnnotationDto].self
)
logger.info("Successfully fetched \(result.count) annotations for bookmark: \(bookmarkId)")
return result
}
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto {
logger.debug("Creating annotation for bookmark: \(bookmarkId)")
let endpoint = "/api/bookmarks/\(bookmarkId)/annotations"
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
let bodyDict: [String: Any] = [
"color": color,
"start_offset": startOffset,
"end_offset": endOffset,
"start_selector": startSelector,
"end_selector": endSelector
]
let bodyData = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
let result = try await makeJSONRequest(
endpoint: endpoint,
method: .POST,
body: bodyData,
responseType: AnnotationDto.self
)
logger.info("Successfully created annotation for bookmark: \(bookmarkId)")
return result
}
}
enum HTTPMethod: String {

View File

@ -1,21 +0,0 @@
import Foundation
struct AnnotationDto: Codable {
let id: String
let text: String
let created: String
let startOffset: Int
let endOffset: Int
let startSelector: String
let endSelector: String
enum CodingKeys: String, CodingKey {
case id
case text
case created
case startOffset = "start_offset"
case endOffset = "end_offset"
case startSelector = "start_selector"
case endSelector = "end_selector"
}
}

View File

@ -1,13 +0,0 @@
import Foundation
struct ServerInfoDto: Codable {
let version: String
let buildDate: String?
let userAgent: String?
enum CodingKeys: String, CodingKey {
case version
case buildDate = "build_date"
case userAgent = "user_agent"
}
}

View File

@ -1,55 +0,0 @@
//
// InfoApiClient.swift
// readeck
//
// Created by Claude Code
import Foundation
protocol PInfoApiClient {
func getServerInfo() async throws -> ServerInfoDto
}
class InfoApiClient: PInfoApiClient {
private let tokenProvider: TokenProvider
private let logger = Logger.network
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
self.tokenProvider = tokenProvider
}
func getServerInfo() async throws -> ServerInfoDto {
guard let endpoint = await tokenProvider.getEndpoint(),
let url = URL(string: "\(endpoint)/api/info") else {
logger.error("Invalid endpoint URL for server info")
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
request.timeoutInterval = 5.0
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
}
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid HTTP response for server info")
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
throw APIError.serverError(httpResponse.statusCode)
}
logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode)
return try JSONDecoder().decode(ServerInfoDto.self, from: data)
}
}

View File

@ -1,24 +0,0 @@
import Foundation
class AnnotationsRepository: PAnnotationsRepository {
private let api: PAPI
init(api: PAPI) {
self.api = api
}
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation] {
let annotationDtos = try await api.getBookmarkAnnotations(bookmarkId: bookmarkId)
return annotationDtos.map { dto in
Annotation(
id: dto.id,
text: dto.text,
created: dto.created,
startOffset: dto.startOffset,
endOffset: dto.endOffset,
startSelector: dto.startSelector,
endSelector: dto.endSelector
)
}
}
}

View File

@ -11,66 +11,34 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
}
func getLabels() async throws -> [BookmarkLabel] {
// First, load from Core Data (instant response)
let cachedLabels = try await loadLabelsFromCoreData()
// Then sync with API in background (don't wait)
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }
do {
let dtos = try await self.api.getBookmarkLabels()
try? await self.saveLabels(dtos)
} catch {
// Silent fail - we already have cached data
}
}
return cachedLabels
}
private func loadLabelsFromCoreData() async throws -> [BookmarkLabel] {
let backgroundContext = coreDataManager.newBackgroundContext()
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
let entities = try backgroundContext.fetch(fetchRequest)
return entities.compactMap { entity -> BookmarkLabel? in
guard let name = entity.name, !name.isEmpty else { return nil }
return BookmarkLabel(
name: name,
count: 0,
href: name
)
}
}
let dtos = try await api.getBookmarkLabels()
try? await saveLabels(dtos)
return dtos.map { $0.toDomain() }
}
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
// Batch fetch all existing label names (much faster than individual queries)
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
let existingNames = Set(existingEntities.compactMap { $0.name })
// Only insert new labels
var insertCount = 0
try await backgroundContext.perform { [weak self] in
guard let self = self else { return }
for dto in dtos {
if !existingNames.contains(dto.name) {
if !self.tagExists(name: dto.name, in: backgroundContext) {
dto.toEntity(context: backgroundContext)
insertCount += 1
}
}
try backgroundContext.save()
}
}
// Only save if there are new labels
if insertCount > 0 {
try backgroundContext.save()
}
private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
do {
let count = try context.count(for: fetchRequest)
return count > 0
} catch {
return false
}
}
}

View File

@ -10,19 +10,16 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
private let coreDataManager = CoreDataManager.shared
private let api: PAPI
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
init(api: PAPI = API(),
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
init(api: PAPI = API()) {
self.api = api
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
}
// MARK: - Sync Methods
func syncOfflineBookmarks() async {
// First check if server is reachable
guard await checkServerReachabilityUseCase.execute() else {
guard await ServerConnectivity.isServerReachable() else {
await MainActor.run {
isSyncing = false
syncStatus = "Server not reachable. Cannot sync."
@ -124,4 +121,22 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
}
}
// MARK: - Auto Sync on Server Connectivity Changes
func startAutoSync() {
// Monitor server connectivity and auto-sync when server becomes reachable
NotificationCenter.default.addObserver(
forName: .serverDidBecomeAvailable,
object: nil,
queue: .main
) { [weak self] _ in
Task {
await self?.syncOfflineBookmarks()
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}

View File

@ -1,114 +0,0 @@
//
// ServerInfoRepository.swift
// readeck
//
// Created by Claude Code
import Foundation
class ServerInfoRepository: PServerInfoRepository {
private let apiClient: PInfoApiClient
private let logger = Logger.network
// Cache properties
private var cachedServerInfo: ServerInfo?
private var lastCheckTime: Date?
private let cacheTTL: TimeInterval = 30.0 // 30 seconds cache
private let rateLimitInterval: TimeInterval = 5.0 // min 5 seconds between requests
// Thread safety
private let queue = DispatchQueue(label: "com.readeck.serverInfoRepository", attributes: .concurrent)
init(apiClient: PInfoApiClient) {
self.apiClient = apiClient
}
func checkServerReachability() async -> Bool {
// Check cache first
if let cached = getCachedReachability() {
logger.debug("Server reachability from cache: \(cached)")
return cached
}
// Check rate limiting
if isRateLimited() {
logger.debug("Server reachability check rate limited, using cached value")
return cachedServerInfo?.isReachable ?? false
}
// Perform actual check
do {
let info = try await apiClient.getServerInfo()
let serverInfo = ServerInfo(from: info)
updateCache(serverInfo: serverInfo)
logger.info("Server reachability checked: true (version: \(info.version))")
return true
} catch {
let unreachableInfo = ServerInfo.unreachable
updateCache(serverInfo: unreachableInfo)
logger.warning("Server reachability check failed: \(error.localizedDescription)")
return false
}
}
func getServerInfo() async throws -> ServerInfo {
// Check cache first
if let cached = getCachedServerInfo() {
logger.debug("Server info from cache")
return cached
}
// Check rate limiting
if isRateLimited(), let cached = cachedServerInfo {
logger.debug("Server info check rate limited, using cached value")
return cached
}
// Fetch fresh info
let dto = try await apiClient.getServerInfo()
let serverInfo = ServerInfo(from: dto)
updateCache(serverInfo: serverInfo)
logger.info("Server info fetched: version \(dto.version)")
return serverInfo
}
// MARK: - Cache Management
private func getCachedReachability() -> Bool? {
queue.sync {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedServerInfo else {
return nil
}
return cached.isReachable
}
}
private func getCachedServerInfo() -> ServerInfo? {
queue.sync {
guard let lastCheck = lastCheckTime,
Date().timeIntervalSince(lastCheck) < cacheTTL,
let cached = cachedServerInfo else {
return nil
}
return cached
}
}
private func isRateLimited() -> Bool {
queue.sync {
guard let lastCheck = lastCheckTime else {
return false
}
return Date().timeIntervalSince(lastCheck) < rateLimitInterval
}
}
private func updateCache(serverInfo: ServerInfo) {
queue.async(flags: .barrier) { [weak self] in
self?.cachedServerInfo = serverInfo
self?.lastCheckTime = Date()
}
}
}

View File

@ -0,0 +1,92 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue.global(qos: .background)
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
if path.status == .satisfied {
// Network is available, now check server
Task {
let serverReachable = await ServerConnectivity.isServerReachable()
DispatchQueue.main.async {
let wasReachable = self?.isServerReachable ?? false
self?.isServerReachable = serverReachable
// Notify when server becomes available
if !wasReachable && serverReachable {
NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil)
}
}
}
} else {
DispatchQueue.main.async {
self?.isServerReachable = false
}
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
// Check if the Readeck server endpoint is reachable
static func isServerReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint + "/api/health") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 5.0 // 5 second timeout
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
// Fallback: try basic endpoint if health endpoint doesn't exist
return await isBasicEndpointReachable()
}
return false
}
private static func isBasicEndpointReachable() async -> Bool {
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
!endpoint.isEmpty,
let url = URL(string: endpoint) else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 3.0
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode < 500
}
} catch {
print("Server connectivity check failed: \(error)")
}
return false
}
}

View File

@ -1,19 +0,0 @@
import Foundation
struct Annotation: Identifiable, Hashable {
let id: String
let text: String
let created: String
let startOffset: Int
let endOffset: Int
let startSelector: String
let endSelector: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Annotation, rhs: Annotation) -> Bool {
lhs.id == rhs.id
}
}

View File

@ -1,21 +0,0 @@
import Foundation
struct ServerInfo {
let version: String
let buildDate: String?
let userAgent: String?
let isReachable: Bool
}
extension ServerInfo {
init(from dto: ServerInfoDto) {
self.version = dto.version
self.buildDate = dto.buildDate
self.userAgent = dto.userAgent
self.isReachable = true
}
static var unreachable: ServerInfo {
ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false)
}
}

View File

@ -1,3 +0,0 @@
protocol PAnnotationsRepository {
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
}

View File

@ -1,10 +0,0 @@
//
// PServerInfoRepository.swift
// readeck
//
// Created by Claude Code
protocol PServerInfoRepository {
func checkServerReachability() async -> Bool
func getServerInfo() async throws -> ServerInfo
}

View File

@ -1,28 +0,0 @@
//
// CheckServerReachabilityUseCase.swift
// readeck
//
// Created by Claude Code
import Foundation
protocol PCheckServerReachabilityUseCase {
func execute() async -> Bool
func getServerInfo() async throws -> ServerInfo
}
class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
private let repository: PServerInfoRepository
init(repository: PServerInfoRepository) {
self.repository = repository
}
func execute() async -> Bool {
return await repository.checkServerReachability()
}
func getServerInfo() async throws -> ServerInfo {
return try await repository.getServerInfo()
}
}

View File

@ -1,17 +0,0 @@
import Foundation
protocol PGetBookmarkAnnotationsUseCase {
func execute(bookmarkId: String) async throws -> [Annotation]
}
class GetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(bookmarkId: String) async throws -> [Annotation] {
return try await repository.fetchAnnotations(bookmarkId: bookmarkId)
}
}

View File

@ -4,39 +4,6 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
## Version 1.2
### Annotations & Highlighting
- **Highlight important passages** directly in your articles
- Select text to bring up a beautiful color picker overlay
- Choose from four distinct colors: yellow, green, blue, and red
- Your highlights are saved and synced across devices
- Tap on annotations in the list to jump directly to that passage in the article
- Glass morphism design for a modern, elegant look
### Performance Improvements
- **Dramatically faster label loading** - especially with 1000+ labels
- Labels now load instantly from local cache, then sync in background
- Optimized label management to prevent crashes and lag
- Share Extension now loads labels without delay
- Reduced memory usage when working with large label collections
- Better offline support - labels always available even without internet
### Fixes & Improvements
- Centralized color management for consistent appearance
- Improved annotation creation workflow
- Better text selection handling in article view
- Implemented lazy loading for label lists
- Switched to Core Data as primary source for labels
- Batch operations for faster database queries
- Background sync to keep labels up-to-date without blocking the UI
- Fixed duplicate ID warnings in label lists
---
## Version 1.1
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.

View File

@ -8,20 +8,19 @@
import Foundation
import SwiftUI
@MainActor
@Observable
class AppViewModel {
class AppViewModel: ObservableObject {
private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory
private let logoutUseCase: LogoutUseCase
var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
@Published var hasFinishedSetup: Bool = true
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
self.logoutUseCase = logoutUseCase
setupNotificationObservers()
loadSetupStatus()
Task {
await loadSetupStatus()
}
}
private func setupNotificationObservers() {
@ -30,7 +29,7 @@ class AppViewModel {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
Task {
await self?.handleUnauthorizedResponse()
}
}
@ -40,17 +39,19 @@ class AppViewModel {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.loadSetupStatus()
}
self?.loadSetupStatus()
}
}
@MainActor
private func handleUnauthorizedResponse() async {
print("AppViewModel: Handling 401 Unauthorized - logging out user")
do {
try await factory.makeLogoutUseCase().execute()
// Führe den Logout durch
try await logoutUseCase.execute()
// Update UI state
loadSetupStatus()
print("AppViewModel: User successfully logged out due to 401 error")
@ -59,18 +60,11 @@ class AppViewModel {
}
}
@MainActor
private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup
}
func onAppResume() async {
await checkServerReachability()
}
private func checkServerReachability() async {
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
}
deinit {
NotificationCenter.default.removeObserver(self)
}

View File

@ -1,45 +0,0 @@
import SwiftUI
struct AnnotationColorOverlay: View {
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 8) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: onColorSelected)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2)
)
}
private struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 36, height: 36)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
)
}
}
}
}
#Preview {
AnnotationColorOverlay { color in
print("Selected: \(color)")
}
.padding()
}

View File

@ -1,63 +0,0 @@
import SwiftUI
struct AnnotationColorPicker: View {
let selectedText: String
let onColorSelected: (AnnotationColor) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Highlight Text")
.font(.headline)
Text(selectedText)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(3)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
Text("Select Color")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 16) {
ForEach(Constants.annotationColors, id: \.self) { color in
ColorButton(color: color, onTap: handleColorSelection)
}
}
Button("Cancel") {
dismiss()
}
.foregroundColor(.secondary)
}
.padding(24)
.frame(maxWidth: 400)
}
private func handleColorSelection(_ color: AnnotationColor) {
onColorSelected(color)
dismiss()
}
}
struct ColorButton: View {
let color: AnnotationColor
let onTap: (AnnotationColor) -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: { onTap(color) }) {
Circle()
.fill(color.swiftUIColor(isDark: colorScheme == .dark))
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 1)
)
}
}
}

View File

@ -1,120 +0,0 @@
import SwiftUI
struct AnnotationsListView: View {
let bookmarkId: String
@State private var viewModel = AnnotationsListViewModel()
@Environment(\.dismiss) private var dismiss
var onAnnotationTap: ((String) -> Void)?
enum ViewState {
case loading
case empty
case loaded([Annotation])
case error(String)
}
private var viewState: ViewState {
if viewModel.isLoading {
return .loading
} else if let error = viewModel.errorMessage, viewModel.showErrorAlert {
return .error(error)
} else if viewModel.annotations.isEmpty {
return .empty
} else {
return .loaded(viewModel.annotations)
}
}
var body: some View {
List {
switch viewState {
case .loading:
HStack {
Spacer()
ProgressView()
Spacer()
}
case .empty:
ContentUnavailableView(
"No Annotations",
systemImage: "pencil.slash",
description: Text("This bookmark has no annotations yet.")
)
case .loaded(let annotations):
ForEach(annotations) { annotation in
Button(action: {
onAnnotationTap?(annotation.id)
dismiss()
}) {
VStack(alignment: .leading, spacing: 8) {
if !annotation.text.isEmpty {
Text(annotation.text)
.font(.body)
.foregroundColor(.primary)
}
Text(formatDate(annotation.created))
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
case .error:
EmptyView()
}
}
.navigationTitle("Annotations")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.task {
await viewModel.loadAnnotations(for: bookmarkId)
}
.alert("Error", isPresented: $viewModel.showErrorAlert) {
Button("OK", role: .cancel) {}
} message: {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
}
}
}
private func formatDate(_ dateString: String) -> String {
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoFormatterNoMillis = ISO8601DateFormatter()
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
var date: Date?
if let parsedDate = isoFormatter.date(from: dateString) {
date = parsedDate
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
date = parsedDate
}
if let date = date {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
displayFormatter.timeStyle = .short
displayFormatter.locale = .autoupdatingCurrent
return displayFormatter.string(from: date)
}
return dateString
}
}
#Preview {
NavigationStack {
AnnotationsListView(bookmarkId: "123")
}
}

View File

@ -1,29 +0,0 @@
import Foundation
@Observable
class AnnotationsListViewModel {
private let getAnnotationsUseCase: PGetBookmarkAnnotationsUseCase
var annotations: [Annotation] = []
var isLoading = false
var errorMessage: String?
var showErrorAlert = false
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getAnnotationsUseCase = factory.makeGetBookmarkAnnotationsUseCase()
}
@MainActor
func loadAnnotations(for bookmarkId: String) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
annotations = try await getAnnotationsUseCase.execute(bookmarkId: bookmarkId)
} catch {
errorMessage = "Failed to load annotations"
showErrorAlert = true
}
}
}

View File

@ -29,7 +29,6 @@ struct BookmarkDetailLegacyView: View {
@State private var initialContentEndPosition: CGFloat = 0
@State private var showingFontSettings = false
@State private var showingLabelsSheet = false
@State private var showingAnnotationsSheet = false
@State private var readingProgress: Double = 0.0
@State private var lastSentProgress: Double = 0.0
@State private var showJumpToProgressButton: Bool = false
@ -87,20 +86,6 @@ struct BookmarkDetailLegacyView: View {
if webViewHeight != height {
webViewHeight = height
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
}
)
.frame(height: webViewHeight)
@ -235,12 +220,6 @@ struct BookmarkDetailLegacyView: View {
Image(systemName: "tag")
}
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
Button(action: {
showingFontSettings = true
}) {
@ -273,11 +252,6 @@ struct BookmarkDetailLegacyView: View {
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingAnnotationsSheet) {
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
viewModel.selectedAnnotationId = annotationId
}
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
@ -297,20 +271,9 @@ struct BookmarkDetailLegacyView: View {
}
}
}
.onChange(of: showingAnnotationsSheet) { _, isShowing in
if !isShowing {
// Reload bookmark detail when labels sheet is dismissed
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
// Trigger WebView reload when annotation is selected
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)

View File

@ -14,7 +14,6 @@ struct BookmarkDetailView2: View {
@State private var initialContentEndPosition: CGFloat = 0
@State private var showingFontSettings = false
@State private var showingLabelsSheet = false
@State private var showingAnnotationsSheet = false
@State private var readingProgress: Double = 0.0
@State private var lastSentProgress: Double = 0.0
@State private var showJumpToProgressButton: Bool = false
@ -51,11 +50,6 @@ struct BookmarkDetailView2: View {
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingAnnotationsSheet) {
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
viewModel.selectedAnnotationId = annotationId
}
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
@ -73,19 +67,9 @@ struct BookmarkDetailView2: View {
}
}
}
.onChange(of: showingAnnotationsSheet) { _, isShowing in
if !isShowing {
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
// Trigger WebView reload when annotation is selected
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
@ -270,14 +254,6 @@ struct BookmarkDetailView2: View {
Image(systemName: "tag")
}
if viewModel.hasAnnotations {
Button(action: {
showingAnnotationsSheet = true
}) {
Image(systemName: "pencil.line")
}
}
Button(action: {
showingFontSettings = true
}) {
@ -461,20 +437,6 @@ struct BookmarkDetailView2: View {
if webViewHeight != height {
webViewHeight = height
}
},
selectedAnnotationId: viewModel.selectedAnnotationId,
onAnnotationCreated: { color, text, startOffset, endOffset, startSelector, endSelector in
Task {
await viewModel.createAnnotation(
bookmarkId: bookmarkId,
color: color,
text: text,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
}
)
.frame(height: webViewHeight)

View File

@ -8,7 +8,6 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -19,8 +18,6 @@ class BookmarkDetailViewModel {
var errorMessage: String?
var settings: Settings?
var readProgress: Int = 0
var selectedAnnotationId: String?
var hasAnnotations: Bool = false
private var factory: UseCaseFactory?
private var cancellables = Set<AnyCancellable>()
@ -31,7 +28,6 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.factory = factory
readProgressSubject
@ -88,9 +84,6 @@ class BookmarkDetailViewModel {
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs
// Check if article contains annotations
hasAnnotations = articleContent.contains("<rd-annotation")
}
@MainActor
@ -144,22 +137,4 @@ class BookmarkDetailViewModel {
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
readProgressSubject.send((id, progress, anchor))
}
@MainActor
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
do {
let annotation = try await api.createAnnotation(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
print("✅ Annotation created: \(annotation.id)")
} catch {
print("❌ Failed to create annotation: \(error)")
errorMessage = "Error creating annotation"
}
}
}

View File

@ -26,9 +26,6 @@ class BookmarksViewModel {
// Undo delete functionality
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
// Prevent concurrent updates
private var isUpdating = false
private var cancellables = Set<AnyCancellable>()
private var limit = 50
@ -107,10 +104,6 @@ class BookmarksViewModel {
@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
guard !isUpdating else { return }
isUpdating = true
defer { isUpdating = false }
isLoading = true
errorMessage = nil
currentState = state
@ -156,9 +149,7 @@ class BookmarksViewModel {
@MainActor
func loadMoreBookmarks() async {
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads
isUpdating = true
defer { isUpdating = false }
guard !isLoading && hasMoreData else { return } // prevent multiple loads
isLoading = true
errorMessage = nil

View File

@ -10,53 +10,7 @@
//
import Foundation
import SwiftUI
struct Constants {
// Annotation colors
static let annotationColors: [AnnotationColor] = [.yellow, .green, .blue, .red]
}
enum AnnotationColor: String, CaseIterable, Codable {
case yellow = "yellow"
case green = "green"
case blue = "blue"
case red = "red"
// Base hex color for buttons and overlays
var hexColor: String {
switch self {
case .yellow: return "#D4A843"
case .green: return "#6FB546"
case .blue: return "#4A9BB8"
case .red: return "#C84848"
}
}
// RGB values for SwiftUI Color
private var rgb: (red: Double, green: Double, blue: Double) {
switch self {
case .yellow: return (212, 168, 67)
case .green: return (111, 181, 70)
case .blue: return (74, 155, 184)
case .red: return (200, 72, 72)
}
}
func swiftUIColor(isDark: Bool) -> Color {
let (r, g, b) = rgb
return Color(red: r/255, green: g/255, blue: b/255)
}
// CSS rgba string for JavaScript (for highlighting)
func cssColor(isDark: Bool) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), 0.3)"
}
// CSS rgba string with custom opacity
func cssColorWithOpacity(_ opacity: Double) -> String {
let (r, g, b) = rgb
return "rgba(\(Int(r)), \(Int(g)), \(Int(b)), \(opacity))"
}
// Empty for now - can be used for other constants in the future
}

View File

@ -11,8 +11,6 @@ struct NativeWebView: View {
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
@ -22,7 +20,6 @@ struct NativeWebView: View {
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
setupAnnotationMessageHandler()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
@ -30,9 +27,6 @@ struct NativeWebView: View {
.onChange(of: colorScheme) { _, _ in
loadStyledContent()
}
.onChange(of: selectedAnnotationId) { _, _ in
loadStyledContent()
}
.onChange(of: webPage.isLoading) { _, isLoading in
if !isLoading {
// Update height when content finishes loading
@ -45,44 +39,6 @@ struct NativeWebView: View {
}
}
private func setupAnnotationMessageHandler() {
guard let onAnnotationCreated = onAnnotationCreated else { return }
// Poll for annotation messages from JavaScript
Task { @MainActor in
let page = webPage
while true {
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
let script = """
return (function() {
if (window.__pendingAnnotation) {
const data = window.__pendingAnnotation;
window.__pendingAnnotation = null;
return data;
}
return null;
})();
"""
do {
if let result = try await page.callJavaScript(script) as? [String: Any],
let color = result["color"] as? String,
let text = result["text"] as? String,
let startOffset = result["startOffset"] as? Int,
let endOffset = result["endOffset"] as? Int,
let startSelector = result["startSelector"] as? String,
let endSelector = result["endSelector"] as? String {
onAnnotationCreated(color, text, startOffset, endOffset, startSelector, endSelector)
}
} catch {
// Silently continue polling
}
}
}
}
private func updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0
@ -241,49 +197,6 @@ struct NativeWebView: View {
th { font-weight: 600; }
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
/* Annotation Highlighting - for rd-annotation tags */
rd-annotation {
border-radius: 3px;
padding: 2px 0;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</style>
</head>
<body>
@ -329,12 +242,6 @@ struct NativeWebView: View {
}
scheduleHeightCheck();
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -366,280 +273,6 @@ struct NativeWebView: View {
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
}
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// For NativeWebView: use global variable for polling
window.__pendingAnnotation = {
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
};
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
private func generateScrollToAnnotationJS() -> String {
guard let selectedId = selectedAnnotationId else {
return ""
}
return """
// Scroll to selected annotation and add selected class
function scrollToAnnotation() {
// Remove 'selected' class from all annotations
document.querySelectorAll('rd-annotation.selected').forEach(el => {
el.classList.remove('selected');
});
// Find and highlight selected annotation
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
setTimeout(() => {
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
}
// MARK: - Hybrid WebView (Not Currently Used)

View File

@ -214,7 +214,7 @@ struct TagManagementView: View {
@ViewBuilder
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 8) {
ForEach(chunkedLabels, id: \.self) { rowLabels in
HStack(alignment: .top, spacing: 8) {
ForEach(rowLabels, id: \.id) { label in

View File

@ -6,8 +6,6 @@ struct WebView: UIViewRepresentable {
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var selectedAnnotationId: String?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> WKWebView {
@ -30,11 +28,8 @@ struct WebView: UIViewRepresentable {
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
context.coordinator.webView = webView
return webView
}
@ -42,7 +37,6 @@ struct WebView: UIViewRepresentable {
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
context.coordinator.onAnnotationCreated = onAnnotationCreated
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
@ -241,49 +235,6 @@ struct WebView: UIViewRepresentable {
--separator-color: #e0e0e0;
}
}
/* Annotation Highlighting - for rd-annotation tags */
rd-annotation {
border-radius: 3px;
padding: 2px 0;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
/* Yellow annotations */
rd-annotation[data-annotation-color="yellow"] {
background-color: \(AnnotationColor.yellow.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="yellow"].selected {
background-color: \(AnnotationColor.yellow.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.yellow.cssColorWithOpacity(0.6));
}
/* Green annotations */
rd-annotation[data-annotation-color="green"] {
background-color: \(AnnotationColor.green.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="green"].selected {
background-color: \(AnnotationColor.green.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.green.cssColorWithOpacity(0.6));
}
/* Blue annotations */
rd-annotation[data-annotation-color="blue"] {
background-color: \(AnnotationColor.blue.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="blue"].selected {
background-color: \(AnnotationColor.blue.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.blue.cssColorWithOpacity(0.6));
}
/* Red annotations */
rd-annotation[data-annotation-color="red"] {
background-color: \(AnnotationColor.red.cssColor(isDark: isDarkMode));
}
rd-annotation[data-annotation-color="red"].selected {
background-color: \(AnnotationColor.red.cssColorWithOpacity(0.5));
box-shadow: 0 0 0 2px \(AnnotationColor.red.cssColorWithOpacity(0.6));
}
</style>
</head>
<body>
@ -313,12 +264,6 @@ struct WebView: UIViewRepresentable {
document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', debouncedHeightUpdate);
});
// Scroll to selected annotation
\(generateScrollToAnnotationJS())
// Text Selection and Annotation Overlay
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
</script>
</body>
</html>
@ -331,7 +276,6 @@ struct WebView: UIViewRepresentable {
webView.navigationDelegate = nil
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
webView.loadHTMLString("", baseURL: nil)
coordinator.cleanup()
}
@ -361,295 +305,12 @@ struct WebView: UIViewRepresentable {
return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"
}
}
private func generateScrollToAnnotationJS() -> String {
guard let selectedId = selectedAnnotationId else {
return ""
}
return """
// Scroll to selected annotation and add selected class
function scrollToAnnotation() {
// Remove 'selected' class from all annotations
document.querySelectorAll('rd-annotation.selected').forEach(el => {
el.classList.remove('selected');
});
// Find and highlight selected annotation
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
if (selectedElement) {
selectedElement.classList.add('selected');
setTimeout(() => {
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scrollToAnnotation);
} else {
setTimeout(scrollToAnnotation, 300);
}
"""
}
private func generateAnnotationOverlayJS(isDarkMode: Bool) -> String {
let yellowColor = AnnotationColor.yellow.cssColor(isDark: isDarkMode)
let greenColor = AnnotationColor.green.cssColor(isDark: isDarkMode)
let blueColor = AnnotationColor.blue.cssColor(isDark: isDarkMode)
let redColor = AnnotationColor.red.cssColor(isDark: isDarkMode)
return """
// Create annotation color overlay
(function() {
let currentSelection = null;
let currentRange = null;
let selectionTimeout = null;
// Create overlay container with arrow
const overlay = document.createElement('div');
overlay.id = 'annotation-overlay';
overlay.style.cssText = `
display: none;
position: absolute;
z-index: 10000;
`;
// Create arrow/triangle pointing up with glass effect
const arrow = document.createElement('div');
arrow.style.cssText = `
position: absolute;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
border-bottom: none;
top: -11px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;
overlay.appendChild(arrow);
// Create the actual content container with glass morphism effect
const content = document.createElement('div');
content.style.cssText = `
display: flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
gap: 12px;
flex-direction: row;
align-items: center;
`;
overlay.appendChild(content);
// Add "Markierung" label
const label = document.createElement('span');
label.textContent = 'Markierung';
label.style.cssText = `
color: black;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
`;
content.appendChild(label);
// Create color buttons with solid colors
const colors = [
{ name: 'yellow', color: '\(AnnotationColor.yellow.hexColor)' },
{ name: 'red', color: '\(AnnotationColor.red.hexColor)' },
{ name: 'blue', color: '\(AnnotationColor.blue.hexColor)' },
{ name: 'green', color: '\(AnnotationColor.green.hexColor)' }
];
colors.forEach(({ name, color }) => {
const btn = document.createElement('button');
btn.dataset.color = name;
btn.style.cssText = `
width: 40px;
height: 40px;
border-radius: 50%;
background: ${color};
border: 3px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0;
margin: 0;
transition: transform 0.2s, border-color 0.2s;
`;
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'scale(1.1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'scale(1)';
btn.style.borderColor = 'rgba(255, 255, 255, 0.3)';
});
btn.addEventListener('click', () => handleColorSelection(name));
content.appendChild(btn);
});
document.body.appendChild(overlay);
// Selection change listener
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(() => {
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 0) {
currentSelection = text;
currentRange = selection.getRangeAt(0).cloneRange();
showOverlay(selection.getRangeAt(0));
} else {
hideOverlay();
}
}, 150);
});
function showOverlay(range) {
const rect = range.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
overlay.style.display = 'block';
// Center horizontally under selection
const overlayWidth = 320; // Approximate width with label + 4 buttons
const centerX = rect.left + (rect.width / 2);
const leftPos = Math.max(8, Math.min(centerX - (overlayWidth / 2), window.innerWidth - overlayWidth - 8));
// Position with extra space below selection (55px instead of 70px) to bring it closer
const topPos = rect.bottom + scrollY + 55;
overlay.style.left = leftPos + 'px';
overlay.style.top = topPos + 'px';
}
function hideOverlay() {
overlay.style.display = 'none';
currentSelection = null;
currentRange = null;
}
function calculateOffset(container, offset) {
const preRange = document.createRange();
preRange.selectNodeContents(document.body);
preRange.setEnd(container, offset);
return preRange.toString().length;
}
function getXPathSelector(node) {
// If node is text node, use parent element
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (!element || element === document.body) return 'body';
const path = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
// Count position among siblings of same tag (1-based index)
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
// Format: tagname[index] (1-based)
path.unshift(tagName + '[' + index + ']');
current = current.parentElement;
}
const selector = path.join('/');
console.log('Generated selector:', selector);
return selector || 'body';
}
function calculateOffsetInElement(container, offset) {
// Calculate offset relative to the parent element (not document.body)
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (!element) return offset;
// Create range from start of element to the position
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(container, offset);
return range.toString().length;
}
function generateTempId() {
return 'temp-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}
function handleColorSelection(color) {
if (!currentRange || !currentSelection) return;
// Generate XPath-like selectors for start and end containers
const startSelector = getXPathSelector(currentRange.startContainer);
const endSelector = getXPathSelector(currentRange.endContainer);
// Calculate offsets relative to the element (not document.body)
const startOffset = calculateOffsetInElement(currentRange.startContainer, currentRange.startOffset);
const endOffset = calculateOffsetInElement(currentRange.endContainer, currentRange.endOffset);
// Create annotation element
const annotation = document.createElement('rd-annotation');
annotation.setAttribute('data-annotation-color', color);
annotation.setAttribute('data-annotation-id-value', generateTempId());
// Wrap selection in annotation
try {
currentRange.surroundContents(annotation);
} catch (e) {
// If surroundContents fails (e.g., partial element selection), extract and wrap
const fragment = currentRange.extractContents();
annotation.appendChild(fragment);
currentRange.insertNode(annotation);
}
// Send to Swift with selectors
window.webkit.messageHandlers.annotationCreated.postMessage({
color: color,
text: currentSelection,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
});
// Clear selection and hide overlay
window.getSelection().removeAllRanges();
hideOverlay();
}
})();
"""
}
}
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
// Callbacks
var onHeightChange: ((CGFloat) -> Void)?
var onScroll: ((Double) -> Void)?
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
// WebView reference
weak var webView: WKWebView?
// Height management
var lastHeight: CGFloat = 0
@ -691,17 +352,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
self.handleScrollProgress(progress: progress)
}
}
if message.name == "annotationCreated", let body = message.body as? [String: Any],
let color = body["color"] as? String,
let text = body["text"] as? String,
let startOffset = body["startOffset"] as? Int,
let endOffset = body["endOffset"] as? Int,
let startSelector = body["startSelector"] as? String,
let endSelector = body["endSelector"] as? String {
DispatchQueue.main.async {
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
}
}
}
private func handleHeightUpdate(height: CGFloat) {
@ -777,6 +427,5 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
onHeightChange = nil
onScroll = nil
onAnnotationCreated = nil
}
}

View File

@ -20,8 +20,6 @@ protocol UseCaseFactory {
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
}
@ -32,9 +30,6 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
private let settingsRepository: PSettingsRepository = SettingsRepository()
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
static let shared = DefaultUseCaseFactory()
@ -117,12 +112,4 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
}
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase {
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
}
}

View File

@ -9,10 +9,6 @@ import Foundation
import Combine
class MockUseCaseFactory: UseCaseFactory {
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
MockCheckServerReachabilityUseCase()
}
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
MockOfflineBookmarkSyncUseCase()
}
@ -88,10 +84,6 @@ class MockUseCaseFactory: UseCaseFactory {
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
MockSaveCardLayoutUseCase()
}
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
MockGetBookmarkAnnotationsUseCase()
}
}
@ -232,24 +224,6 @@ class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase {
}
}
class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
func execute() async -> Bool {
return true
}
func getServerInfo() async throws -> ServerInfo {
return ServerInfo(version: "1.0.0", buildDate: nil, userAgent: nil, isReachable: true)
}
}
class MockGetBookmarkAnnotationsUseCase: PGetBookmarkAnnotationsUseCase {
func execute(bookmarkId: String) async throws -> [Annotation] {
return [
.init(id: "1", text: "bla", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "")
]
}
}
extension Bookmark {
static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)

View File

@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
private let successDelaySubject = PassthroughSubject<Int, Never>()
private var completionTimerActive = false
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
self.syncUseCase = syncUseCase
setupBindings()
refreshState()
}

View File

@ -13,7 +13,7 @@ struct PadSidebarView: View {
@State private var selectedTag: BookmarkLabel?
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
@ -87,11 +87,11 @@ struct PadSidebarView: View {
case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .unread:
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
case .favorite:
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
case .archived:
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
case .settings:
SettingsView()
case .article:

View File

@ -12,7 +12,7 @@ struct PhoneTabView: View {
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
@State private var selectedTab: SidebarTab = .unread
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
// Navigation paths for each tab
@State private var allPath = NavigationPath()
@ -149,9 +149,9 @@ struct PhoneTabView: View {
.padding()
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
List(bookmarks) { bookmark in
// Hidden NavigationLink to remove disclosure indicator
// To restore: uncomment block below and remove ZStack
ZStack {
// Hidden NavigationLink to remove disclosure indicator
NavigationLink {
BookmarkDetailView(bookmarkId: bookmark.id)
} label: {
@ -234,11 +234,11 @@ struct PhoneTabView: View {
case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
case .unread:
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
case .favorite:
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
case .archived:
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
case .search:
EmptyView() // search is directly implemented
case .settings:

View File

@ -1,46 +1,5 @@
import SwiftUI
extension AttributedString {
init(styledMarkdown markdownString: String) throws {
var output = try AttributedString(
markdown: markdownString,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
),
baseURL: nil
)
for (intentBlock, intentRange) in output.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
switch level {
case 1:
output[intentRange].font = .system(.title).bold()
case 2:
output[intentRange].font = .system(.title2).bold()
case 3:
output[intentRange].font = .system(.title3).bold()
default:
break
}
default:
break
}
}
if intentRange.lowerBound != output.startIndex {
output.characters.insert(contentsOf: "\n\n", at: intentRange.lowerBound)
}
}
self = output
}
}
struct ReleaseNotesView: View {
@Environment(\.dismiss) private var dismiss
@ -74,7 +33,10 @@ struct ReleaseNotesView: View {
private func loadReleaseNotes() -> AttributedString? {
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
let markdownContent = try? String(contentsOf: url),
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
let attributedString = try? AttributedString(
markdown: markdownContent,
options: .init(interpretedSyntax: .full)
) else {
return nil
}
return attributedString

View File

@ -11,7 +11,8 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false
init(showingLogoutAlert: Bool = false) {
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
self.viewModel = viewModel
self.showingLogoutAlert = showingLogoutAlert
}
@ -30,12 +31,11 @@ struct SettingsServerView: View {
// Form
VStack(spacing: 16) {
// Server Endpoint
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Text("Server Endpoint")
.font(.headline)
if viewModel.isSetupMode {
TextField("",
text: $viewModel.endpoint,
prompt: Text("Server Endpoint").foregroundColor(.secondary))
TextField("https://readeck.example.com", text: $viewModel.endpoint)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
@ -43,42 +43,6 @@ struct SettingsServerView: View {
.onChange(of: viewModel.endpoint) {
viewModel.clearMessages()
}
// Quick Input Chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
QuickInputChip(text: "http://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "http://" + viewModel.endpoint
}
})
QuickInputChip(text: "https://", action: {
if !viewModel.endpoint.starts(with: "http") {
viewModel.endpoint = "https://" + viewModel.endpoint
}
})
QuickInputChip(text: "192.168.", action: {
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
if viewModel.endpoint.starts(with: "http") {
viewModel.endpoint += "192.168."
} else {
viewModel.endpoint = "http://192.168."
}
}
})
QuickInputChip(text: ":8000", action: {
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
viewModel.endpoint += ":8000"
}
})
}
.padding(.horizontal, 1)
}
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
HStack {
Image(systemName: "server.rack")
@ -91,13 +55,11 @@ struct SettingsServerView: View {
.padding(.vertical, 8)
}
}
// Username
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Text("Username")
.font(.headline)
if viewModel.isSetupMode {
TextField("",
text: $viewModel.username,
prompt: Text("Username").foregroundColor(.secondary))
TextField("Your Username", text: $viewModel.username)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
@ -116,13 +78,12 @@ struct SettingsServerView: View {
.padding(.vertical, 8)
}
}
// Password
if viewModel.isSetupMode {
VStack(alignment: .leading, spacing: 8) {
SecureField("",
text: $viewModel.password,
prompt: Text("Password").foregroundColor(.secondary))
VStack(alignment: .leading, spacing: 6) {
Text("Password")
.font(.headline)
SecureField("Your Password", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.password) {
viewModel.clearMessages()
@ -211,22 +172,8 @@ struct SettingsServerView: View {
}
}
// MARK: - Quick Input Chip Component
struct QuickInputChip: View {
let text: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5))
.foregroundColor(.secondary)
.cornerRadius(12)
}
}
#Preview {
SettingsServerView(viewModel: .init(
MockUseCaseFactory()
))
}

View File

@ -62,15 +62,8 @@ class SettingsServerViewModel {
isLoading = true
defer { isLoading = false }
do {
// Normalize endpoint before saving
let normalizedEndpoint = normalizeEndpoint(endpoint)
let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token)
// Update local endpoint with normalized version
endpoint = normalizedEndpoint
let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token)
isLoggedIn = true
successMessage = "Server settings saved and successfully logged in."
try await SettingsRepository().saveHasFinishedSetup(true)
@ -81,51 +74,6 @@ class SettingsServerViewModel {
}
}
// MARK: - Endpoint Normalization
private func normalizeEndpoint(_ endpoint: String) -> String {
var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
// Remove query parameters
if let queryIndex = normalized.firstIndex(of: "?") {
normalized = String(normalized[..<queryIndex])
}
// Parse URL components
guard var urlComponents = URLComponents(string: normalized) else {
// If parsing fails, try adding https:// and parse again
normalized = "https://" + normalized
guard var urlComponents = URLComponents(string: normalized) else {
return normalized
}
return buildNormalizedURL(from: urlComponents)
}
return buildNormalizedURL(from: urlComponents)
}
private func buildNormalizedURL(from components: URLComponents) -> String {
var urlComponents = components
// Ensure scheme is http or https, default to https
if urlComponents.scheme == nil {
urlComponents.scheme = "https"
} else if urlComponents.scheme != "http" && urlComponents.scheme != "https" {
urlComponents.scheme = "https"
}
// Remove trailing slash from path if present
if urlComponents.path.hasSuffix("/") {
urlComponents.path = String(urlComponents.path.dropLast())
}
// Remove query parameters (already done above, but double check)
urlComponents.query = nil
urlComponents.fragment = nil
return urlComponents.string ?? components.string ?? ""
}
@MainActor
func logout() async {
do {

View File

@ -8,6 +8,9 @@ extension Notification.Name {
// MARK: - Authentication
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
// MARK: - Network
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
// MARK: - UI Interactions
static let dismissKeyboard = Notification.Name("DismissKeyboard")
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")

View File

@ -10,9 +10,8 @@ import netfox
@main
struct readeckApp: App {
@State private var appViewModel = AppViewModel()
@StateObject private var appViewModel = AppViewModel()
@StateObject private var appSettings = AppSettings()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
@ -30,6 +29,8 @@ struct readeckApp: App {
#if DEBUG
NFX.sharedInstance().start()
#endif
// Initialize server connectivity monitoring
_ = ServerConnectivity.shared
Task {
await loadAppSettings()
}
@ -39,13 +40,6 @@ struct readeckApp: App {
await loadAppSettings()
}
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active {
Task {
await appViewModel.onAppResume()
}
}
}
}
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB