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:
Ilyas Hallak 2025-08-16 22:32:20 +02:00
parent f20f86a41a
commit ef13faeff7
21 changed files with 1284 additions and 65 deletions

View File

@ -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" : {

View 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 []
}
}
}

View File

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

View File

@ -15,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,

View File

@ -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, "")
}
}
}
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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)")

View 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 {
}
*/

View 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
}
}

View File

@ -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
}
}

View 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)
}
}

View File

@ -0,0 +1,92 @@
import Foundation
import Network
class ServerConnectivity: ObservableObject {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue.global(qos: .background)
@Published var isServerReachable = false
static let shared = ServerConnectivity()
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
if path.status == .satisfied {
// Network is available, now check server
Task {
let serverReachable = await ServerConnectivity.isServerReachable()
DispatchQueue.main.async {
let wasReachable = self?.isServerReachable ?? false
self?.isServerReachable = serverReachable
// Notify when server becomes available
if !wasReachable && serverReachable {
NotificationCenter.default.post(name: 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
}
}

View File

@ -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
View 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) })
}
}

View File

@ -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()

View 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
}
}
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -29,6 +29,8 @@ struct readeckApp: App {
#if DEBUG
NFX.sharedInstance().start()
#endif
// Initialize server connectivity monitoring
_ = ServerConnectivity.shared
Task {
await loadSetupStatus()
}

View File

@ -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>

View File

@ -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>