This commit is contained in:
Ilyas Hallak 2025-06-25 21:44:55 +02:00
parent ad4483aa63
commit 2d3be2430f
20 changed files with 873 additions and 65 deletions

View File

@ -174,7 +174,10 @@
},
"subpath" : "Yams"
}
],
"prebuilts" : [
]
},
"version" : 6
"version" : 7
}

View File

@ -76,9 +76,7 @@
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Data/CoreData/CoreDataManager.swift,
Domain/Model/Bookmark.swift,
readeck.xcdatamodeld,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D2B7FAE2DFA27A400EBDB2B"
BuildableName = "URLShare.appex"
BlueprintName = "URLShare"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5D45F9C72DF858680048D5B8"
BuildableName = "readeck.app"
BlueprintName = "readeck"
ReferencedContainer = "container:readeck.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -12,12 +12,25 @@
<key>URLShare.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
<key>readeck.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>5D2B7FAE2DFA27A400EBDB2B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>5D45F9C72DF858680048D5B8</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>

View File

@ -10,7 +10,7 @@ import Foundation
protocol PAPI {
var tokenProvider: TokenProvider { get }
func login(username: String, password: String) async throws -> UserDto
func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto]
func getBookmarks(state: BookmarkState?, updatedSince: Date?, limit: Int?, offset: Int?) async throws -> [BookmarkDto]
func getBookmark(id: String) async throws -> BookmarkDetailDto
func getBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
@ -131,21 +131,46 @@ class API: PAPI {
return userDto
}
func getBookmarks(state: BookmarkState? = nil) async throws -> [BookmarkDto] {
func getBookmarks(state: BookmarkState? = nil, updatedSince: Date? = nil, limit: Int? = nil, offset: Int? = nil) async throws -> [BookmarkDto] {
var endpoint = "/api/bookmarks"
var queryParams: [String] = []
// Query-Parameter basierend auf State hinzufügen
if let state = state {
switch state {
case .unread:
endpoint += "?is_archived=false&is_marked=false"
queryParams.append("is_archived=false")
queryParams.append("is_marked=false")
case .favorite:
endpoint += "?is_marked=true"
queryParams.append("is_marked=true")
case .archived:
endpoint += "?is_archived=true"
queryParams.append("is_archived=true")
}
}
if let updatedSince = updatedSince {
let dateFormatter = ISO8601DateFormatter()
let updatedSinceString = dateFormatter.string(from: updatedSince)
queryParams.append("updated_since=\(updatedSinceString)")
}
/*
// Limit Parameter
if let limit = limit {
queryParams.append("limit=\(limit)")
}
// Offset Parameter
if let offset = offset {
queryParams.append("offset=\(offset)")
}
// Query-String zusammenbauen
if !queryParams.isEmpty {
endpoint += "?" + queryParams.joined(separator: "&")
}
*/
return try await makeJSONRequest(
endpoint: endpoint,
responseType: [BookmarkDto].self

View File

@ -62,5 +62,3 @@ struct ImageResourceDto: Codable {
let height: Int
let width: Int
}

View File

@ -0,0 +1,213 @@
import Foundation
import CoreData
// MARK: - DTO -> Entity
extension BookmarkDto {
func toEntity(context: NSManagedObjectContext) -> BookmarkEntity {
let entity = BookmarkEntity(context: context)
entity.title = self.title
entity.url = self.url
entity.authors = self.authors.first
entity.desc = self.description
entity.created = self.created
entity.siteName = self.siteName
entity.site = self.site
entity.authors = self.authors.first // TODO: support multiple authors
entity.published = self.published
entity.created = self.created
entity.update = self.updated
entity.readingTime = Int16(self.readingTime ?? 0)
entity.readProgress = Int16(self.readProgress)
entity.wordCount = Int64(self.wordCount ?? 0)
entity.isArchived = self.isArchived
entity.isMarked = self.isMarked
entity.hasArticle = self.hasArticle
entity.loaded = self.loaded
entity.hasDeleted = self.isDeleted
entity.documentType = self.documentType
entity.href = self.href
entity.lang = self.lang
entity.textDirection = self.textDirection
entity.type = self.type
entity.state = Int16(self.state)
// entity.resources = self.resources.toEntity(context: context)
return entity
}
}
extension BookmarkResourcesDto {
func toEntity(context: NSManagedObjectContext) -> BookmarkResourcesEntity {
let entity = BookmarkResourcesEntity(context: context)
entity.article = self.article?.toEntity(context: context)
entity.icon = self.icon?.toEntity(context: context)
entity.image = self.image?.toEntity(context: context)
entity.log = self.log?.toEntity(context: context)
entity.props = self.props?.toEntity(context: context)
entity.thumbnail = self.thumbnail?.toEntity(context: context)
return entity
}
}
extension ImageResourceDto {
func toEntity(context: NSManagedObjectContext) -> ImageResourceEntity {
let entity = ImageResourceEntity(context: context)
entity.src = self.src
entity.width = Int64(self.width)
entity.height = Int64(self.height)
return entity
}
}
extension ResourceDto {
func toEntity(context: NSManagedObjectContext) -> ResourceEntity {
let entity = ResourceEntity(context: context)
entity.src = self.src
return entity
}
}
// ------------------------------------------------
// MARK: - BookmarkEntity to Domain Mapping
extension BookmarkEntity {
}
// MARK: - Domain to BookmarkEntity Mapping
extension Bookmark {
func toEntity(context: NSManagedObjectContext) -> BookmarkEntity {
let entity = BookmarkEntity(context: context)
entity.populateFrom(bookmark: self)
return entity
}
func updateEntity(_ entity: BookmarkEntity) {
entity.populateFrom(bookmark: self)
}
}
extension Resource {
func toEntity(context: NSManagedObjectContext) -> ResourceEntity {
let entity = ResourceEntity(context: context)
entity.populateFrom(resource: self)
return entity
}
}
// MARK: - Private Helper Methods
private extension BookmarkEntity {
func populateFrom(bookmark: Bookmark) {
self.id = bookmark.id
self.title = bookmark.title
self.url = bookmark.url
self.desc = bookmark.description
self.siteName = bookmark.siteName
self.site = bookmark.site
self.authors = bookmark.authors.first // TODO: support multiple authors
self.published = bookmark.published
self.created = bookmark.created
self.update = bookmark.updated
self.readingTime = Int16(bookmark.readingTime ?? 0)
self.readProgress = Int16(bookmark.readProgress)
self.wordCount = Int64(bookmark.wordCount ?? 0)
self.isArchived = bookmark.isArchived
self.isMarked = bookmark.isMarked
self.hasArticle = bookmark.hasArticle
self.loaded = bookmark.loaded
self.hasDeleted = bookmark.isDeleted
self.documentType = bookmark.documentType
self.href = bookmark.href
self.lang = bookmark.lang
self.textDirection = bookmark.textDirection
self.type = bookmark.type
self.state = Int16(bookmark.state)
}
}
// MARK: - BookmarkState Mapping
private extension BookmarkState {
static func fromRawValue(_ value: Int) -> BookmarkState {
switch value {
case 0: return .unread
case 1: return .favorite
case 2: return .archived
default: return .unread
}
}
}
private extension BookmarkResourcesEntity {
func populateFrom(bookmarkResources: BookmarkResources) {
}
}
private extension ImageResourceEntity {
func populateFrom(imageResource: ImageResource) {
self.src = imageResource.src
self.height = Int64(imageResource.height)
self.width = Int64(imageResource.width)
}
}
private extension ResourceEntity {
func populateFrom(resource: Resource) {
self.src = resource.src
}
}
// MARK: - Date Conversion Helpers
private extension String {
func toDate() -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: self) ??
ISO8601DateFormatter().date(from: self)
}
}
private extension Date {
func toISOString() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: self)
}
}
// MARK: - Array Mapping Extensions
extension Array where Element == BookmarkEntity {
func toDomain() -> [Bookmark] {
return [] // self.map { $0.toDomain() }
}
}
extension Array where Element == Bookmark {
func toEntities(context: NSManagedObjectContext) -> [BookmarkEntity] {
return self.map { $0.toEntity(context: context) }
}
}
/*
extension BookmarkEntity {
func toDomain() -> Bookmark {
return Bookmark(id: id ?? "", title: title ?? "", url: url!, href: href ?? "", description: description, authors: [authors ?? ""], created: created ?? "", published: published, updated: update!, siteName: siteName ?? "", site: site!, readingTime: Int(readingTime), wordCount: Int(wordCount), hasArticle: hasArticle, isArchived: isArchived, isDeleted: isDeleted, isMarked: isMarked, labels: [], lang: lang, loaded: loaded, readProgress: Int(readProgress), documentType: documentType ?? "", state: Int(state), textDirection: textDirection ?? "", type: type ?? "", resources: resources.toDomain())
)
}
}
extension BookmarkResourcesEntity {
func toDomain() -> BookmarkResources {
return BookmarkResources(article: ar, icon: <#T##ImageResource?#>, image: <#T##ImageResource?#>, log: <#T##Resource?#>, props: <#T##Resource?#>, thumbnail: <#T##ImageResource?#>
}
}
extension ImageResourceEntity {
}
*/

