feat: Add offline bookmark sync functionality
Add comprehensive offline bookmark support with sync capabilities: - Implement offline bookmark storage using Core Data with App Group sharing - Add Share Extension support for saving bookmarks when server unavailable - Create LocalBookmarksSyncView for managing offline bookmark queue - Add OfflineSyncManager for automatic and manual sync operations - Implement ServerConnectivity monitoring and status handling - Add badge notifications on More tab for pending offline bookmarks - Fix tag pagination in Share Extension with unique IDs for proper rendering - Update PhoneTabView with event-based badge count updates - Add App Group entitlements for data sharing between main app and extension The offline system provides seamless bookmark saving when disconnected, with automatic sync when connection is restored and manual sync options.
This commit is contained in:
parent
f20f86a41a
commit
ef13faeff7
@ -25,9 +25,32 @@
|
||||
},
|
||||
"%lld articles in the queue" : {
|
||||
|
||||
},
|
||||
"%lld bookmark%@ synced successfully" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld bookmark%2$@ synced successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld bookmark%@ waiting for sync" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld bookmark%2$@ waiting for sync"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld min" : {
|
||||
|
||||
},
|
||||
"%lld minutes" : {
|
||||
|
||||
},
|
||||
"%lld." : {
|
||||
|
||||
@ -79,15 +102,27 @@
|
||||
},
|
||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||
|
||||
},
|
||||
"Automatic sync" : {
|
||||
|
||||
},
|
||||
"Automatically mark articles as read" : {
|
||||
|
||||
},
|
||||
"Available tags" : {
|
||||
|
||||
},
|
||||
"Cancel" : {
|
||||
|
||||
},
|
||||
"Clear cache" : {
|
||||
|
||||
},
|
||||
"Close" : {
|
||||
|
||||
},
|
||||
"Data Management" : {
|
||||
|
||||
},
|
||||
"Delete" : {
|
||||
|
||||
@ -190,6 +225,9 @@
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
},
|
||||
"Open external links in in-app Safari" : {
|
||||
|
||||
},
|
||||
"Optional: Custom title" : {
|
||||
|
||||
@ -233,15 +271,24 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reading Settings" : {
|
||||
|
||||
},
|
||||
"Remove" : {
|
||||
|
||||
},
|
||||
"Reset settings" : {
|
||||
|
||||
},
|
||||
"Restore" : {
|
||||
|
||||
},
|
||||
"Resume listening" : {
|
||||
|
||||
},
|
||||
"Safari Reader Mode" : {
|
||||
|
||||
},
|
||||
"Save bookmark" : {
|
||||
|
||||
@ -275,6 +322,9 @@
|
||||
},
|
||||
"Server Endpoint" : {
|
||||
|
||||
},
|
||||
"Server not reachable - saving locally" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
|
||||
@ -284,6 +334,15 @@
|
||||
},
|
||||
"Successfully logged in" : {
|
||||
|
||||
},
|
||||
"Sync interval" : {
|
||||
|
||||
},
|
||||
"Sync Settings" : {
|
||||
|
||||
},
|
||||
"Syncing with server..." : {
|
||||
|
||||
},
|
||||
"Theme" : {
|
||||
|
||||
|
||||
60
URLShare/OfflineBookmarkManager.swift
Normal file
60
URLShare/OfflineBookmarkManager.swift
Normal file
@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class OfflineBookmarkManager {
|
||||
static let shared = OfflineBookmarkManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Core Data Stack for Share Extension
|
||||
|
||||
var context: NSManagedObjectContext {
|
||||
return CoreDataManager.shared.context
|
||||
}
|
||||
|
||||
// MARK: - Offline Storage Methods
|
||||
|
||||
func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool {
|
||||
let tagsString = tags.joined(separator: ",")
|
||||
|
||||
// Check if URL already exists offline
|
||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
|
||||
|
||||
do {
|
||||
let existingEntities = try context.fetch(fetchRequest)
|
||||
if let existingEntity = existingEntities.first {
|
||||
// Update existing entry
|
||||
existingEntity.tags = tagsString
|
||||
existingEntity.title = title
|
||||
} else {
|
||||
// Create new entry
|
||||
let entity = ArticleURLEntity(context: context)
|
||||
entity.id = UUID()
|
||||
entity.url = url
|
||||
entity.title = title
|
||||
entity.tags = tagsString
|
||||
}
|
||||
|
||||
try context.save()
|
||||
print("Bookmark saved offline: \(url)")
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save offline bookmark: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func getTags() -> [String] {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
|
||||
do {
|
||||
let tagEntities = try context.fetch(fetchRequest)
|
||||
return tagEntities.compactMap { $0.name }.sorted()
|
||||
} catch {
|
||||
print("Failed to fetch tags: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
62
URLShare/ServerConnectivity.swift
Normal file
62
URLShare/ServerConnectivity.swift
Normal file
@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
class ServerConnectivity: ObservableObject {
|
||||
@Published var isServerReachable = false
|
||||
|
||||
static let shared = ServerConnectivity()
|
||||
|
||||
private init() {}
|
||||
|
||||
// Check if the Readeck server endpoint is reachable
|
||||
static func isServerReachable() async -> Bool {
|
||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
||||
!endpoint.isEmpty,
|
||||
let url = URL(string: endpoint + "/api/health") else {
|
||||
return false
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 5.0 // 5 second timeout
|
||||
|
||||
do {
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
return httpResponse.statusCode == 200
|
||||
}
|
||||
} catch {
|
||||
print("Server connectivity check failed: \(error)")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Alternative check using ping-style endpoint
|
||||
static func isServerReachableSync() -> Bool {
|
||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
||||
!endpoint.isEmpty,
|
||||
let url = URL(string: endpoint) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var isReachable = false
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "HEAD" // Just check if server responds
|
||||
request.timeoutInterval = 3.0
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { _, response, error in
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
task.resume()
|
||||
_ = semaphore.wait(timeout: .now() + 3.0)
|
||||
|
||||
return isReachable
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ struct ShareBookmarkView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
logoSection
|
||||
serverStatusSection
|
||||
urlSection
|
||||
tagManagementSection
|
||||
.id(AddBookmarkFieldFocus.labels)
|
||||
@ -70,6 +71,26 @@ struct ShareBookmarkView: View {
|
||||
.opacity(0.9)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var serverStatusSection: some View {
|
||||
if !viewModel.isServerReachable {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
.foregroundColor(.orange)
|
||||
Text("Server not reachable - saving locally")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var urlSection: some View {
|
||||
if let url = viewModel.url {
|
||||
@ -113,7 +134,7 @@ struct ShareBookmarkView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var tagManagementSection: some View {
|
||||
if !viewModel.labels.isEmpty {
|
||||
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
|
||||
TagManagementView(
|
||||
allLabels: convertToBookmarkLabels(viewModel.labels),
|
||||
selectedLabels: viewModel.selectedLabels,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import CoreData
|
||||
|
||||
class ShareBookmarkViewModel: ObservableObject {
|
||||
@Published var url: String?
|
||||
@ -10,6 +11,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
||||
@Published var isSaving: Bool = false
|
||||
@Published var searchText: String = ""
|
||||
@Published var isServerReachable: Bool = true
|
||||
let extensionContext: NSExtensionContext?
|
||||
|
||||
// Computed properties for pagination
|
||||
@ -27,7 +29,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var availableLabelPages: [[BookmarkLabelDto]] {
|
||||
let pageSize = Constants.Labels.pageSize
|
||||
let pageSize = 12 // Extension can't access Constants.Labels.pageSize
|
||||
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
||||
|
||||
if labelsToShow.count <= pageSize {
|
||||
@ -45,9 +47,14 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
checkServerReachability()
|
||||
loadLabels()
|
||||
}
|
||||
|
||||
private func checkServerReachability() {
|
||||
isServerReachable = ServerConnectivity.isServerReachableSync()
|
||||
}
|
||||
|
||||
private func extractSharedContent() {
|
||||
guard let extensionContext = extensionContext else { return }
|
||||
for item in extensionContext.inputItems {
|
||||
@ -77,12 +84,30 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
|
||||
func loadLabels() {
|
||||
Task {
|
||||
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
} ?? []
|
||||
let sorted = loaded.sorted { $0.count > $1.count }
|
||||
await MainActor.run {
|
||||
self.labels = Array(sorted)
|
||||
// Check if server is reachable
|
||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||
print("DEBUG: Server reachable: \(serverReachable)")
|
||||
|
||||
if serverReachable {
|
||||
// Load from API
|
||||
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
} ?? []
|
||||
let sorted = loaded.sorted { $0.count > $1.count }
|
||||
await MainActor.run {
|
||||
self.labels = Array(sorted)
|
||||
print("DEBUG: Loaded \(loaded.count) labels from API")
|
||||
}
|
||||
} else {
|
||||
// Load from local database
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,16 +118,40 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
isSaving = true
|
||||
Task {
|
||||
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
self?.isSaving = false
|
||||
if !error {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
|
||||
// Check server connectivity
|
||||
if ServerConnectivity.isServerReachableSync() {
|
||||
// Online - try to save via API
|
||||
Task {
|
||||
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||
self?.isSaving = false
|
||||
if !error {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Server not reachable - save locally
|
||||
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
||||
url: url,
|
||||
title: title,
|
||||
tags: Array(selectedLabels)
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isSaving = false
|
||||
if success {
|
||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
} else {
|
||||
self.statusMessage = ("Failed to save locally.", true, "❌")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.readeck.app</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck</string>
|
||||
|
||||
@ -86,6 +86,7 @@
|
||||
Data/KeychainHelper.swift,
|
||||
Domain/Model/Bookmark.swift,
|
||||
Domain/Model/BookmarkLabel.swift,
|
||||
Logger.swift,
|
||||
readeck.xcdatamodeld,
|
||||
Splash.storyboard,
|
||||
UI/Components/Constants.swift,
|
||||
@ -435,7 +436,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -468,7 +469,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -623,7 +624,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -667,7 +668,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 15;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -8,6 +8,15 @@ class CoreDataManager {
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container = NSPersistentContainer(name: "readeck")
|
||||
|
||||
// Use App Group container for shared access with extensions
|
||||
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
|
||||
|
||||
if let storeURL = storeURL {
|
||||
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
||||
container.persistentStoreDescriptions = [storeDescription]
|
||||
}
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
fatalError("Core Data error: \(error)")
|
||||
|
||||
213
readeck/Data/Mappers/BookmarkEntityMapper.swift
Normal file
213
readeck/Data/Mappers/BookmarkEntityMapper.swift
Normal file
@ -0,0 +1,213 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
|
||||
// MARK: - DTO -> Entity
|
||||
|
||||
extension BookmarkDto {
|
||||
func toEntity(context: NSManagedObjectContext) -> BookmarkEntity {
|
||||
let entity = BookmarkEntity(context: context)
|
||||
entity.title = self.title
|
||||
entity.url = self.url
|
||||
entity.authors = self.authors.first
|
||||
entity.desc = self.description
|
||||
entity.created = self.created
|
||||
|
||||
entity.siteName = self.siteName
|
||||
entity.site = self.site
|
||||
entity.authors = self.authors.first // TODO: support multiple authors
|
||||
entity.published = self.published
|
||||
entity.created = self.created
|
||||
entity.update = self.updated
|
||||
entity.readingTime = Int16(self.readingTime ?? 0)
|
||||
entity.readProgress = Int16(self.readProgress)
|
||||
entity.wordCount = Int64(self.wordCount ?? 0)
|
||||
entity.isArchived = self.isArchived
|
||||
entity.isMarked = self.isMarked
|
||||
entity.hasArticle = self.hasArticle
|
||||
entity.loaded = self.loaded
|
||||
entity.hasDeleted = self.isDeleted
|
||||
entity.documentType = self.documentType
|
||||
entity.href = self.href
|
||||
entity.lang = self.lang
|
||||
entity.textDirection = self.textDirection
|
||||
entity.type = self.type
|
||||
entity.state = Int16(self.state)
|
||||
|
||||
// entity.resources = self.resources.toEntity(context: context)
|
||||
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
extension BookmarkResourcesDto {
|
||||
func toEntity(context: NSManagedObjectContext) -> BookmarkResourcesEntity {
|
||||
let entity = BookmarkResourcesEntity(context: context)
|
||||
|
||||
entity.article = self.article?.toEntity(context: context)
|
||||
entity.icon = self.icon?.toEntity(context: context)
|
||||
entity.image = self.image?.toEntity(context: context)
|
||||
entity.log = self.log?.toEntity(context: context)
|
||||
entity.props = self.props?.toEntity(context: context)
|
||||
entity.thumbnail = self.thumbnail?.toEntity(context: context)
|
||||
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageResourceDto {
|
||||
func toEntity(context: NSManagedObjectContext) -> ImageResourceEntity {
|
||||
let entity = ImageResourceEntity(context: context)
|
||||
entity.src = self.src
|
||||
entity.width = Int64(self.width)
|
||||
entity.height = Int64(self.height)
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
extension ResourceDto {
|
||||
func toEntity(context: NSManagedObjectContext) -> ResourceEntity {
|
||||
let entity = ResourceEntity(context: context)
|
||||
entity.src = self.src
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
// MARK: - BookmarkEntity to Domain Mapping
|
||||
extension BookmarkEntity {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Domain to BookmarkEntity Mapping
|
||||
extension Bookmark {
|
||||
func toEntity(context: NSManagedObjectContext) -> BookmarkEntity {
|
||||
let entity = BookmarkEntity(context: context)
|
||||
entity.populateFrom(bookmark: self)
|
||||
return entity
|
||||
}
|
||||
|
||||
func updateEntity(_ entity: BookmarkEntity) {
|
||||
entity.populateFrom(bookmark: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Resource {
|
||||
func toEntity(context: NSManagedObjectContext) -> ResourceEntity {
|
||||
let entity = ResourceEntity(context: context)
|
||||
entity.populateFrom(resource: self)
|
||||
return entity
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
private extension BookmarkEntity {
|
||||
func populateFrom(bookmark: Bookmark) {
|
||||
self.id = bookmark.id
|
||||
self.title = bookmark.title
|
||||
self.url = bookmark.url
|
||||
self.desc = bookmark.description
|
||||
self.siteName = bookmark.siteName
|
||||
self.site = bookmark.site
|
||||
self.authors = bookmark.authors.first // TODO: support multiple authors
|
||||
self.published = bookmark.published
|
||||
self.created = bookmark.created
|
||||
self.update = bookmark.updated
|
||||
self.readingTime = Int16(bookmark.readingTime ?? 0)
|
||||
self.readProgress = Int16(bookmark.readProgress)
|
||||
self.wordCount = Int64(bookmark.wordCount ?? 0)
|
||||
self.isArchived = bookmark.isArchived
|
||||
self.isMarked = bookmark.isMarked
|
||||
self.hasArticle = bookmark.hasArticle
|
||||
self.loaded = bookmark.loaded
|
||||
self.hasDeleted = bookmark.isDeleted
|
||||
self.documentType = bookmark.documentType
|
||||
self.href = bookmark.href
|
||||
self.lang = bookmark.lang
|
||||
self.textDirection = bookmark.textDirection
|
||||
self.type = bookmark.type
|
||||
self.state = Int16(bookmark.state)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookmarkState Mapping
|
||||
private extension BookmarkState {
|
||||
static func fromRawValue(_ value: Int) -> BookmarkState {
|
||||
switch value {
|
||||
case 0: return .unread
|
||||
case 1: return .favorite
|
||||
case 2: return .archived
|
||||
default: return .unread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BookmarkResourcesEntity {
|
||||
func populateFrom(bookmarkResources: BookmarkResources) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private extension ImageResourceEntity {
|
||||
func populateFrom(imageResource: ImageResource) {
|
||||
self.src = imageResource.src
|
||||
self.height = Int64(imageResource.height)
|
||||
self.width = Int64(imageResource.width)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ResourceEntity {
|
||||
func populateFrom(resource: Resource) {
|
||||
self.src = resource.src
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Conversion Helpers
|
||||
private extension String {
|
||||
func toDate() -> Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.date(from: self) ??
|
||||
ISO8601DateFormatter().date(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
func toISOString() -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Mapping Extensions
|
||||
extension Array where Element == BookmarkEntity {
|
||||
func toDomain() -> [Bookmark] {
|
||||
return [] // self.map { $0.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Bookmark {
|
||||
func toEntities(context: NSManagedObjectContext) -> [BookmarkEntity] {
|
||||
return self.map { $0.toEntity(context: context) }
|
||||
}
|
||||
}
|
||||
/*
|
||||
extension BookmarkEntity {
|
||||
func toDomain() -> Bookmark {
|
||||
return Bookmark(id: id ?? "", title: title ?? "", url: url!, href: href ?? "", description: description, authors: [authors ?? ""], created: created ?? "", published: published, updated: update!, siteName: siteName ?? "", site: site!, readingTime: Int(readingTime), wordCount: Int(wordCount), hasArticle: hasArticle, isArchived: isArchived, isDeleted: isDeleted, isMarked: isMarked, labels: [], lang: lang, loaded: loaded, readProgress: Int(readProgress), documentType: documentType ?? "", state: Int(state), textDirection: textDirection ?? "", type: type ?? "", resources: resources.toDomain())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BookmarkResourcesEntity {
|
||||
func toDomain() -> BookmarkResources {
|
||||
return BookmarkResources(article: ar, icon: <#T##ImageResource?#>, image: <#T##ImageResource?#>, log: <#T##Resource?#>, props: <#T##Resource?#>, thumbnail: <#T##ImageResource?#>
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageResourceEntity {
|
||||
|
||||
}
|
||||
*/
|
||||
19
readeck/Data/Mappers/TagEntityMapper.swift
Normal file
19
readeck/Data/Mappers/TagEntityMapper.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// TagEntityMapper.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 11.08.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
extension BookmarkLabelDto {
|
||||
|
||||
@discardableResult
|
||||
func toEntity(context: NSManagedObjectContext) -> TagEntity {
|
||||
let entity = TagEntity(context: context)
|
||||
entity.name = name
|
||||
return entity
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,34 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class LabelsRepository: PLabelsRepository {
|
||||
private let api: PAPI
|
||||
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
|
||||
init(api: PAPI) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func getLabels() async throws -> [BookmarkLabel] {
|
||||
let dtos = try await api.getBookmarkLabels()
|
||||
try? await saveLabels(dtos)
|
||||
return dtos.map { $0.toDomain() }
|
||||
}
|
||||
|
||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws {
|
||||
for dto in dtos {
|
||||
if !tagExists(name: dto.name) {
|
||||
dto.toEntity(context: coreDataManager.context)
|
||||
}
|
||||
}
|
||||
try coreDataManager.context.save()
|
||||
}
|
||||
|
||||
private func tagExists(name: String) -> Bool {
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
|
||||
|
||||
return (try? coreDataManager.context.fetch(fetchRequest).isEmpty == false) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
135
readeck/Data/Repository/OfflineSyncManager.swift
Normal file
135
readeck/Data/Repository/OfflineSyncManager.swift
Normal file
@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
class OfflineSyncManager: ObservableObject {
|
||||
static let shared = OfflineSyncManager()
|
||||
|
||||
@Published var isSyncing = false
|
||||
@Published var syncStatus: String?
|
||||
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
private let api: PAPI
|
||||
|
||||
init(api: PAPI = API()) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
// MARK: - Sync Methods
|
||||
|
||||
func syncOfflineBookmarks() async {
|
||||
// First check if server is reachable
|
||||
guard await ServerConnectivity.isServerReachable() else {
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
syncStatus = "Server not reachable. Cannot sync."
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
self.syncStatus = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isSyncing = true
|
||||
syncStatus = "Syncing bookmarks with server..."
|
||||
}
|
||||
|
||||
let offlineBookmarks = getOfflineBookmarks()
|
||||
|
||||
guard !offlineBookmarks.isEmpty else {
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
syncStatus = "No bookmarks to sync"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.syncStatus = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var successCount = 0
|
||||
var failedCount = 0
|
||||
|
||||
for bookmark in offlineBookmarks {
|
||||
guard let url = bookmark.url else {
|
||||
failedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let tags = bookmark.tags?.components(separatedBy: ",").filter { !$0.isEmpty } ?? []
|
||||
let title = bookmark.title ?? ""
|
||||
|
||||
do {
|
||||
// Try to upload via API
|
||||
let dto = CreateBookmarkRequestDto(url: url, title: title, labels: tags.isEmpty ? nil : tags)
|
||||
_ = try await api.createBookmark(createRequest: dto)
|
||||
|
||||
// If successful, delete from offline storage
|
||||
deleteOfflineBookmark(bookmark)
|
||||
successCount += 1
|
||||
|
||||
await MainActor.run {
|
||||
syncStatus = "Synced \(successCount) bookmarks..."
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("Failed to sync bookmark: \(url) - \(error)")
|
||||
failedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
if failedCount == 0 {
|
||||
syncStatus = "✅ Successfully synced \(successCount) bookmarks"
|
||||
} else {
|
||||
syncStatus = "⚠️ Synced \(successCount), failed \(failedCount) bookmarks"
|
||||
}
|
||||
}
|
||||
|
||||
// Clear status after a few seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
self.syncStatus = nil
|
||||
}
|
||||
}
|
||||
|
||||
func getOfflineBookmarksCount() -> Int {
|
||||
return getOfflineBookmarks().count
|
||||
}
|
||||
|
||||
private func getOfflineBookmarks() -> [ArticleURLEntity] {
|
||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||
|
||||
do {
|
||||
return try coreDataManager.context.fetch(fetchRequest)
|
||||
} catch {
|
||||
print("Failed to fetch offline bookmarks: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
|
||||
coreDataManager.context.delete(entity)
|
||||
coreDataManager.save()
|
||||
}
|
||||
|
||||
// MARK: - Auto Sync on Server Connectivity Changes
|
||||
|
||||
func startAutoSync() {
|
||||
// Monitor server connectivity and auto-sync when server becomes reachable
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("ServerDidBecomeAvailable"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task {
|
||||
await self?.syncOfflineBookmarks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
92
readeck/Data/Utils/NetworkConnectivity.swift
Normal file
92
readeck/Data/Utils/NetworkConnectivity.swift
Normal file
@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
class ServerConnectivity: ObservableObject {
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue.global(qos: .background)
|
||||
|
||||
@Published var isServerReachable = false
|
||||
|
||||
static let shared = ServerConnectivity()
|
||||
|
||||
private init() {
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
if path.status == .satisfied {
|
||||
// Network is available, now check server
|
||||
Task {
|
||||
let serverReachable = await ServerConnectivity.isServerReachable()
|
||||
DispatchQueue.main.async {
|
||||
let wasReachable = self?.isServerReachable ?? false
|
||||
self?.isServerReachable = serverReachable
|
||||
|
||||
// Notify when server becomes available
|
||||
if !wasReachable && serverReachable {
|
||||
NotificationCenter.default.post(name: NSNotification.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
|
||||
}
|
||||
}
|
||||
@ -2,4 +2,5 @@ import Foundation
|
||||
|
||||
protocol PLabelsRepository {
|
||||
func getLabels() async throws -> [BookmarkLabel]
|
||||
}
|
||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
||||
}
|
||||
|
||||
250
readeck/Logger.swift
Normal file
250
readeck/Logger.swift
Normal file
@ -0,0 +1,250 @@
|
||||
//
|
||||
// Logger.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 16.08.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Log Configuration
|
||||
|
||||
enum LogLevel: Int, CaseIterable {
|
||||
case debug = 0
|
||||
case info = 1
|
||||
case notice = 2
|
||||
case warning = 3
|
||||
case error = 4
|
||||
case critical = 5
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .debug: return "🔍"
|
||||
case .info: return "ℹ️"
|
||||
case .notice: return "📢"
|
||||
case .warning: return "⚠️"
|
||||
case .error: return "❌"
|
||||
case .critical: return "💥"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LogCategory: String, CaseIterable {
|
||||
case network = "Network"
|
||||
case ui = "UI"
|
||||
case data = "Data"
|
||||
case auth = "Authentication"
|
||||
case performance = "Performance"
|
||||
case general = "General"
|
||||
case manual = "Manual"
|
||||
case viewModel = "ViewModel"
|
||||
}
|
||||
|
||||
class LogConfiguration: ObservableObject {
|
||||
static let shared = LogConfiguration()
|
||||
|
||||
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
|
||||
@Published var globalMinLevel: LogLevel = .debug
|
||||
@Published var showPerformanceLogs = true
|
||||
@Published var showTimestamps = true
|
||||
@Published var includeSourceLocation = true
|
||||
|
||||
private init() {
|
||||
loadConfiguration()
|
||||
}
|
||||
|
||||
func setLevel(_ level: LogLevel, for category: LogCategory) {
|
||||
categoryLevels[category] = level
|
||||
saveConfiguration()
|
||||
}
|
||||
|
||||
func getLevel(for category: LogCategory) -> LogLevel {
|
||||
return categoryLevels[category] ?? globalMinLevel
|
||||
}
|
||||
|
||||
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
||||
let categoryLevel = getLevel(for: category)
|
||||
return level.rawValue >= categoryLevel.rawValue
|
||||
}
|
||||
|
||||
private func loadConfiguration() {
|
||||
// Load from UserDefaults
|
||||
if let data = UserDefaults.standard.data(forKey: "LogConfiguration"),
|
||||
let config = try? JSONDecoder().decode([String: Int].self, from: data) {
|
||||
for (categoryString, levelInt) in config {
|
||||
if let category = LogCategory(rawValue: categoryString),
|
||||
let level = LogLevel(rawValue: levelInt) {
|
||||
categoryLevels[category] = level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug
|
||||
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
||||
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
||||
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
||||
}
|
||||
|
||||
private func saveConfiguration() {
|
||||
let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue }
|
||||
if let data = try? JSONEncoder().encode(config) {
|
||||
UserDefaults.standard.set(data, forKey: "LogConfiguration")
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(globalMinLevel.rawValue, forKey: "LogGlobalLevel")
|
||||
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
||||
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
||||
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
||||
}
|
||||
}
|
||||
|
||||
struct Logger {
|
||||
private let logger: os.Logger
|
||||
private let category: LogCategory
|
||||
private let config = LogConfiguration.shared
|
||||
|
||||
init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.romm.app", category: LogCategory) {
|
||||
self.logger = os.Logger(subsystem: subsystem, category: category.rawValue)
|
||||
self.category = category
|
||||
}
|
||||
|
||||
// MARK: - Log Levels
|
||||
|
||||
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
guard config.shouldLog(.debug, for: category) else { return }
|
||||
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
||||
logger.debug("\(formattedMessage)")
|
||||
}
|
||||
|
||||
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
guard config.shouldLog(.info, for: category) else { return }
|
||||
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
||||
logger.info("\(formattedMessage)")
|
||||
}
|
||||
|
||||
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
guard config.shouldLog(.notice, for: category) else { return }
|
||||
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
||||
logger.notice("\(formattedMessage)")
|
||||
}
|
||||
|
||||
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
guard config.shouldLog(.warning, for: category) else { return }
|
||||
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
||||
logger.warning("\(formattedMessage)")
|
||||
}
|
||||
|
||||
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
guard config.shouldLog(.error, for: category) else { return }
|
||||
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
||||
logger.error("\(formattedMessage)")
|
||||
}
|
||||
|
||||
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
guard config.shouldLog(.critical, for: category) else { return }
|
||||
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
||||
logger.critical("\(formattedMessage)")
|
||||
}
|
||||
|
||||
// MARK: - Convenience Methods
|
||||
|
||||
func logNetworkRequest(method: String, url: String, statusCode: Int? = nil) {
|
||||
guard config.shouldLog(.info, for: category) else { return }
|
||||
if let statusCode = statusCode {
|
||||
info("🌐 \(method) \(url) - Status: \(statusCode)")
|
||||
} else {
|
||||
info("🌐 \(method) \(url)")
|
||||
}
|
||||
}
|
||||
|
||||
func logNetworkError(method: String, url: String, error: Error) {
|
||||
guard config.shouldLog(.error, for: category) else { return }
|
||||
self.error("❌ \(method) \(url) - Error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func logPerformance(_ operation: String, duration: TimeInterval) {
|
||||
guard config.showPerformanceLogs && config.shouldLog(.info, for: category) else { return }
|
||||
info("⏱️ \(operation) completed in \(String(format: "%.3f", duration))s")
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func formatMessage(_ message: String, level: LogLevel, file: String, function: String, line: Int) -> String {
|
||||
var components: [String] = []
|
||||
|
||||
if config.showTimestamps {
|
||||
let timestamp = DateFormatter.logTimestamp.string(from: Date())
|
||||
components.append(timestamp)
|
||||
}
|
||||
|
||||
components.append(level.emoji)
|
||||
components.append("[\(category.rawValue)]")
|
||||
|
||||
if config.includeSourceLocation {
|
||||
components.append("[\(sourceFileName(filePath: file)):\(line)]")
|
||||
components.append(function)
|
||||
}
|
||||
|
||||
components.append("-")
|
||||
components.append(message)
|
||||
|
||||
return components.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func sourceFileName(filePath: String) -> String {
|
||||
return URL(fileURLWithPath: filePath).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category-specific Loggers
|
||||
|
||||
extension Logger {
|
||||
static let network = Logger(category: .network)
|
||||
static let ui = Logger(category: .ui)
|
||||
static let data = Logger(category: .data)
|
||||
static let auth = Logger(category: .auth)
|
||||
static let performance = Logger(category: .performance)
|
||||
static let general = Logger(category: .general)
|
||||
static let manual = Logger(category: .manual)
|
||||
static let viewModel = Logger(category: .viewModel)
|
||||
}
|
||||
|
||||
// MARK: - Performance Measurement Helper
|
||||
|
||||
struct PerformanceMeasurement {
|
||||
private let startTime = CFAbsoluteTimeGetCurrent()
|
||||
private let operation: String
|
||||
private let logger: Logger
|
||||
|
||||
init(operation: String, logger: Logger = .performance) {
|
||||
self.operation = operation
|
||||
self.logger = logger
|
||||
logger.debug("🚀 Starting \(operation)")
|
||||
}
|
||||
|
||||
func end() {
|
||||
let duration = CFAbsoluteTimeGetCurrent() - startTime
|
||||
logger.logPerformance(operation, duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateFormatter Extension
|
||||
|
||||
extension DateFormatter {
|
||||
static let logTimestamp: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Dictionary Extension
|
||||
|
||||
extension Dictionary {
|
||||
func mapKeys<T>(_ transform: (Key) throws -> T) rethrows -> [T: Value] {
|
||||
return try Dictionary<T, Value>(uniqueKeysWithValues: map { (try transform($0.key), $0.value) })
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ struct BookmarksView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
|
||||
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
|
||||
105
readeck/UI/Components/LocalBookmarksSyncView.swift
Normal file
105
readeck/UI/Components/LocalBookmarksSyncView.swift
Normal file
@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LocalBookmarksSyncView: View {
|
||||
@StateObject private var syncManager = OfflineSyncManager.shared
|
||||
@StateObject private var serverConnectivity = ServerConnectivity.shared
|
||||
@State private var showSuccessMessage = false
|
||||
@State private var syncedBookmarkCount = 0
|
||||
|
||||
let localBookmarkCount: Int
|
||||
|
||||
init(bookmarkCount: Int) {
|
||||
self.localBookmarkCount = bookmarkCount
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if showSuccessMessage {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.imageScale(.small)
|
||||
|
||||
Text("\(syncedBookmarkCount) bookmark\(syncedBookmarkCount == 1 ? "" : "s") synced successfully")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.green)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
withAnimation {
|
||||
showSuccessMessage = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if localBookmarkCount > 0 || syncManager.isSyncing {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: syncManager.isSyncing ? "arrow.triangle.2.circlepath" : "externaldrive.badge.wifi")
|
||||
.foregroundColor(syncManager.isSyncing ? .blue : .blue)
|
||||
.imageScale(.medium)
|
||||
|
||||
if syncManager.isSyncing {
|
||||
Text("Syncing with server...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Text("\(localBookmarkCount) bookmark\(localBookmarkCount == 1 ? "" : "s") waiting for sync")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !syncManager.isSyncing && localBookmarkCount > 0 {
|
||||
Button {
|
||||
syncedBookmarkCount = localBookmarkCount // Store count before sync
|
||||
Task {
|
||||
await syncManager.syncOfflineBookmarks()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let status = syncManager.syncStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal)
|
||||
.animation(.easeInOut, value: syncManager.isSyncing)
|
||||
.animation(.easeInOut, value: syncManager.syncStatus)
|
||||
}
|
||||
}
|
||||
.onChange(of: syncManager.isSyncing) { _ in
|
||||
if !syncManager.isSyncing {
|
||||
// Show success message if all bookmarks are synced
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let currentCount = syncManager.getOfflineBookmarksCount()
|
||||
if currentCount == 0 {
|
||||
withAnimation {
|
||||
showSuccessMessage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,59 +13,119 @@ struct PhoneTabView: View {
|
||||
|
||||
@State private var selectedMoreTab: SidebarTab? = nil
|
||||
@State private var selectedTabIndex: Int = 1
|
||||
@StateObject private var syncManager = OfflineSyncManager.shared
|
||||
@State private var phoneTabLocalBookmarkCount = 0
|
||||
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
GlobalPlayerContainerView {
|
||||
TabView(selection: $selectedTabIndex) {
|
||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||
NavigationStack {
|
||||
tabView(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(idx)
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
List(moreTabs, id: \.self) { tab in
|
||||
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
.navigationTitle("More")
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
|
||||
if appSettings.enableTTS {
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
.tag(mainTabs.count)
|
||||
.onAppear {
|
||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||
selectedMoreTab = nil
|
||||
mainTabsContent
|
||||
moreTabContent
|
||||
}
|
||||
.accentColor(.accentColor)
|
||||
.onAppear {
|
||||
updateLocalBookmarkCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
updateLocalBookmarkCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
updateLocalBookmarkCount()
|
||||
}
|
||||
.onChange(of: syncManager.isSyncing) {
|
||||
if !syncManager.isSyncing {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
updateLocalBookmarkCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
.accentColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLocalBookmarkCount() {
|
||||
let count = syncManager.getOfflineBookmarksCount()
|
||||
DispatchQueue.main.async {
|
||||
self.phoneTabLocalBookmarkCount = count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Tab Content
|
||||
|
||||
@ViewBuilder
|
||||
private var mainTabsContent: some View {
|
||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||
NavigationStack {
|
||||
tabView(for: tab)
|
||||
}
|
||||
.tabItem {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.tag(idx)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabContent: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
moreTabsList
|
||||
moreTabsFooter
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
.badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0)
|
||||
.tag(mainTabs.count)
|
||||
.onAppear {
|
||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabsList: some View {
|
||||
List {
|
||||
ForEach(moreTabs, id: \.self) { tab in
|
||||
NavigationLink {
|
||||
tabView(for: tab)
|
||||
.navigationTitle(tab.label)
|
||||
.onDisappear {
|
||||
// tags and search handle navigation by own
|
||||
if tab != .tags && tab != .search {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(tab.label, systemImage: tab.systemImage)
|
||||
}
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
|
||||
if phoneTabLocalBookmarkCount > 0 {
|
||||
Section {
|
||||
VStack {
|
||||
LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("More")
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var moreTabsFooter: some View {
|
||||
if appSettings.enableTTS {
|
||||
PlayerQueueResumeButton()
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,8 @@ struct readeckApp: App {
|
||||
#if DEBUG
|
||||
NFX.sharedInstance().start()
|
||||
#endif
|
||||
// Initialize server connectivity monitoring
|
||||
_ = ServerConnectivity.shared
|
||||
Task {
|
||||
await loadSetupStatus()
|
||||
}
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.readeck.app</string>
|
||||
</array>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
|
||||
@ -1,5 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="tags" optional="YES" attributeType="String"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<attribute name="url" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="BookmarkEntity" representedClassName="BookmarkEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="authors" optional="YES" attributeType="String"/>
|
||||
<attribute name="created" optional="YES" attributeType="String"/>
|
||||
<attribute name="desc" optional="YES" attributeType="String"/>
|
||||
<attribute name="documentType" optional="YES" attributeType="String"/>
|
||||
<attribute name="hasArticle" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="hasDeleted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="href" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="isArchived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="isMarked" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="lang" optional="YES" attributeType="String"/>
|
||||
<attribute name="loaded" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="published" optional="YES" attributeType="String"/>
|
||||
<attribute name="readingTime" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="readProgress" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="site" optional="YES" attributeType="String"/>
|
||||
<attribute name="siteName" optional="YES" attributeType="String"/>
|
||||
<attribute name="state" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="textDirection" optional="YES" attributeType="String"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<attribute name="type" optional="YES" attributeType="String"/>
|
||||
<attribute name="update" optional="YES" attributeType="String"/>
|
||||
<attribute name="url" optional="YES" attributeType="String"/>
|
||||
<attribute name="wordCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="resources" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="BookmarkResourcesEntity"/>
|
||||
</entity>
|
||||
<entity name="BookmarkResourcesEntity" representedClassName="BookmarkResourcesEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<relationship name="article" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ResourceEntity"/>
|
||||
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ImageResourceEntity"/>
|
||||
<relationship name="image" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ImageResourceEntity"/>
|
||||
<relationship name="log" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ResourceEntity"/>
|
||||
<relationship name="props" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ResourceEntity"/>
|
||||
<relationship name="thumbnail" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ImageResourceEntity"/>
|
||||
</entity>
|
||||
<entity name="ImageResourceEntity" representedClassName="ImageResourceEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="height" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="src" optional="YES" attributeType="String"/>
|
||||
<attribute name="width" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
</entity>
|
||||
<entity name="ResourceEntity" representedClassName="ResourceEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="src" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||
@ -10,4 +60,7 @@
|
||||
<attribute name="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
</model>
|
||||
Loading…
x
Reference in New Issue
Block a user