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,14 +49,16 @@ class OfflineBookmarkManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTags() -> [String] {
|
func getTags() async -> [String] {
|
||||||
do {
|
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
|
||||||
return try context.safePerform { [weak self] in
|
|
||||||
guard let self = self else { return [] }
|
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await backgroundContext.perform {
|
||||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
let tagEntities = try self.context.fetch(fetchRequest)
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||||
return tagEntities.compactMap { $0.name }.sorted()
|
|
||||||
|
let tagEntities = try backgroundContext.fetch(fetchRequest)
|
||||||
|
return tagEntities.compactMap { $0.name }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch tags: \(error)")
|
print("Failed to fetch tags: \(error)")
|
||||||
@ -64,4 +66,37 @@ class OfflineBookmarkManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveTags(_ tags: [String]) async {
|
||||||
|
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await backgroundContext.perform {
|
||||||
|
// Batch fetch existing tags
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
fetchRequest.propertiesToFetch = ["name"]
|
||||||
|
|
||||||
|
let existingEntities = try backgroundContext.fetch(fetchRequest)
|
||||||
|
let existingNames = Set(existingEntities.compactMap { $0.name })
|
||||||
|
|
||||||
|
// Only insert new tags
|
||||||
|
var insertCount = 0
|
||||||
|
for tag in tags {
|
||||||
|
if !existingNames.contains(tag) {
|
||||||
|
let entity = TagEntity(context: backgroundContext)
|
||||||
|
entity.name = tag
|
||||||
|
insertCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only save if there are new tags
|
||||||
|
if insertCount > 0 {
|
||||||
|
try backgroundContext.save()
|
||||||
|
print("Saved \(insertCount) new tags to Core Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to save tags: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,6 +15,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
let extensionContext: NSExtensionContext?
|
let extensionContext: NSExtensionContext?
|
||||||
|
|
||||||
private let logger = Logger.viewModel
|
private let logger = Logger.viewModel
|
||||||
|
private let serverCheck = ShareExtensionServerCheck.shared
|
||||||
|
|
||||||
var availableLabels: [BookmarkLabelDto] {
|
var availableLabels: [BookmarkLabelDto] {
|
||||||
return labels.filter { !selectedLabels.contains($0.name) }
|
return labels.filter { !selectedLabels.contains($0.name) }
|
||||||
@ -50,17 +51,9 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
logger.debug("ShareBookmarkViewModel appeared")
|
logger.debug("ShareBookmarkViewModel appeared")
|
||||||
checkServerReachability()
|
|
||||||
loadLabels()
|
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() {
|
private func extractSharedContent() {
|
||||||
logger.debug("Starting to extract shared content")
|
logger.debug("Starting to extract shared content")
|
||||||
guard let extensionContext = extensionContext else {
|
guard let extensionContext = extensionContext else {
|
||||||
@ -131,33 +124,46 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
||||||
logger.debug("Starting to load labels")
|
logger.debug("Starting to load labels")
|
||||||
Task {
|
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)")
|
logger.debug("Server reachable for labels: \(serverReachable)")
|
||||||
|
|
||||||
if serverReachable {
|
if serverReachable {
|
||||||
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
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 }
|
let sorted = loaded.sorted { $0.count > $1.count }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.labels = Array(sorted)
|
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()
|
measurement.end()
|
||||||
}
|
}
|
||||||
} else {
|
} 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
||||||
@ -170,12 +176,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
logger.debug("Set saving state to true")
|
logger.debug("Set saving state to true")
|
||||||
|
|
||||||
// Check server connectivity
|
// Check server connectivity
|
||||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
Task {
|
||||||
|
let serverReachable = await serverCheck.checkServerReachability()
|
||||||
logger.debug("Server connectivity for save: \(serverReachable)")
|
logger.debug("Server connectivity for save: \(serverReachable)")
|
||||||
if serverReachable {
|
if serverReachable {
|
||||||
// Online - try to save via API
|
// Online - try to save via API
|
||||||
logger.info("Attempting to save bookmark via API")
|
logger.info("Attempting to save bookmark via API")
|
||||||
Task {
|
|
||||||
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
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?.logger.info("API save completed - Success: \(!error), Message: \(message)")
|
||||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
@ -189,7 +195,6 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
self?.logger.error("Failed to save bookmark via API: \(message)")
|
self?.logger.error("Failed to save bookmark via API: \(message)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Server not reachable - save locally
|
// Server not reachable - save locally
|
||||||
logger.info("Server not reachable, attempting local save")
|
logger.info("Server not reachable, attempting local save")
|
||||||
@ -215,6 +220,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func completeExtensionRequest() {
|
private func completeExtensionRequest() {
|
||||||
logger.debug("Completing extension request")
|
logger.debug("Completing extension request")
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,39 @@ import Foundation
|
|||||||
class SimpleAPI {
|
class SimpleAPI {
|
||||||
private static let logger = Logger.network
|
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
|
// MARK: - API Methods
|
||||||
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
||||||
logger.info("Adding bookmark: \(url)")
|
logger.info("Adding bookmark: \(url)")
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
import Foundation
|
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 struct CreateBookmarkRequestDto: Codable {
|
||||||
public let labels: [String]?
|
public let labels: [String]?
|
||||||
public let title: String?
|
public let title: String?
|
||||||
@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable {
|
|||||||
self.href = href
|
self.href = href
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -437,7 +437,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 29;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -470,7 +470,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 29;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -625,7 +625,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 29;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -669,7 +669,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 29;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
|||||||
@ -18,6 +18,8 @@ protocol PAPI {
|
|||||||
func deleteBookmark(id: String) async throws
|
func deleteBookmark(id: String) async throws
|
||||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
||||||
func getBookmarkLabels() async throws -> [BookmarkLabelDto]
|
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 {
|
class API: PAPI {
|
||||||
@ -444,6 +446,46 @@ class API: PAPI {
|
|||||||
logger.info("Successfully fetched \(result.count) bookmark labels")
|
logger.info("Successfully fetched \(result.count) bookmark labels")
|
||||||
return result
|
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 {
|
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] {
|
func getLabels() async throws -> [BookmarkLabel] {
|
||||||
let dtos = try await api.getBookmarkLabels()
|
// First, load from Core Data (instant response)
|
||||||
try? await saveLabels(dtos)
|
let cachedLabels = try await loadLabelsFromCoreData()
|
||||||
return dtos.map { $0.toDomain() }
|
|
||||||
|
// 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 {
|
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
||||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||||
|
|
||||||
try await backgroundContext.perform { [weak self] in
|
try await backgroundContext.perform {
|
||||||
guard let self = self else { return }
|
// 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 {
|
for dto in dtos {
|
||||||
if !self.tagExists(name: dto.name, in: backgroundContext) {
|
if !existingNames.contains(dto.name) {
|
||||||
dto.toEntity(context: backgroundContext)
|
dto.toEntity(context: backgroundContext)
|
||||||
|
insertCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only save if there are new labels
|
||||||
|
if insertCount > 0 {
|
||||||
try backgroundContext.save()
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,16 +10,19 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
|||||||
|
|
||||||
private let coreDataManager = CoreDataManager.shared
|
private let coreDataManager = CoreDataManager.shared
|
||||||
private let api: PAPI
|
private let api: PAPI
|
||||||
|
private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase
|
||||||
|
|
||||||
init(api: PAPI = API()) {
|
init(api: PAPI = API(),
|
||||||
|
checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) {
|
||||||
self.api = api
|
self.api = api
|
||||||
|
self.checkServerReachabilityUseCase = checkServerReachabilityUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sync Methods
|
// MARK: - Sync Methods
|
||||||
|
|
||||||
func syncOfflineBookmarks() async {
|
func syncOfflineBookmarks() async {
|
||||||
// First check if server is reachable
|
// First check if server is reachable
|
||||||
guard await ServerConnectivity.isServerReachable() else {
|
guard await checkServerReachabilityUseCase.execute() else {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
syncStatus = "Server not reachable. Cannot sync."
|
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.
|
**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
|
## 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.
|
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 Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class AppViewModel: ObservableObject {
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
class AppViewModel {
|
||||||
private let settingsRepository = SettingsRepository()
|
private let settingsRepository = SettingsRepository()
|
||||||
private let logoutUseCase: LogoutUseCase
|
private let factory: UseCaseFactory
|
||||||
|
|
||||||
@Published var hasFinishedSetup: Bool = true
|
var hasFinishedSetup: Bool = true
|
||||||
|
var isServerReachable: Bool = false
|
||||||
|
|
||||||
init(logoutUseCase: LogoutUseCase = LogoutUseCase()) {
|
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.logoutUseCase = logoutUseCase
|
self.factory = factory
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
|
|
||||||
Task {
|
loadSetupStatus()
|
||||||
await loadSetupStatus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObservers() {
|
private func setupNotificationObservers() {
|
||||||
@ -29,7 +30,7 @@ class AppViewModel: ObservableObject {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task {
|
Task { @MainActor in
|
||||||
await self?.handleUnauthorizedResponse()
|
await self?.handleUnauthorizedResponse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,19 +40,17 @@ class AppViewModel: ObservableObject {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
self?.loadSetupStatus()
|
self?.loadSetupStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleUnauthorizedResponse() async {
|
private func handleUnauthorizedResponse() async {
|
||||||
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
print("AppViewModel: Handling 401 Unauthorized - logging out user")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Führe den Logout durch
|
try await factory.makeLogoutUseCase().execute()
|
||||||
try await logoutUseCase.execute()
|
|
||||||
|
|
||||||
// Update UI state
|
|
||||||
loadSetupStatus()
|
loadSetupStatus()
|
||||||
|
|
||||||
print("AppViewModel: User successfully logged out due to 401 error")
|
print("AppViewModel: User successfully logged out due to 401 error")
|
||||||
@ -60,11 +59,18 @@ class AppViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadSetupStatus() {
|
private func loadSetupStatus() {
|
||||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func onAppResume() async {
|
||||||
|
await checkServerReachability()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkServerReachability() async {
|
||||||
|
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
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,6 +29,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
@State private var initialContentEndPosition: CGFloat = 0
|
@State private var initialContentEndPosition: CGFloat = 0
|
||||||
@State private var showingFontSettings = false
|
@State private var showingFontSettings = false
|
||||||
@State private var showingLabelsSheet = false
|
@State private var showingLabelsSheet = false
|
||||||
|
@State private var showingAnnotationsSheet = false
|
||||||
@State private var readingProgress: Double = 0.0
|
@State private var readingProgress: Double = 0.0
|
||||||
@State private var lastSentProgress: Double = 0.0
|
@State private var lastSentProgress: Double = 0.0
|
||||||
@State private var showJumpToProgressButton: Bool = false
|
@State private var showJumpToProgressButton: Bool = false
|
||||||
@ -86,6 +87,20 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
if webViewHeight != height {
|
if webViewHeight != height {
|
||||||
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)
|
.frame(height: webViewHeight)
|
||||||
@ -220,6 +235,12 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
Image(systemName: "tag")
|
Image(systemName: "tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showingAnnotationsSheet = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "pencil.line")
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingFontSettings = true
|
showingFontSettings = true
|
||||||
}) {
|
}) {
|
||||||
@ -252,6 +273,11 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
.sheet(isPresented: $showingLabelsSheet) {
|
.sheet(isPresented: $showingLabelsSheet) {
|
||||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAnnotationsSheet) {
|
||||||
|
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
|
||||||
|
viewModel.selectedAnnotationId = annotationId
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingImageViewer) {
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
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
|
.onChange(of: viewModel.readProgress) { _, progress in
|
||||||
showJumpToProgressButton = progress > 0 && progress < 100
|
showJumpToProgressButton = progress > 0 && progress < 100
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
|
||||||
|
// Trigger WebView reload when annotation is selected
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
await viewModel.loadArticleContent(id: bookmarkId)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct BookmarkDetailView2: View {
|
|||||||
@State private var initialContentEndPosition: CGFloat = 0
|
@State private var initialContentEndPosition: CGFloat = 0
|
||||||
@State private var showingFontSettings = false
|
@State private var showingFontSettings = false
|
||||||
@State private var showingLabelsSheet = false
|
@State private var showingLabelsSheet = false
|
||||||
|
@State private var showingAnnotationsSheet = false
|
||||||
@State private var readingProgress: Double = 0.0
|
@State private var readingProgress: Double = 0.0
|
||||||
@State private var lastSentProgress: Double = 0.0
|
@State private var lastSentProgress: Double = 0.0
|
||||||
@State private var showJumpToProgressButton: Bool = false
|
@State private var showJumpToProgressButton: Bool = false
|
||||||
@ -50,6 +51,11 @@ struct BookmarkDetailView2: View {
|
|||||||
.sheet(isPresented: $showingLabelsSheet) {
|
.sheet(isPresented: $showingLabelsSheet) {
|
||||||
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAnnotationsSheet) {
|
||||||
|
AnnotationsListView(bookmarkId: bookmarkId) { annotationId in
|
||||||
|
viewModel.selectedAnnotationId = annotationId
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingImageViewer) {
|
.sheet(isPresented: $showingImageViewer) {
|
||||||
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
|
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
|
.onChange(of: viewModel.readProgress) { _, progress in
|
||||||
showJumpToProgressButton = progress > 0 && progress < 100
|
showJumpToProgressButton = progress > 0 && progress < 100
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.selectedAnnotationId) { _, _ in
|
||||||
|
// Trigger WebView reload when annotation is selected
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||||
await viewModel.loadArticleContent(id: bookmarkId)
|
await viewModel.loadArticleContent(id: bookmarkId)
|
||||||
@ -254,6 +270,14 @@ struct BookmarkDetailView2: View {
|
|||||||
Image(systemName: "tag")
|
Image(systemName: "tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewModel.hasAnnotations {
|
||||||
|
Button(action: {
|
||||||
|
showingAnnotationsSheet = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "pencil.line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingFontSettings = true
|
showingFontSettings = true
|
||||||
}) {
|
}) {
|
||||||
@ -437,6 +461,20 @@ struct BookmarkDetailView2: View {
|
|||||||
if webViewHeight != height {
|
if webViewHeight != height {
|
||||||
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)
|
.frame(height: webViewHeight)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class BookmarkDetailViewModel {
|
|||||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||||
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
|
||||||
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
|
||||||
|
private let api: PAPI
|
||||||
|
|
||||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||||
var articleContent: String = ""
|
var articleContent: String = ""
|
||||||
@ -18,6 +19,8 @@ class BookmarkDetailViewModel {
|
|||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var settings: Settings?
|
var settings: Settings?
|
||||||
var readProgress: Int = 0
|
var readProgress: Int = 0
|
||||||
|
var selectedAnnotationId: String?
|
||||||
|
var hasAnnotations: Bool = false
|
||||||
|
|
||||||
private var factory: UseCaseFactory?
|
private var factory: UseCaseFactory?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@ -28,6 +31,7 @@ class BookmarkDetailViewModel {
|
|||||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||||
|
self.api = API()
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
|
|
||||||
readProgressSubject
|
readProgressSubject
|
||||||
@ -84,6 +88,9 @@ class BookmarkDetailViewModel {
|
|||||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
|
||||||
articleParagraphs = paragraphs
|
articleParagraphs = paragraphs
|
||||||
|
|
||||||
|
// Check if article contains annotations
|
||||||
|
hasAnnotations = articleContent.contains("<rd-annotation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -137,4 +144,22 @@ class BookmarkDetailViewModel {
|
|||||||
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
|
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
|
||||||
readProgressSubject.send((id, progress, anchor))
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,9 @@ class BookmarksViewModel {
|
|||||||
// Undo delete functionality
|
// Undo delete functionality
|
||||||
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete
|
||||||
|
|
||||||
|
// Prevent concurrent updates
|
||||||
|
private var isUpdating = false
|
||||||
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var limit = 50
|
private var limit = 50
|
||||||
@ -104,6 +107,10 @@ class BookmarksViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
||||||
|
guard !isUpdating else { return }
|
||||||
|
isUpdating = true
|
||||||
|
defer { isUpdating = false }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
currentState = state
|
currentState = state
|
||||||
@ -149,7 +156,9 @@ class BookmarksViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadMoreBookmarks() async {
|
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
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|||||||
@ -10,7 +10,53 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct Constants {
|
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,6 +11,8 @@ struct NativeWebView: View {
|
|||||||
let settings: Settings
|
let settings: Settings
|
||||||
let onHeightChange: (CGFloat) -> Void
|
let onHeightChange: (CGFloat) -> Void
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
|
var selectedAnnotationId: String?
|
||||||
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
|
|
||||||
@State private var webPage = WebPage()
|
@State private var webPage = WebPage()
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@ -20,6 +22,7 @@ struct NativeWebView: View {
|
|||||||
.scrollDisabled(true) // Disable internal scrolling
|
.scrollDisabled(true) // Disable internal scrolling
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
|
setupAnnotationMessageHandler()
|
||||||
}
|
}
|
||||||
.onChange(of: htmlContent) { _, _ in
|
.onChange(of: htmlContent) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
@ -27,6 +30,9 @@ struct NativeWebView: View {
|
|||||||
.onChange(of: colorScheme) { _, _ in
|
.onChange(of: colorScheme) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedAnnotationId) { _, _ in
|
||||||
|
loadStyledContent()
|
||||||
|
}
|
||||||
.onChange(of: webPage.isLoading) { _, isLoading in
|
.onChange(of: webPage.isLoading) { _, isLoading in
|
||||||
if !isLoading {
|
if !isLoading {
|
||||||
// Update height when content finishes loading
|
// Update height when content finishes loading
|
||||||
@ -39,6 +45,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 {
|
private func updateContentHeightWithJS() async {
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
|
|
||||||
@ -197,6 +241,49 @@ struct NativeWebView: View {
|
|||||||
th { font-weight: 600; }
|
th { font-weight: 600; }
|
||||||
|
|
||||||
hr { border: none; height: 1px; background-color: #ccc; margin: 24px 0; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -242,6 +329,12 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scheduleHeightCheck();
|
scheduleHeightCheck();
|
||||||
|
|
||||||
|
// Scroll to selected annotation
|
||||||
|
\(generateScrollToAnnotationJS())
|
||||||
|
|
||||||
|
// Text Selection and Annotation Overlay
|
||||||
|
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -273,6 +366,280 @@ struct NativeWebView: View {
|
|||||||
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
|
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)
|
// MARK: - Hybrid WebView (Not Currently Used)
|
||||||
|
|||||||
@ -214,7 +214,7 @@ struct TagManagementView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var labelsScrollView: some View {
|
private var labelsScrollView: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
ForEach(chunkedLabels, id: \.self) { rowLabels in
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
ForEach(rowLabels, id: \.id) { label in
|
ForEach(rowLabels, id: \.id) { label in
|
||||||
|
|||||||
@ -6,6 +6,8 @@ struct WebView: UIViewRepresentable {
|
|||||||
let settings: Settings
|
let settings: Settings
|
||||||
let onHeightChange: (CGFloat) -> Void
|
let onHeightChange: (CGFloat) -> Void
|
||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
|
var selectedAnnotationId: String?
|
||||||
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
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: "heightUpdate")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||||
|
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
|
context.coordinator.webView = webView
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@ -37,6 +42,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
@ -235,6 +241,49 @@ struct WebView: UIViewRepresentable {
|
|||||||
--separator-color: #e0e0e0;
|
--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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -264,6 +313,12 @@ struct WebView: UIViewRepresentable {
|
|||||||
document.querySelectorAll('img').forEach(img => {
|
document.querySelectorAll('img').forEach(img => {
|
||||||
img.addEventListener('load', debouncedHeightUpdate);
|
img.addEventListener('load', debouncedHeightUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scroll to selected annotation
|
||||||
|
\(generateScrollToAnnotationJS())
|
||||||
|
|
||||||
|
// Text Selection and Annotation Overlay
|
||||||
|
\(generateAnnotationOverlayJS(isDarkMode: isDarkMode))
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -276,6 +331,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.navigationDelegate = nil
|
webView.navigationDelegate = nil
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
||||||
webView.loadHTMLString("", baseURL: nil)
|
webView.loadHTMLString("", baseURL: nil)
|
||||||
coordinator.cleanup()
|
coordinator.cleanup()
|
||||||
}
|
}
|
||||||
@ -305,12 +361,295 @@ struct WebView: UIViewRepresentable {
|
|||||||
return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace"
|
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 {
|
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
||||||
|
|
||||||
|
// WebView reference
|
||||||
|
weak var webView: WKWebView?
|
||||||
|
|
||||||
// Height management
|
// Height management
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
@ -352,6 +691,17 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
self.handleScrollProgress(progress: progress)
|
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) {
|
private func handleHeightUpdate(height: CGFloat) {
|
||||||
@ -427,5 +777,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
|
|
||||||
onHeightChange = nil
|
onHeightChange = nil
|
||||||
onScroll = nil
|
onScroll = nil
|
||||||
|
onAnnotationCreated = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ protocol UseCaseFactory {
|
|||||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||||
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +32,9 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
||||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||||
private let settingsRepository: PSettingsRepository = SettingsRepository()
|
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()
|
static let shared = DefaultUseCaseFactory()
|
||||||
|
|
||||||
@ -112,4 +117,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
return SaveCardLayoutUseCase(settingsRepository: settingsRepository)
|
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
|
import Combine
|
||||||
|
|
||||||
class MockUseCaseFactory: UseCaseFactory {
|
class MockUseCaseFactory: UseCaseFactory {
|
||||||
|
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
|
||||||
|
MockCheckServerReachabilityUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||||
MockOfflineBookmarkSyncUseCase()
|
MockOfflineBookmarkSyncUseCase()
|
||||||
}
|
}
|
||||||
@ -84,6 +88,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase {
|
||||||
MockSaveCardLayoutUseCase()
|
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 {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
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)
|
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 let successDelaySubject = PassthroughSubject<Int, Never>()
|
||||||
private var completionTimerActive = false
|
private var completionTimerActive = false
|
||||||
|
|
||||||
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.syncUseCase = syncUseCase
|
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
|
||||||
setupBindings()
|
setupBindings()
|
||||||
refreshState()
|
refreshState()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct PadSidebarView: View {
|
|||||||
@State private var selectedTag: BookmarkLabel?
|
@State private var selectedTag: BookmarkLabel?
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@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]
|
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||||
|
|
||||||
@ -87,11 +87,11 @@ struct PadSidebarView: View {
|
|||||||
case .all:
|
case .all:
|
||||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .unread:
|
case .unread:
|
||||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .favorite:
|
case .favorite:
|
||||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .archived:
|
case .archived:
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
|
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
case .article:
|
case .article:
|
||||||
|
|||||||
@ -12,7 +12,7 @@ struct PhoneTabView: View {
|
|||||||
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
||||||
|
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@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
|
// Navigation paths for each tab
|
||||||
@State private var allPath = NavigationPath()
|
@State private var allPath = NavigationPath()
|
||||||
@ -149,9 +149,9 @@ struct PhoneTabView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
||||||
List(bookmarks) { bookmark in
|
List(bookmarks) { bookmark in
|
||||||
// Hidden NavigationLink to remove disclosure indicator
|
|
||||||
// To restore: uncomment block below and remove ZStack
|
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|
||||||
|
// Hidden NavigationLink to remove disclosure indicator
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||||
} label: {
|
} label: {
|
||||||
@ -234,11 +234,11 @@ struct PhoneTabView: View {
|
|||||||
case .all:
|
case .all:
|
||||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .unread:
|
case .unread:
|
||||||
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .favorite:
|
case .favorite:
|
||||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .archived:
|
case .archived:
|
||||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
||||||
case .search:
|
case .search:
|
||||||
EmptyView() // search is directly implemented
|
EmptyView() // search is directly implemented
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@ -1,5 +1,46 @@
|
|||||||
import SwiftUI
|
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 {
|
struct ReleaseNotesView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@ -33,10 +74,7 @@ struct ReleaseNotesView: View {
|
|||||||
private func loadReleaseNotes() -> AttributedString? {
|
private func loadReleaseNotes() -> AttributedString? {
|
||||||
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
guard let url = Bundle.main.url(forResource: "RELEASE_NOTES", withExtension: "md"),
|
||||||
let markdownContent = try? String(contentsOf: url),
|
let markdownContent = try? String(contentsOf: url),
|
||||||
let attributedString = try? AttributedString(
|
let attributedString = try? AttributedString(styledMarkdown: markdownContent) else {
|
||||||
markdown: markdownContent,
|
|
||||||
options: .init(interpretedSyntax: .full)
|
|
||||||
) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return attributedString
|
return attributedString
|
||||||
|
|||||||
@ -11,8 +11,7 @@ struct SettingsServerView: View {
|
|||||||
@State private var viewModel = SettingsServerViewModel()
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
@State private var showingLogoutAlert = false
|
@State private var showingLogoutAlert = false
|
||||||
|
|
||||||
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
|
init(showingLogoutAlert: Bool = false) {
|
||||||
self.viewModel = viewModel
|
|
||||||
self.showingLogoutAlert = showingLogoutAlert
|
self.showingLogoutAlert = showingLogoutAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,11 +30,12 @@ struct SettingsServerView: View {
|
|||||||
|
|
||||||
// Form
|
// Form
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
// Server Endpoint
|
||||||
Text("Server Endpoint")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.headline)
|
|
||||||
if viewModel.isSetupMode {
|
if viewModel.isSetupMode {
|
||||||
TextField("https://readeck.example.com", text: $viewModel.endpoint)
|
TextField("",
|
||||||
|
text: $viewModel.endpoint,
|
||||||
|
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
@ -43,6 +43,42 @@ struct SettingsServerView: View {
|
|||||||
.onChange(of: viewModel.endpoint) {
|
.onChange(of: viewModel.endpoint) {
|
||||||
viewModel.clearMessages()
|
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 {
|
} else {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "server.rack")
|
Image(systemName: "server.rack")
|
||||||
@ -55,11 +91,13 @@ struct SettingsServerView: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Username")
|
// Username
|
||||||
.font(.headline)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if viewModel.isSetupMode {
|
if viewModel.isSetupMode {
|
||||||
TextField("Your Username", text: $viewModel.username)
|
TextField("",
|
||||||
|
text: $viewModel.username,
|
||||||
|
prompt: Text("Username").foregroundColor(.secondary))
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
@ -78,12 +116,13 @@ struct SettingsServerView: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if viewModel.isSetupMode {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Password")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
SecureField("Your Password", text: $viewModel.password)
|
// Password
|
||||||
|
if viewModel.isSetupMode {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
SecureField("",
|
||||||
|
text: $viewModel.password,
|
||||||
|
prompt: Text("Password").foregroundColor(.secondary))
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.onChange(of: viewModel.password) {
|
.onChange(of: viewModel.password) {
|
||||||
viewModel.clearMessages()
|
viewModel.clearMessages()
|
||||||
@ -172,8 +211,22 @@ struct SettingsServerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
// MARK: - Quick Input Chip Component
|
||||||
SettingsServerView(viewModel: .init(
|
|
||||||
MockUseCaseFactory()
|
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
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
|
// Normalize endpoint before saving
|
||||||
try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token)
|
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
|
isLoggedIn = true
|
||||||
successMessage = "Server settings saved and successfully logged in."
|
successMessage = "Server settings saved and successfully logged in."
|
||||||
try await SettingsRepository().saveHasFinishedSetup(true)
|
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||||
@ -74,6 +81,51 @@ class SettingsServerViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Endpoint Normalization
|
||||||
|
|
||||||
|
private func normalizeEndpoint(_ endpoint: String) -> String {
|
||||||
|
var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Remove query parameters
|
||||||
|
if let queryIndex = normalized.firstIndex(of: "?") {
|
||||||
|
normalized = String(normalized[..<queryIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL components
|
||||||
|
guard var urlComponents = URLComponents(string: normalized) else {
|
||||||
|
// If parsing fails, try adding https:// and parse again
|
||||||
|
normalized = "https://" + normalized
|
||||||
|
guard var urlComponents = URLComponents(string: normalized) else {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return buildNormalizedURL(from: urlComponents)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildNormalizedURL(from: urlComponents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildNormalizedURL(from components: URLComponents) -> String {
|
||||||
|
var urlComponents = components
|
||||||
|
|
||||||
|
// Ensure scheme is http or https, default to https
|
||||||
|
if urlComponents.scheme == nil {
|
||||||
|
urlComponents.scheme = "https"
|
||||||
|
} else if urlComponents.scheme != "http" && urlComponents.scheme != "https" {
|
||||||
|
urlComponents.scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash from path if present
|
||||||
|
if urlComponents.path.hasSuffix("/") {
|
||||||
|
urlComponents.path = String(urlComponents.path.dropLast())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove query parameters (already done above, but double check)
|
||||||
|
urlComponents.query = nil
|
||||||
|
urlComponents.fragment = nil
|
||||||
|
|
||||||
|
return urlComponents.string ?? components.string ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func logout() async {
|
func logout() async {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@ -8,9 +8,6 @@ extension Notification.Name {
|
|||||||
// MARK: - Authentication
|
// MARK: - Authentication
|
||||||
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
|
static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse")
|
||||||
|
|
||||||
// MARK: - Network
|
|
||||||
static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable")
|
|
||||||
|
|
||||||
// MARK: - UI Interactions
|
// MARK: - UI Interactions
|
||||||
static let dismissKeyboard = Notification.Name("DismissKeyboard")
|
static let dismissKeyboard = Notification.Name("DismissKeyboard")
|
||||||
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
|
static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare")
|
||||||
|
|||||||
@ -10,8 +10,9 @@ import netfox
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct readeckApp: App {
|
struct readeckApp: App {
|
||||||
@StateObject private var appViewModel = AppViewModel()
|
@State private var appViewModel = AppViewModel()
|
||||||
@StateObject private var appSettings = AppSettings()
|
@StateObject private var appSettings = AppSettings()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@ -29,8 +30,6 @@ struct readeckApp: App {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
NFX.sharedInstance().start()
|
NFX.sharedInstance().start()
|
||||||
#endif
|
#endif
|
||||||
// Initialize server connectivity monitoring
|
|
||||||
_ = ServerConnectivity.shared
|
|
||||||
Task {
|
Task {
|
||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
@ -40,6 +39,13 @@ struct readeckApp: App {
|
|||||||
await loadAppSettings()
|
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