Compare commits
2 Commits
b71fc0a4e0
...
ef13faeff7
| Author | SHA1 | Date | |
|---|---|---|---|
| ef13faeff7 | |||
| f20f86a41a |
@ -25,6 +25,26 @@
|
||||
},
|
||||
"%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" : {
|
||||
|
||||
@ -302,6 +322,9 @@
|
||||
},
|
||||
"Server Endpoint" : {
|
||||
|
||||
},
|
||||
"Server not reachable - saving locally" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
|
||||
@ -317,6 +340,9 @@
|
||||
},
|
||||
"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 = 14;
|
||||
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 = 14;
|
||||
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 = 14;
|
||||
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 = 14;
|
||||
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