Compare commits
25 Commits
eddc8a35ff
...
aeb0bcad2e
| Author | SHA1 | Date | |
|---|---|---|---|
| aeb0bcad2e | |||
| f42653a92b | |||
| fef1876297 | |||
| 907cc9220f | |||
| c629894611 | |||
| b77e4e3e9f | |||
| 1b9f79bccc | |||
| d1157defbe | |||
| a041300b4f | |||
| ec12815a51 | |||
| cf06a3147d | |||
| 47f8f73664 | |||
| d97e404cc7 | |||
| 6906509aea | |||
| afe3d1e261 | |||
| 554e223bbc | |||
| 819eb4fc56 | |||
| 6385d10317 | |||
| 31ed3fc0e1 | |||
| 04de2c20d4 | |||
| fde1140f24 | |||
| e5334d456d | |||
| 1957995a9e | |||
| bf3ee7a1d7 | |||
| ef8ebd6f00 |
@ -49,19 +49,54 @@ class OfflineBookmarkManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func getTags() -> [String] {
|
||||
func getTags() async -> [String] {
|
||||
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
|
||||
|
||||
do {
|
||||
return try context.safePerform { [weak self] in
|
||||
guard let self = self else { return [] }
|
||||
|
||||
return try await backgroundContext.perform {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
let tagEntities = try self.context.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }.sorted()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||
|
||||
let tagEntities = try backgroundContext.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch tags: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -13,8 +13,9 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
@Published var searchText: String = ""
|
||||
@Published var isServerReachable: Bool = true
|
||||
let extensionContext: NSExtensionContext?
|
||||
|
||||
|
||||
private let logger = Logger.viewModel
|
||||
private let serverCheck = ShareExtensionServerCheck.shared
|
||||
|
||||
var availableLabels: [BookmarkLabelDto] {
|
||||
return labels.filter { !selectedLabels.contains($0.name) }
|
||||
@ -50,17 +51,9 @@ 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 {
|
||||
@ -131,30 +124,43 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
||||
logger.debug("Starting to load labels")
|
||||
Task {
|
||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||
// 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
|
||||
}
|
||||
logger.debug("Server reachable for labels: \(serverReachable)")
|
||||
|
||||
|
||||
if serverReachable {
|
||||
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
if error {
|
||||
self?.logger.error("Failed to sync labels from API: \(message)")
|
||||
}
|
||||
} ?? []
|
||||
|
||||
// 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("Loaded \(loaded.count) labels from API")
|
||||
self.logger.info("Synced \(loaded.count) labels from API and updated cache")
|
||||
measurement.end()
|
||||
}
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
measurement.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -168,14 +174,14 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
}
|
||||
isSaving = true
|
||||
logger.debug("Set saving state to true")
|
||||
|
||||
|
||||
// Check server connectivity
|
||||
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 {
|
||||
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")
|
||||
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 ? "❌" : "✅")
|
||||
@ -189,28 +195,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)")
|
||||
|
||||
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 {
|
||||
// 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, "❌")
|
||||
}
|
||||
} else {
|
||||
self.logger.error("Failed to save bookmark locally")
|
||||
self.statusMessage = ("Failed to save locally.", true, "❌")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
URLShare/ShareExtensionServerCheck.swift
Normal file
41
URLShare/ShareExtensionServerCheck.swift
Normal file
@ -0,0 +1,41 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,40 @@ 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)")
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
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?
|
||||
@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable {
|
||||
self.href = href
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -437,7 +437,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
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 = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
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 = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
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 = 29;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -18,6 +18,8 @@ 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 {
|
||||
@ -435,15 +437,55 @@ class API: PAPI {
|
||||
logger.debug("Fetching bookmark labels")
|
||||
let endpoint = "/api/bookmarks/labels"
|
||||
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||
|
||||
|
||||
let result = try await makeJSONRequest(
|
||||
endpoint: endpoint,
|
||||
responseType: [BookmarkLabelDto].self
|
||||
)
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
21
readeck/Data/API/DTOs/AnnotationDto.swift
Normal file
21
readeck/Data/API/DTOs/AnnotationDto.swift
Normal file
@ -0,0 +1,21 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
13
readeck/Data/API/DTOs/ServerInfoDto.swift
Normal file
13
readeck/Data/API/DTOs/ServerInfoDto.swift
Normal file
@ -0,0 +1,13 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
55
readeck/Data/API/InfoApiClient.swift
Normal file
55
readeck/Data/API/InfoApiClient.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
24
readeck/Data/Repository/AnnotationsRepository.swift
Normal file
24
readeck/Data/Repository/AnnotationsRepository.swift
Normal file
@ -0,0 +1,24 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,34 +11,66 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func getLabels() async throws -> [BookmarkLabel] {
|
||||
let dtos = try await api.getBookmarkLabels()
|
||||
try? await saveLabels(dtos)
|
||||
return dtos.map { $0.toDomain() }
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
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
|
||||
for dto in dtos {
|
||||
if !self.tagExists(name: dto.name, in: backgroundContext) {
|
||||
if !existingNames.contains(dto.name) {
|
||||
dto.toEntity(context: backgroundContext)
|
||||
insertCount += 1
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
// Only save if there are new labels
|
||||
if insertCount > 0 {
|
||||
try backgroundContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,22 +4,25 @@ import SwiftUI
|
||||
|
||||
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
||||
static let shared = OfflineSyncManager()
|
||||
|
||||
|
||||
@Published var isSyncing = false
|
||||
@Published var syncStatus: String?
|
||||
|
||||
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
private let api: PAPI
|
||||
|
||||
init(api: PAPI = API()) {
|
||||
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
|
||||
|
||||
init(api: PAPI = API(),
|
||||
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
|
||||
self.api = api
|
||||
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
|
||||
}
|
||||
|
||||
// MARK: - Sync Methods
|
||||
|
||||
func syncOfflineBookmarks() async {
|
||||
// First check if server is reachable
|
||||
guard await ServerConnectivity.isServerReachable() else {
|
||||
guard await checkServerReachabilityUseCase.execute() else {
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
syncStatus = "Server not reachable. Cannot sync."
|
||||
@ -121,22 +124,4 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
114
readeck/Data/Repository/ServerInfoRepository.swift
Normal file
114
readeck/Data/Repository/ServerInfoRepository.swift
Normal file
@ -0,0 +1,114 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
19
readeck/Domain/Model/Annotation.swift
Normal file
19
readeck/Domain/Model/Annotation.swift
Normal file
@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
21
readeck/Domain/Model/ServerInfo.swift
Normal file
21
readeck/Domain/Model/ServerInfo.swift
Normal file
@ -0,0 +1,21 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
3
readeck/Domain/Protocols/PAnnotationsRepository.swift
Normal file
3
readeck/Domain/Protocols/PAnnotationsRepository.swift
Normal file
@ -0,0 +1,3 @@
|
||||
protocol PAnnotationsRepository {
|
||||
func fetchAnnotations(bookmarkId: String) async throws -> [Annotation]
|
||||
}
|
||||
10
readeck/Domain/Protocols/PServerInfoRepository.swift
Normal file
10
readeck/Domain/Protocols/PServerInfoRepository.swift
Normal file
@ -0,0 +1,10 @@
|
||||
//
|
||||
// PServerInfoRepository.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Claude Code
|
||||
|
||||
protocol PServerInfoRepository {
|
||||
func checkServerReachability() async -> Bool
|
||||
func getServerInfo() async throws -> ServerInfo
|
||||
}
|
||||
28
readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift
Normal file
28
readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
17
readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift
Normal file
17
readeck/Domain/UseCase/GetBookmarkAnnotationsUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,39 @@ 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.
|
||||
|
||||
@ -8,19 +8,20 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class AppViewModel: ObservableObject {
|
||||
@MainActor
|
||||
@Observable
|
||||
class AppViewModel {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private let logoutUseCase: LogoutUseCase
|
||||
|
||||
@Published var hasFinishedSetup: Bool = true
|
||||
|
||||
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
|
||||
self.logoutUseCase = logoutUseCase
|
||||
private let factory: UseCaseFactory
|
||||
|
||||
var hasFinishedSetup: Bool = true
|
||||
var isServerReachable: Bool = false
|
||||
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.factory = factory
|
||||
setupNotificationObservers()
|
||||
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
|
||||
loadSetupStatus()
|
||||
}
|
||||
|
||||
private func setupNotificationObservers() {
|
||||
@ -29,7 +30,7 @@ class AppViewModel: ObservableObject {
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
await self?.handleUnauthorizedResponse()
|
||||
}
|
||||
}
|
||||
@ -39,19 +40,17 @@ class AppViewModel: ObservableObject {
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.loadSetupStatus()
|
||||
Task { @MainActor in
|
||||
self?.loadSetupStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleUnauthorizedResponse() async {
|
||||
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
||||
|
||||
do {
|
||||
// Führe den Logout durch
|
||||
try await logoutUseCase.execute()
|
||||
|
||||
// Update UI state
|
||||
try await factory.makeLogoutUseCase().execute()
|
||||
loadSetupStatus()
|
||||
|
||||
print("AppViewModel: User successfully logged out due to 401 error")
|
||||
@ -60,11 +59,18 @@ class AppViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
45
readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift
Normal file
45
readeck/UI/BookmarkDetail/AnnotationColorOverlay.swift
Normal file
@ -0,0 +1,45 @@
|
||||
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()
|
||||
}
|
||||
63
readeck/UI/BookmarkDetail/AnnotationColorPicker.swift
Normal file
63
readeck/UI/BookmarkDetail/AnnotationColorPicker.swift
Normal file
@ -0,0 +1,63 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
readeck/UI/BookmarkDetail/AnnotationsListView.swift
Normal file
120
readeck/UI/BookmarkDetail/AnnotationsListView.swift
Normal file
@ -0,0 +1,120 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
29
readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift
Normal file
29
readeck/UI/BookmarkDetail/AnnotationsListViewModel.swift
Normal file
@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,18 +29,19 @@ 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
|
||||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||||
@State private var showingImageViewer = false
|
||||
|
||||
|
||||
// MARK: - Envs
|
||||
|
||||
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
|
||||
private let headerHeight: CGFloat = 360
|
||||
|
||||
init(bookmarkId: String, useNativeWebView: Binding<Bool>, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel()) {
|
||||
@ -86,6 +87,20 @@ 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)
|
||||
@ -220,6 +235,12 @@ struct BookmarkDetailLegacyView: View {
|
||||
Image(systemName: "tag")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingAnnotationsSheet = true
|
||||
}) {
|
||||
Image(systemName: "pencil.line")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingFontSettings = true
|
||||
}) {
|
||||
@ -252,6 +273,11 @@ 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)
|
||||
}
|
||||
@ -271,9 +297,20 @@ 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)
|
||||
|
||||
@ -14,6 +14,7 @@ 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
|
||||
@ -50,6 +51,11 @@ 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)
|
||||
}
|
||||
@ -67,9 +73,19 @@ 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)
|
||||
@ -254,6 +270,14 @@ struct BookmarkDetailView2: View {
|
||||
Image(systemName: "tag")
|
||||
}
|
||||
|
||||
if viewModel.hasAnnotations {
|
||||
Button(action: {
|
||||
showingAnnotationsSheet = true
|
||||
}) {
|
||||
Image(systemName: "pencil.line")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingFontSettings = true
|
||||
}) {
|
||||
@ -437,6 +461,20 @@ 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)
|
||||
|
||||
@ -8,7 +8,8 @@ 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 = ""
|
||||
var articleParagraphs: [String] = []
|
||||
@ -18,7 +19,9 @@ 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>()
|
||||
private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>()
|
||||
@ -28,8 +31,9 @@ class BookmarkDetailViewModel {
|
||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
self.api = API()
|
||||
self.factory = factory
|
||||
|
||||
|
||||
readProgressSubject
|
||||
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] (id, progress, anchor) in
|
||||
@ -67,23 +71,26 @@ class BookmarkDetailViewModel {
|
||||
@MainActor
|
||||
func loadArticleContent(id: String) async {
|
||||
isLoadingArticle = true
|
||||
|
||||
|
||||
do {
|
||||
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
|
||||
processArticleContent()
|
||||
} catch {
|
||||
errorMessage = "Error loading article"
|
||||
}
|
||||
|
||||
|
||||
isLoadingArticle = false
|
||||
}
|
||||
|
||||
|
||||
private func processArticleContent() {
|
||||
let paragraphs = articleContent
|
||||
.components(separatedBy: .newlines)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
|
||||
articleParagraphs = paragraphs
|
||||
|
||||
// Check if article contains annotations
|
||||
hasAnnotations = articleContent.contains("<rd-annotation")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -137,4 +144,22 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,9 +22,12 @@ class BookmarksViewModel {
|
||||
var showingAddBookmarkFromShare = false
|
||||
var shareURL = ""
|
||||
var shareTitle = ""
|
||||
|
||||
|
||||
// Undo delete functionality
|
||||
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||
|
||||
// Prevent concurrent updates
|
||||
private var isUpdating = false
|
||||
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@ -104,15 +107,19 @@ 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
|
||||
currentType = type
|
||||
currentTag = tag
|
||||
|
||||
|
||||
offset = 0
|
||||
hasMoreData = true
|
||||
|
||||
|
||||
do {
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
state: state,
|
||||
@ -142,18 +149,20 @@ class BookmarksViewModel {
|
||||
}
|
||||
// Don't clear bookmarks on error - keep existing data visible
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
isInitialLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadMoreBookmarks() async {
|
||||
guard !isLoading && hasMoreData else { return } // prevent multiple loads
|
||||
|
||||
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads
|
||||
isUpdating = true
|
||||
defer { isUpdating = false }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
do {
|
||||
offset += limit // inc. offset
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
@ -181,7 +190,7 @@ class BookmarksViewModel {
|
||||
errorMessage = "Error loading more bookmarks"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,53 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Constants {
|
||||
// Empty for now - can be used for other constants in the future
|
||||
// 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))"
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,9 @@ 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
|
||||
|
||||
@ -20,6 +22,7 @@ struct NativeWebView: View {
|
||||
.scrollDisabled(true) // Disable internal scrolling
|
||||
.onAppear {
|
||||
loadStyledContent()
|
||||
setupAnnotationMessageHandler()
|
||||
}
|
||||
.onChange(of: htmlContent) { _, _ in
|
||||
loadStyledContent()
|
||||
@ -27,6 +30,9 @@ 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
|
||||
@ -38,6 +44,44 @@ 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
|
||||
@ -197,6 +241,49 @@ 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>
|
||||
@ -242,6 +329,12 @@ struct NativeWebView: View {
|
||||
}
|
||||
|
||||
scheduleHeightCheck();
|
||||
|
||||
// Scroll to selected annotation
|
||||
\(generateScrollToAnnotationJS())
|
||||
|
||||
// Text Selection and Annotation Overlay
|
||||
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -273,6 +366,280 @@ 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)
|
||||
|
||||
@ -214,7 +214,7 @@ struct TagManagementView: View {
|
||||
@ViewBuilder
|
||||
private var labelsScrollView: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ForEach(rowLabels, id: \.id) { label in
|
||||
|
||||
@ -6,6 +6,8 @@ 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 {
|
||||
@ -28,8 +30,11 @@ 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
|
||||
}
|
||||
@ -37,6 +42,7 @@ 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)
|
||||
@ -235,6 +241,49 @@ 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>
|
||||
@ -264,6 +313,12 @@ 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>
|
||||
@ -276,6 +331,7 @@ 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()
|
||||
}
|
||||
@ -305,12 +361,295 @@ 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
|
||||
@ -352,6 +691,17 @@ 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) {
|
||||
@ -419,13 +769,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
func cleanup() {
|
||||
guard !isCleanedUp else { return }
|
||||
isCleanedUp = true
|
||||
|
||||
|
||||
scrollEndTimer?.invalidate()
|
||||
scrollEndTimer = nil
|
||||
heightUpdateTimer?.invalidate()
|
||||
heightUpdateTimer = nil
|
||||
|
||||
|
||||
onHeightChange = nil
|
||||
onScroll = nil
|
||||
onAnnotationCreated = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ protocol UseCaseFactory {
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||
}
|
||||
|
||||
|
||||
@ -30,9 +32,12 @@ 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()
|
||||
|
||||
|
||||
private init() {}
|
||||
|
||||
func makeLoginUseCase() -> PLoginUseCase {
|
||||
@ -112,4 +117,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
||||
}
|
||||
|
||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase {
|
||||
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
|
||||
}
|
||||
|
||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,10 @@ import Foundation
|
||||
import Combine
|
||||
|
||||
class MockUseCaseFactory: UseCaseFactory {
|
||||
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
|
||||
MockCheckServerReachabilityUseCase()
|
||||
}
|
||||
|
||||
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||
MockOfflineBookmarkSyncUseCase()
|
||||
}
|
||||
@ -84,6 +88,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||
MockSaveCardLayoutUseCase()
|
||||
}
|
||||
|
||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||
MockGetBookmarkAnnotationsUseCase()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -224,6 +232,24 @@ 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)
|
||||
|
||||
@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
|
||||
private let successDelaySubject = PassthroughSubject<Int, Never>()
|
||||
private var completionTimerActive = false
|
||||
|
||||
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
|
||||
self.syncUseCase = syncUseCase
|
||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
|
||||
setupBindings()
|
||||
refreshState()
|
||||
}
|
||||
|
||||
@ -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(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
|
||||
|
||||
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], selectedBookmark: $selectedBookmark)
|
||||
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .favorite:
|
||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
||||
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .archived:
|
||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
||||
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .article:
|
||||
|
||||
@ -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(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
|
||||
|
||||
// Navigation paths for each tab
|
||||
@State private var allPath = NavigationPath()
|
||||
@ -149,11 +149,11 @@ 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)
|
||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
@ -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], selectedBookmark: .constant(nil))
|
||||
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .favorite:
|
||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
|
||||
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .archived:
|
||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
||||
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||
case .search:
|
||||
EmptyView() // search is directly implemented
|
||||
case .settings:
|
||||
|
||||
@ -1,5 +1,46 @@
|
||||
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
|
||||
|
||||
@ -33,10 +74,7 @@ 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(
|
||||
markdown: markdownContent,
|
||||
options: .init(interpretedSyntax: .full)
|
||||
) else {
|
||||
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
|
||||
return nil
|
||||
}
|
||||
return attributedString
|
||||
|
||||
@ -10,17 +10,16 @@ import SwiftUI
|
||||
struct SettingsServerView: View {
|
||||
@State private var viewModel = SettingsServerViewModel()
|
||||
@State private var showingLogoutAlert = false
|
||||
|
||||
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
init(showingLogoutAlert: Bool = false) {
|
||||
self.showingLogoutAlert = showingLogoutAlert
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
|
||||
Text(viewModel.isSetupMode ?
|
||||
"Enter your Readeck server details to get started." :
|
||||
"Your current server connection and login credentials.")
|
||||
@ -28,14 +27,15 @@ struct SettingsServerView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
|
||||
// Form
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Server Endpoint")
|
||||
.font(.headline)
|
||||
// Server Endpoint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.isSetupMode {
|
||||
TextField("https://readeck.example.com", text: $viewModel.endpoint)
|
||||
TextField("",
|
||||
text: $viewModel.endpoint,
|
||||
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
@ -43,6 +43,42 @@ 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")
|
||||
@ -55,11 +91,13 @@ struct SettingsServerView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Username")
|
||||
.font(.headline)
|
||||
|
||||
// Username
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.isSetupMode {
|
||||
TextField("Your Username", text: $viewModel.username)
|
||||
TextField("",
|
||||
text: $viewModel.username,
|
||||
prompt: Text("Username").foregroundColor(.secondary))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
@ -78,12 +116,13 @@ struct SettingsServerView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Password
|
||||
if viewModel.isSetupMode {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Password")
|
||||
.font(.headline)
|
||||
|
||||
SecureField("Your Password", text: $viewModel.password)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SecureField("",
|
||||
text: $viewModel.password,
|
||||
prompt: Text("Password").foregroundColor(.secondary))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: viewModel.password) {
|
||||
viewModel.clearMessages()
|
||||
@ -91,7 +130,7 @@ struct SettingsServerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Messages
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
HStack {
|
||||
@ -102,7 +141,7 @@ struct SettingsServerView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let successMessage = viewModel.successMessage {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
@ -112,7 +151,7 @@ struct SettingsServerView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if viewModel.isSetupMode {
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
@ -135,7 +174,7 @@ struct SettingsServerView: View {
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
@ -172,8 +211,22 @@ struct SettingsServerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsServerView(viewModel: .init(
|
||||
MockUseCaseFactory()
|
||||
))
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,8 +62,15 @@ class SettingsServerViewModel {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
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)
|
||||
// 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
|
||||
|
||||
isLoggedIn = true
|
||||
successMessage = "Server settings saved and successfully logged in."
|
||||
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||
@ -73,6 +80,51 @@ class SettingsServerViewModel {
|
||||
isLoggedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@ -7,10 +7,7 @@ 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")
|
||||
|
||||
@ -10,8 +10,9 @@ import netfox
|
||||
|
||||
@main
|
||||
struct readeckApp: App {
|
||||
@StateObject private var appViewModel = AppViewModel()
|
||||
@State private var appViewModel = AppViewModel()
|
||||
@StateObject private var appSettings = AppSettings()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@ -29,8 +30,6 @@ struct readeckApp: App {
|
||||
#if DEBUG
|
||||
NFX.sharedInstance().start()
|
||||
#endif
|
||||
// Initialize server connectivity monitoring
|
||||
_ = ServerConnectivity.shared
|
||||
Task {
|
||||
await loadAppSettings()
|
||||
}
|
||||
@ -40,6 +39,13 @@ 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.6 MiB After Width: | Height: | Size: 1.7 MiB |
Loading…
x
Reference in New Issue
Block a user