View File

@ -0,0 +1,103 @@
import Foundation
import CoreData
class BookmarkSyncRepository {
private let userDefaults = UserDefaults.standard
private let lastSyncTimestampKey = "lastBookmarkSyncTimestamp"
private var api: PAPI
private let coreDataManager = CoreDataManager.shared
init(api: PAPI) {
self.api = api
}
func resetBookmarks() async throws {
let context = coreDataManager.context
try await context.perform {
// Fetch Request für alle BookmarkEntity Objekte
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = BookmarkEntity.fetchRequest()
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
// Batch Delete ausführen
try context.execute(deleteRequest)
// Context speichern
try context.save()
// UserDefaults zurücksetzen
self.userDefaults.removeObject(forKey: self.lastSyncTimestampKey)
print("Alle Bookmarks wurden erfolgreich gelöscht")
} catch {
print("Fehler beim Löschen der Bookmarks: \(error)")
throw error
}
}
}
func syncBookmarks() async throws {
try? await resetBookmarks()
// Letzten Sync-Timestamp aus UserDefaults abrufen
let lastSyncTimestamp = userDefaults.double(forKey: lastSyncTimestampKey)
let updatedSince = lastSyncTimestamp > 0 ? Date(timeIntervalSince1970: lastSyncTimestamp) : nil
// Bookmarks vom Server abrufen
let bookmarks = try await fetchBookmarks(updatedSince: updatedSince)
// Batch-Insert durchführen
try await batchInsertBookmarks(bookmarks)
// Aktuellen Timestamp speichern
userDefaults.set(Date().timeIntervalSince1970, forKey: lastSyncTimestampKey)
}
private func fetchBookmarks(updatedSince: Date?) async throws -> [Bookmark] {
let bookmarks = try await api.getBookmarks(state: .unread, updatedSince: updatedSince, limit: nil, offset: nil)
return bookmarks.map {
$0.toDomain()
}
}
private func batchInsertBookmarks(_ bookmarks: [Bookmark]) async throws {
let context = CoreDataManager.shared.context
// Existierende URLs aus Core Data abrufen
let existingURLs = try await getExistingBookmarkURLs()
// Nur neue Bookmarks filtern
let newBookmarks = bookmarks.filter { !existingURLs.contains($0.url) }
// Batch-Insert
await context.perform {
for bookmark in newBookmarks {
newBookmarks.forEach {
_ = $0.toEntity(context: context)
}
}
do {
try context.save()
} catch {
print("Fehler beim Speichern der Bookmarks: \(error)")
}
}
}
private func getExistingBookmarkURLs() async throws -> Set<String> {
let context = CoreDataManager.shared.context
let request: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
request.propertiesToFetch = ["url"]
request.resultType = .dictionaryResultType
return try await context.perform {
let results = try context.fetch(request) as! [[String: Any]]
return Set(results.compactMap { $0["url"] as? String })
}
}
}

