Compare commits

..

No commits in common. "ef13faeff75cbc164c245d3f68a66f4a68ed28fd" and "b71fc0a4e07eca5625431b726bff37e1aff5b086" have entirely different histories.

21 changed files with 66 additions and 1252 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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