Compare commits
No commits in common. "ef13faeff75cbc164c245d3f68a66f4a68ed28fd" and "b71fc0a4e07eca5625431b726bff37e1aff5b086" have entirely different histories.
ef13faeff7
...
b71fc0a4e0
@ -25,26 +25,6 @@
|
|||||||
},
|
},
|
||||||
"%lld articles in the queue" : {
|
"%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 min" : {
|
||||||
|
|
||||||
@ -322,9 +302,6 @@
|
|||||||
},
|
},
|
||||||
"Server Endpoint" : {
|
"Server Endpoint" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Server not reachable - saving locally" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Settings" : {
|
"Settings" : {
|
||||||
|
|
||||||
@ -340,9 +317,6 @@
|
|||||||
},
|
},
|
||||||
"Sync Settings" : {
|
"Sync Settings" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Syncing with server..." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Theme" : {
|
"Theme" : {
|
||||||
|
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
class ServerConnectivity: ObservableObject {
|
|
||||||
@Published var isServerReachable = false
|
|
||||||
|
|
||||||
static let shared = ServerConnectivity()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// Check if the Readeck server endpoint is reachable
|
|
||||||
static func isServerReachable() async -> Bool {
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
|
||||||
!endpoint.isEmpty,
|
|
||||||
let url = URL(string: endpoint + "/api/health") else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "GET"
|
|
||||||
request.timeoutInterval = 5.0 // 5 second timeout
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
return httpResponse.statusCode == 200
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Server connectivity check failed: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alternative check using ping-style endpoint
|
|
||||||
static func isServerReachableSync() -> Bool {
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(),
|
|
||||||
!endpoint.isEmpty,
|
|
||||||
let url = URL(string: endpoint) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
|
||||||
var isReachable = false
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "HEAD" // Just check if server responds
|
|
||||||
request.timeoutInterval = 3.0
|
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request) { _, response, error in
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error
|
|
||||||
}
|
|
||||||
semaphore.signal()
|
|
||||||
}
|
|
||||||
|
|
||||||
task.resume()
|
|
||||||
_ = semaphore.wait(timeout: .now() + 3.0)
|
|
||||||
|
|
||||||
return isReachable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,7 +15,6 @@ struct ShareBookmarkView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
logoSection
|
logoSection
|
||||||
serverStatusSection
|
|
||||||
urlSection
|
urlSection
|
||||||
tagManagementSection
|
tagManagementSection
|
||||||
.id(AddBookmarkFieldFocus.labels)
|
.id(AddBookmarkFieldFocus.labels)
|
||||||
@ -71,26 +70,6 @@ struct ShareBookmarkView: View {
|
|||||||
.opacity(0.9)
|
.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
|
@ViewBuilder
|
||||||
private var urlSection: some View {
|
private var urlSection: some View {
|
||||||
if let url = viewModel.url {
|
if let url = viewModel.url {
|
||||||
@ -134,7 +113,7 @@ struct ShareBookmarkView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var tagManagementSection: some View {
|
private var tagManagementSection: some View {
|
||||||
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
|
if !viewModel.labels.isEmpty {
|
||||||
TagManagementView(
|
TagManagementView(
|
||||||
allLabels: convertToBookmarkLabels(viewModel.labels),
|
allLabels: convertToBookmarkLabels(viewModel.labels),
|
||||||
selectedLabels: viewModel.selectedLabels,
|
selectedLabels: viewModel.selectedLabels,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import CoreData
|
|
||||||
|
|
||||||
class ShareBookmarkViewModel: ObservableObject {
|
class ShareBookmarkViewModel: ObservableObject {
|
||||||
@Published var url: String?
|
@Published var url: String?
|
||||||
@ -11,7 +10,6 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
||||||
@Published var isSaving: Bool = false
|
@Published var isSaving: Bool = false
|
||||||
@Published var searchText: String = ""
|
@Published var searchText: String = ""
|
||||||
@Published var isServerReachable: Bool = true
|
|
||||||
let extensionContext: NSExtensionContext?
|
let extensionContext: NSExtensionContext?
|
||||||
|
|
||||||
// Computed properties for pagination
|
// Computed properties for pagination
|
||||||
@ -29,7 +27,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var availableLabelPages: [[BookmarkLabelDto]] {
|
var availableLabelPages: [[BookmarkLabelDto]] {
|
||||||
let pageSize = 12 // Extension can't access Constants.Labels.pageSize
|
let pageSize = Constants.Labels.pageSize
|
||||||
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
||||||
|
|
||||||
if labelsToShow.count <= pageSize {
|
if labelsToShow.count <= pageSize {
|
||||||
@ -47,14 +45,9 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
checkServerReachability()
|
|
||||||
loadLabels()
|
loadLabels()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkServerReachability() {
|
|
||||||
isServerReachable = ServerConnectivity.isServerReachableSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func extractSharedContent() {
|
private func extractSharedContent() {
|
||||||
guard let extensionContext = extensionContext else { return }
|
guard let extensionContext = extensionContext else { return }
|
||||||
for item in extensionContext.inputItems {
|
for item in extensionContext.inputItems {
|
||||||
@ -84,30 +77,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
|
|
||||||
func loadLabels() {
|
func loadLabels() {
|
||||||
Task {
|
Task {
|
||||||
// Check if server is reachable
|
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
||||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
print("DEBUG: Server reachable: \(serverReachable)")
|
} ?? []
|
||||||
|
let sorted = loaded.sorted { $0.count > $1.count }
|
||||||
if serverReachable {
|
await MainActor.run {
|
||||||
// Load from API
|
self.labels = Array(sorted)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,40 +93,16 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isSaving = true
|
isSaving = true
|
||||||
|
Task {
|
||||||
// Check server connectivity
|
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
||||||
if ServerConnectivity.isServerReachableSync() {
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
// Online - try to save via API
|
self?.isSaving = false
|
||||||
Task {
|
if !error {
|
||||||
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
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,10 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.readeck.app</string>
|
|
||||||
</array>
|
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck</string>
|
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck</string>
|
||||||
|
|||||||
@ -86,7 +86,6 @@
|
|||||||
Data/KeychainHelper.swift,
|
Data/KeychainHelper.swift,
|
||||||
Domain/Model/Bookmark.swift,
|
Domain/Model/Bookmark.swift,
|
||||||
Domain/Model/BookmarkLabel.swift,
|
Domain/Model/BookmarkLabel.swift,
|
||||||
Logger.swift,
|
|
||||||
readeck.xcdatamodeld,
|
readeck.xcdatamodeld,
|
||||||
Splash.storyboard,
|
Splash.storyboard,
|
||||||
UI/Components/Constants.swift,
|
UI/Components/Constants.swift,
|
||||||
@ -436,7 +435,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -469,7 +468,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -624,7 +623,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -668,7 +667,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
|||||||
@ -8,15 +8,6 @@ class CoreDataManager {
|
|||||||
|
|
||||||
lazy var persistentContainer: NSPersistentContainer = {
|
lazy var persistentContainer: NSPersistentContainer = {
|
||||||
let container = NSPersistentContainer(name: "readeck")
|
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
|
container.loadPersistentStores { _, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
fatalError("Core Data error: \(error)")
|
fatalError("Core Data error: \(error)")
|
||||||
|
|||||||
@ -1,213 +0,0 @@
|
|||||||
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 {
|
|
||||||
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
//
|
|
||||||
// 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,34 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
|
||||||
|
|
||||||
class LabelsRepository: PLabelsRepository {
|
class LabelsRepository: PLabelsRepository {
|
||||||
private let api: PAPI
|
private let api: PAPI
|
||||||
|
|
||||||
private let coreDataManager = CoreDataManager.shared
|
|
||||||
|
|
||||||
init(api: PAPI) {
|
init(api: PAPI) {
|
||||||
self.api = api
|
self.api = api
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLabels() async throws -> [BookmarkLabel] {
|
func getLabels() async throws -> [BookmarkLabel] {
|
||||||
let dtos = try await api.getBookmarkLabels()
|
let dtos = try await api.getBookmarkLabels()
|
||||||
try? await saveLabels(dtos)
|
|
||||||
return dtos.map { $0.toDomain() }
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
class ServerConnectivity: ObservableObject {
|
|
||||||
private let monitor = NWPathMonitor()
|
|
||||||
private let queue = DispatchQueue.global(qos: .background)
|
|
||||||
|
|
||||||
@Published var isServerReachable = false
|
|
||||||
|
|
||||||
static let shared = ServerConnectivity()
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
startMonitoring()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startMonitoring() {
|
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
|
||||||
if path.status == .satisfied {
|
|
||||||
// Network is available, now check server
|
|
||||||
Task {
|
|
||||||
let serverReachable = await ServerConnectivity.isServerReachable()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let wasReachable = self?.isServerReachable ?? false
|
|
||||||
self?.isServerReachable = serverReachable
|
|
||||||
|
|
||||||
// Notify when server becomes available
|
|
||||||
if !wasReachable && serverReachable {
|
|
||||||
NotificationCenter.default.post(name: 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,5 +2,4 @@ import Foundation
|
|||||||
|
|
||||||
protocol PLabelsRepository {
|
protocol PLabelsRepository {
|
||||||
func getLabels() async throws -> [BookmarkLabel]
|
func getLabels() async throws -> [BookmarkLabel]
|
||||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
}
|
||||||
}
|
|
||||||
|
|||||||
@ -1,250 +0,0 @@
|
|||||||
//
|
|
||||||
// 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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
|
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
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,119 +13,59 @@ struct PhoneTabView: View {
|
|||||||
|
|
||||||
@State private var selectedMoreTab: SidebarTab? = nil
|
@State private var selectedMoreTab: SidebarTab? = nil
|
||||||
@State private var selectedTabIndex: Int = 1
|
@State private var selectedTabIndex: Int = 1
|
||||||
@StateObject private var syncManager = OfflineSyncManager.shared
|
|
||||||
@State private var phoneTabLocalBookmarkCount = 0
|
|
||||||
|
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GlobalPlayerContainerView {
|
GlobalPlayerContainerView {
|
||||||
TabView(selection: $selectedTabIndex) {
|
TabView(selection: $selectedTabIndex) {
|
||||||
mainTabsContent
|
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||||
moreTabContent
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.accentColor(.accentColor)
|
.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,8 +29,6 @@ struct readeckApp: App {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
NFX.sharedInstance().start()
|
NFX.sharedInstance().start()
|
||||||
#endif
|
#endif
|
||||||
// Initialize server connectivity monitoring
|
|
||||||
_ = ServerConnectivity.shared
|
|
||||||
Task {
|
Task {
|
||||||
await loadSetupStatus()
|
await loadSetupStatus()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,6 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<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>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
|
|||||||
@ -1,55 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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="">
|
<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">
|
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||||
@ -60,7 +10,4 @@
|
|||||||
<attribute name="token" optional="YES" attributeType="String"/>
|
<attribute name="token" optional="YES" attributeType="String"/>
|
||||||
<attribute name="username" optional="YES" attributeType="String"/>
|
<attribute name="username" optional="YES" attributeType="String"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
|
|
||||||
<attribute name="name" optional="YES" attributeType="String"/>
|
|
||||||
</entity>
|
|
||||||
</model>
|
</model>
|
||||||
Loading…
x
Reference in New Issue
Block a user