View File

@ -1,23 +1,32 @@
import Foundation
import CoreData
protocol PBookmarksRepository {
func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark]
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?) async throws -> [Bookmark]
func fetchBookmark(id: String) async throws -> BookmarkDetail
func fetchBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
func deleteBookmark(id: String) async throws
func syncBookmarks() async throws
}
class BookmarksRepository: PBookmarksRepository {
private var api: PAPI
private let coreDataManager = CoreDataManager.shared
init(api: PAPI) {
self.api = api
}
func fetchBookmarks(state: BookmarkState? = nil) async throws -> [Bookmark] {
let bookmarkDtos = try await api.getBookmarks(state: state)
func syncBookmarks() async throws {
}
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil) async throws -> [Bookmark] {
let bookmarkDtos = try await api.getBookmarks(state: state, updatedSince: nil, limit: limit, offset: offset)
return bookmarkDtos.map { $0.toDomain() }
}

View File

@ -7,8 +7,8 @@ class GetBookmarksUseCase {
self.repository = repository
}
func execute(state: BookmarkState? = nil) async throws -> [Bookmark] {
let allBookmarks = try await repository.fetchBookmarks(state: state)
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil) async throws -> [Bookmark] {
let allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset)
// Fallback-Filterung auf Client-Seite falls API keine Query-Parameter unterstützt
if let state = state {

View File

@ -0,0 +1,19 @@
//
// SyncBookmarksUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 15.06.25.
//
class SyncBookmarksUseCase {
let bookmarkRepository: BookmarkSyncRepository
init(bookmarkRepository: BookmarkSyncRepository) {
self.bookmarkRepository = bookmarkRepository
}
func execute() async throws {
try await self.bookmarkRepository.syncBookmarks()
}
}

View File

@ -1,6 +1,199 @@
import SwiftUI
import SafariServices
struct BookmarkEntityCardView: View {
let bookmark: BookmarkEntity
let currentState: BookmarkState
let onArchive: (BookmarkEntity) -> Void
let onDelete: (BookmarkEntity) -> Void
let onToggleFavorite: (BookmarkEntity) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Vorschaubild - verwende image oder thumbnail
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
// Titel
Text(bookmark.title ?? "")
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Meta-Info mit Datum
VStack(alignment: .leading, spacing: 4) {
HStack {
// Veröffentlichungsdatum
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
Spacer() // show spacer only if we have the published Date
}
if bookmark.readingTime > 0 {
Label("\(bookmark.readingTime) min", systemImage: "clock")
}
}
HStack {
if bookmark.siteName?.isEmpty == false {
Label(bookmark.siteName ?? "", systemImage: "globe")
}
}
HStack {
Label("Original Seite öffnen", systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url ?? "")
}
}
}
.font(.caption)
.foregroundColor(.secondary)
// Progress Bar für Lesefortschritt
if bookmark.readProgress > 0 {
ProgressView(value: Double(bookmark.readProgress), total: 100)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 4)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
// Swipe Actions hinzufügen
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
// Löschen (ganz rechts)
Button("Löschen", role: .destructive) {
onDelete(bookmark)
}
.tint(.red)
// Favorit (rechts)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Entfernen" : "Favorit",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
}
.tint(bookmark.isMarked ? .gray : .pink)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
// Archivieren (links)
Button {
onArchive(bookmark)
} label: {
if currentState == .archived {
Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
} else {
Label("Archivieren", systemImage: "archivebox")
}
}
.tint(currentState == .archived ? .blue : .orange)
}
}
// MARK: - Computed Properties
private var formattedPublishedDate: String? {
guard let published = bookmark.published, !published.isEmpty else {
return nil
}
// Prüfe auf Unix Epoch (1970-01-01) - bedeutet "kein Datum"
if published.contains("1970-01-01") {
return nil
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
guard let date = formatter.date(from: published) else {
// Fallback ohne Millisekunden
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
guard let fallbackDate = formatter.date(from: published) else {
return nil
}
return formatDate(fallbackDate)
}
return formatDate(date)
}
private func formatDate(_ date: Date) -> String {
let now = Date()
let calendar = Calendar.current
// Heute
if calendar.isDateInToday(date) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Heute, \(formatter.string(from: date))"
}
// Gestern
if calendar.isDateInYesterday(date) {
let formatter = DateFormatter()
formatter.timeStyle = .short
return "Gestern, \(formatter.string(from: date))"
}
// Diese Woche
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, HH:mm"
return formatter.string(from: date)
}
// Dieses Jahr
if calendar.isDate(date, equalTo: now, toGranularity: .year) {
let formatter = DateFormatter()
formatter.dateFormat = "d. MMM, HH:mm"
return formatter.string(from: date)
}
// Andere Jahre
let formatter = DateFormatter()
formatter.dateFormat = "d. MMM yyyy"
return formatter.string(from: date)
}
private var imageURL: URL? {
// Bevorzuge image, dann thumbnail, dann icon
if let imageUrl = bookmark.resources?.image?.src {
return URL(string: imageUrl)
} else if let thumbnailUrl = bookmark.resources?.thumbnail?.src {
return URL(string: thumbnailUrl)
} else if let iconUrl = bookmark.resources?.icon?.src {
return URL(string: iconUrl)
}
return nil
}
}
struct BookmarkCardView: View {
let bookmark: Bookmark
let currentState: BookmarkState

View File

@ -10,54 +10,51 @@ struct BookmarksView: View {
@State private var shareURL = ""
@State private var shareTitle = ""
@FetchRequest(entity: BookmarkEntity.entity(), sortDescriptors: [])
private var bookmarks: FetchedResults<BookmarkEntity>
var body: some View {
NavigationStack {
ZStack {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView("Lade \(state.displayName)...")
} else {
List {
ForEach(viewModel.bookmarks, id: \.id) { bookmark in
Button(action: {
selectedBookmarkId = bookmark.id
}) {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
Task {
await viewModel.deleteBookmark(bookmark: bookmark)
}
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
List {
ForEach(bookmarks) { bookmark in
Button(action: {
selectedBookmarkId = bookmark.id
}) {
BookmarkEntityCardView(
bookmark: bookmark,
currentState: state,
onArchive: { bookmark in
Task {
//await viewModel.toggleArchive(bookmark: bookmark)
}
},
onDelete: { bookmark in
Task {
//await viewModel.deleteBookmark(bookmark: bookmark)
}
},
onToggleFavorite: { bookmark in
Task {
//await viewModel.toggleFavorite(bookmark: bookmark)
}
}
)
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listStyle(.plain)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if bookmarks.isEmpty {
ContentUnavailableView(
"Keine Bookmarks",
systemImage: "bookmark",
description: Text("Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden.")
)
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if viewModel.bookmarks.isEmpty && !viewModel.isLoading {
ContentUnavailableView(
"Keine Bookmarks",
systemImage: "bookmark",
description: Text("Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden.")
)
}
}
}
@ -106,6 +103,7 @@ struct BookmarksView: View {
}
.task {
await viewModel.loadBookmarks(state: state)
await viewModel.syncBookmarks()
}
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed

View File

@ -6,6 +6,7 @@ class BookmarksViewModel {
private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase()
private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
private let syncBookmarksUseCase = DefaultUseCaseFactory.shared.makeSyncBookmarksUseCase()
var bookmarks: [Bookmark] = []
var isLoading = false
@ -54,14 +55,22 @@ class BookmarksViewModel {
currentState = state
do {
bookmarks = try await getBooksmarksUseCase.execute(state: state)
bookmarks = try await getBooksmarksUseCase.execute(state: state, limit: 100, offset: 0)
} catch {
errorMessage = "Fehler beim Laden der Bookmarks"
bookmarks = []
bookmarks = []
}
isLoading = false
}
func syncBookmarks() async {
do {
try await syncBookmarksUseCase.execute()
} catch {
errorMessage = "Fehler beim Synchronisieren der Bookmarks"
}
}
@MainActor
func refreshBookmarks() async {

View File

@ -10,6 +10,7 @@ protocol UseCaseFactory {
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
func makeSyncBookmarksUseCase() -> SyncBookmarksUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
@ -17,6 +18,9 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
private lazy var bookmarkSyncRepository = BookmarkSyncRepository(api: api)
private let settingsRepository: PSettingsRepository = SettingsRepository()
static let shared = DefaultUseCaseFactory()
@ -63,4 +67,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase {
return CreateBookmarkUseCase(repository: bookmarksRepository)
}
func makeSyncBookmarksUseCase() -> SyncBookmarksUseCase {
return SyncBookmarksUseCase(bookmarkRepository: bookmarkSyncRepository)
}
}

View File

@ -9,12 +9,12 @@ import SwiftUI
@main
struct readeckApp: App {
let persistenceController = PersistenceController.shared
let persistenceController = CoreDataManager.shared
var body: some Scene {
WindowGroup {
MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environment(\.managedObjectContext, persistenceController.persistentContainer.viewContext)
.onOpenURL { url in
handleIncomingURL(url)
}

View File

@ -1,7 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<entity name="BlaEntity" representedClassName="BlaEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
<entity name="BookmarkEntity" representedClassName="BookmarkEntity" syncable="YES" codeGenerationType="class">
<attribute name="authors" optional="YES" attributeType="String"/>
<attribute name="created" optional="YES" attributeType="String"/>
<attribute name="desc" optional="YES" attributeType="String"/>
<attribute name="documentType" optional="YES" attributeType="String"/>
<attribute name="hasArticle" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasDeleted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="href" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="isArchived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isMarked" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lang" optional="YES" attributeType="String"/>
<attribute name="loaded" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="published" optional="YES" attributeType="String"/>
<attribute name="readingTime" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="readProgress" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="site" optional="YES" attributeType="String"/>
<attribute name="siteName" optional="YES" attributeType="String"/>
<attribute name="state" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="textDirection" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/>
<attribute name="type" optional="YES" attributeType="String"/>
<attribute name="update" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="wordCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="resources" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="BookmarkResourcesEntity"/>
</entity>
<entity name="BookmarkResourcesEntity" representedClassName="BookmarkResourcesEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="String"/>
<relationship name="article" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ResourceEntity"/>
<relationship name="icon" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ImageResourceEntity"/>
<relationship name="image" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ImageResourceEntity"/>
<relationship name="log" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ResourceEntity"/>
<relationship name="props" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ResourceEntity"/>
<relationship name="thumbnail" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ImageResourceEntity"/>
</entity>
<entity name="ImageResourceEntity" representedClassName="ImageResourceEntity" syncable="YES" codeGenerationType="class">
<attribute name="height" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="src" optional="YES" attributeType="String"/>
<attribute name="width" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
<entity name="ResourceEntity" representedClassName="ResourceEntity" syncable="YES" codeGenerationType="class">
<attribute name="src" optional="YES" attributeType="String"/>
</entity>
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
<attribute name="endpoint" optional="YES" attributeType="String"/>