Compare commits

...

2 Commits

Author SHA1 Message Date
624816d914 feat: add German localization and improve share extension UX
- Add comprehensive German localization with Localizable.xcstrings
- Integrate R.swift for type-safe resource management
- Improve share extension UI with better styling and optional title input
- Add archive functionality to bookmark detail view
- Update README with current features and planned roadmap
- Remove title validation requirement from share extension
- Optimize share extension auto-dismiss timing
- Clean up code structure and remove unused components
2025-07-04 22:30:01 +02:00
1763dd6fa1 feat: Complete Share Extension implementation with Keychain integration
UI/UX Improvements:
- Replace SLComposeServiceViewController with custom UIViewController
- Add beautiful green-themed UI with Readeck branding and logo
- Implement modern card-based layout with shadows and rounded corners
- Add custom cancel button and proper navigation styling
- Include loading states and comprehensive user feedback

Backend Integration:
- Add KeychainHelper integration for secure token/endpoint storage
- Implement proper API integration with async/await
- Add comprehensive error handling and status messages
- Include DTOs for API communication

Security & Configuration:
- Add keychain access groups to entitlements for both main app and extension
- Update TokenProvider to save tokens to keychain
- Modify LogoutUseCase to clear keychain data
- Update SaveServerSettingsUseCase to persist endpoint in keychain
- Configure proper build settings and file sharing between targets

Extension Lifecycle:
- Implement proper URL extraction from various sources
- Add automatic extension dismissal on success
- Ensure proper extension context handling
2025-07-04 00:00:35 +02:00
19 changed files with 904 additions and 286 deletions

290
Localizable.xcstrings Normal file
View File

@ -0,0 +1,290 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"%lld min" : {
},
"%lld Minuten" : {
},
"12 min • Today • example.com" : {
},
"Abbrechen" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Abbrechen"
}
}
}
},
"Abmelden" : {
},
"Add Item" : {
},
"Anmelden & speichern" : {
},
"Archivieren" : {
},
"Artikel automatisch als gelesen markieren" : {
},
"Automatischer Sync" : {
},
"Benutzername" : {
},
"Bookmark archivieren" : {
},
"Bookmark ist archiviert" : {
},
"Bookmark speichern" : {
},
"Cache leeren" : {
},
"Datenmanagement" : {
},
"Debug-Anmeldung" : {
},
"done" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fertig"
}
}
}
},
"Einfügen" : {
},
"Einstellungen" : {
},
"Einstellungen speichern" : {
},
"Einstellungen zurücksetzen" : {
},
"Entfernen" : {
},
"Entwickler: %@" : {
},
"Erfolgreich angemeldet" : {
},
"Erforderlich" : {
},
"Erneut anmelden & speichern" : {
},
"Es wurden noch keine Bookmarks in %@ gefunden." : {
},
"Externe Links in In-App Safari öffnen" : {
},
"Favorit" : {
},
"Fehler" : {
},
"Fertig" : {
},
"Fertig mit Lesen?" : {
},
"font_settings_title" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schrift-Einstellungen"
}
}
}
},
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
},
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
},
"https://example.com" : {
},
"https://readeck.example.com" : {
},
"Ihr Benutzername" : {
},
"Ihr Passwort" : {
},
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
},
"Item at %@" : {
},
"Keine Bookmarks" : {
},
"Keine Bookmarks gefunden." : {
},
"Keine Ergebnisse" : {
},
"Labels" : {
},
"Lade %@..." : {
},
"Lade Artikel..." : {
},
"Leseeinstellungen" : {
},
"Löschen" : {
},
"Mehr" : {
},
"Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen." : {
},
"Neues Bookmark" : {
},
"OK" : {
},
"Optional: Eigener Titel" : {
},
"Passwort" : {
},
"readeck Bookmark Title" : {
},
"Safari Reader Modus" : {
},
"Schließen" : {
},
"Schrift" : {
},
"Schrift-Einstellungen" : {
},
"Schriftart" : {
},
"Schriftgröße" : {
},
"Select a bookmark" : {
},
"Select an item" : {
},
"Server-Endpunkt" : {
},
"Speichern..." : {
},
"Suchbegriff eingeben..." : {
},
"Suche" : {
},
"Suche..." : {
},
"Sync-Einstellungen" : {
},
"Sync-Intervall" : {
},
"Tags" : {
},
"Theme" : {
},
"This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog." : {
},
"Titel" : {
},
"Über die App" : {
},
"URL" : {
},
"URL gefunden:" : {
},
"Version %@" : {
},
"Vorschau" : {
},
"Website" : {
},
"Wiederherstellen" : {
},
"Wird gespeichert..." : {
},
"z.B. arbeit, wichtig, später" : {
},
"Zwischenablage" : {
}
},
"version" : "1.0"
}

View File

@ -10,32 +10,23 @@ https://codeberg.org/readeck/readeck
## Features ## Features
- Browse and manage bookmarks (Unread, Favorites, Archive) - Browse and manage bookmarks (All, Unread, Favorites, Archive, Article, Videos, Pictures)
- Share Extension for adding URLs from Safari and other apps - Share Extension for adding URLs from Safari and other apps
- Swipe actions for quick bookmark management - Swipe actions for quick bookmark management
- Native iOS design with Dark Mode support - Native iOS design with Dark Mode support
- Full iPad Support with Multi-Column Split View
## Requirements - Font Customization
- Article View with Reading Time and Word Count
- iOS 17.0+ - Search functionality
- Xcode 15.0+
- Swift 5.9+
## Configuration ## Configuration
After installing the app: After installing the app:
1. Open the readeck app 1. Open the readeck app
2. Go to the **Settings** tab 2. Enter your readeck server URL and credentials
3. Enter your readeck server URL and credentials 3. The app will automatically load your bookmarks
4. The app will automatically sync your bookmarks
## Architecture
- **SwiftUI** for UI
- **Core Data** for local storage
- **MVVM** architecture pattern
- **Repository pattern** for data access
## Share Extension ## Share Extension
@ -43,7 +34,17 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
1. Share any webpage in Safari 1. Share any webpage in Safari
2. Select "readeck" from the share sheet 2. Select "readeck" from the share sheet
3. The bookmark is automatically added to your collection 3. Enter a title if you want and hit save
4. The bookmark is automatically added to your collection
## Planned Features
- [ ] Add support for bookmark filtering and sorting options
- [ ] Add support for tags
- [ ] Offline sync with Core Data
- [ ] Add support for collection management
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes
## Contributing ## Contributing
@ -53,17 +54,3 @@ The app includes a Share Extension that allows adding bookmarks directly from Sa
4. Push to the branch (`git push origin feature/amazing-feature`) 4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request 5. Open a Pull Request
## Planned Features
- [ ] Offline sync with Core Data
- [ ] Add support for tags
- [ ] Add support for bookmark filtering and sorting options
- [ ] Implement search functionality
- [ ] Add support for collection management
- [ ] Add support for multiple readeck servers
- [ ] Add offline sync capabilities
- [ ] Add support for custom themes
- [ ] Implement push notifications for new bookmarks
- [ ] Support for iPad multitasking
- [ ] Implement a dark mode toggle in settings
- [ ] Implement a tutorial for first-time users

View File

@ -9,58 +9,228 @@ import UIKit
import Social import Social
import UniformTypeIdentifiers import UniformTypeIdentifiers
class ShareViewController: SLComposeServiceViewController { class ShareViewController: UIViewController {
private var extractedURL: String? private var extractedURL: String?
private var extractedTitle: String? private var extractedTitle: String?
// UI Elements
private var titleTextField: UITextField?
private var urlLabel: UILabel?
private var statusLabel: UILabel?
private var saveButton: UIButton?
private var activityIndicator: UIActivityIndicatorView?
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupUI()
extractSharedContent() extractSharedContent()
}
// MARK: - UI Setup
private func setupUI() {
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
// Automatisch die Haupt-App öffnen, sobald URL extrahiert wurde // Add cancel button
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped))
self.openParentApp() cancelButton.tintColor = UIColor.white
} navigationItem.leftBarButtonItem = cancelButton
// Ensure navigation bar is visible
navigationController?.navigationBar.isTranslucent = false
navigationController?.navigationBar.backgroundColor = UIColor(named: "green") ?? UIColor.systemGreen
navigationController?.navigationBar.tintColor = UIColor.white
navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
// Add logo
let logoImageView = UIImageView(image: UIImage(named: "readeck"))
logoImageView.translatesAutoresizingMaskIntoConstraints = false
logoImageView.contentMode = .scaleAspectFit
logoImageView.alpha = 0.9
view.addSubview(logoImageView)
// Add custom cancel button
let customCancelButton = UIButton(type: .system)
customCancelButton.translatesAutoresizingMaskIntoConstraints = false
customCancelButton.setTitle("Abbrechen", for: .normal)
customCancelButton.setTitleColor(UIColor.white, for: .normal)
customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
view.addSubview(customCancelButton)
// URL Container View
let urlContainerView = UIView()
urlContainerView.translatesAutoresizingMaskIntoConstraints = false
urlContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
urlContainerView.layer.cornerRadius = 12
urlContainerView.layer.shadowColor = UIColor.black.cgColor
urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
urlContainerView.layer.shadowRadius = 4
urlContainerView.layer.shadowOpacity = 0.1
view.addSubview(urlContainerView)
// URL Label
urlLabel = UILabel()
urlLabel?.translatesAutoresizingMaskIntoConstraints = false
urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
urlLabel?.textColor = UIColor.label
urlLabel?.numberOfLines = 0
urlLabel?.text = "URL wird geladen..."
urlLabel?.textAlignment = .left
urlContainerView.addSubview(urlLabel!)
// Title Container View
let titleContainerView = UIView()
titleContainerView.translatesAutoresizingMaskIntoConstraints = false
titleContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
titleContainerView.layer.cornerRadius = 12
titleContainerView.layer.shadowColor = UIColor.black.cgColor
titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
titleContainerView.layer.shadowRadius = 4
titleContainerView.layer.shadowOpacity = 0.1
view.addSubview(titleContainerView)
// Title TextField
titleTextField = UITextField()
titleTextField?.translatesAutoresizingMaskIntoConstraints = false
titleTextField?.placeholder = "Optionales Titel eingeben..."
titleTextField?.borderStyle = .none
titleTextField?.font = UIFont.systemFont(ofSize: 16)
titleTextField?.backgroundColor = UIColor.clear
titleContainerView.addSubview(titleTextField!)
// Status Label
statusLabel = UILabel()
statusLabel?.translatesAutoresizingMaskIntoConstraints = false
statusLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
statusLabel?.numberOfLines = 0
statusLabel?.textAlignment = .center
statusLabel?.isHidden = true
statusLabel?.layer.cornerRadius = 10
statusLabel?.layer.masksToBounds = true
view.addSubview(statusLabel!)
// Save Button
saveButton = UIButton(type: .system)
saveButton?.translatesAutoresizingMaskIntoConstraints = false
saveButton?.setTitle("Bookmark speichern", for: .normal)
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
saveButton?.backgroundColor = UIColor.secondarySystemGroupedBackground
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
saveButton?.layer.cornerRadius = 16
saveButton?.layer.shadowColor = UIColor.black.cgColor
saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4)
saveButton?.layer.shadowRadius = 8
saveButton?.layer.shadowOpacity = 0.2
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
view.addSubview(saveButton!)
// Activity Indicator
activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator?.translatesAutoresizingMaskIntoConstraints = false
activityIndicator?.hidesWhenStopped = true
view.addSubview(activityIndicator!)
setupConstraints()
} }
override func isContentValid() -> Bool { private func setupConstraints() {
return true // Immer true, damit der Button funktioniert guard let urlLabel = urlLabel,
let titleTextField = titleTextField,
let statusLabel = statusLabel,
let saveButton = saveButton,
let activityIndicator = activityIndicator else { return }
// Find container views and logo
let urlContainerView = urlLabel.superview!
let titleContainerView = titleTextField.superview!
let logoImageView = view.subviews.first { $0 is UIImageView }!
let customCancelButton = view.subviews.first { $0 is UIButton && $0 != saveButton }!
NSLayoutConstraint.activate([
// Custom Cancel Button
customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
customCancelButton.heightAnchor.constraint(equalToConstant: 36),
customCancelButton.widthAnchor.constraint(equalToConstant: 100),
// Logo
logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
logoImageView.heightAnchor.constraint(equalToConstant: 40),
logoImageView.widthAnchor.constraint(equalToConstant: 120),
// URL Container
urlContainerView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 24),
urlContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
urlContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
// URL Label inside container
urlLabel.topAnchor.constraint(equalTo: urlContainerView.topAnchor, constant: 16),
urlLabel.leadingAnchor.constraint(equalTo: urlContainerView.leadingAnchor, constant: 16),
urlLabel.trailingAnchor.constraint(equalTo: urlContainerView.trailingAnchor, constant: -16),
urlLabel.bottomAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: -16),
// Title Container
titleContainerView.topAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: 20),
titleContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
titleContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
titleContainerView.heightAnchor.constraint(equalToConstant: 60),
// Title TextField inside container
titleTextField.topAnchor.constraint(equalTo: titleContainerView.topAnchor, constant: 16),
titleTextField.leadingAnchor.constraint(equalTo: titleContainerView.leadingAnchor, constant: 16),
titleTextField.trailingAnchor.constraint(equalTo: titleContainerView.trailingAnchor, constant: -16),
titleTextField.bottomAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: -16),
// Status Label
statusLabel.topAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: 20),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
// Save Button
saveButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 32),
saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
saveButton.heightAnchor.constraint(equalToConstant: 56),
// Activity Indicator
activityIndicator.centerXAnchor.constraint(equalTo: saveButton.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor)
])
} }
override func didSelectPost() { // MARK: - Content Extraction
openMainApp()
}
// MARK: - Private Methods
private func extractSharedContent() { private func extractSharedContent() {
guard let extensionContext = extensionContext else { return } guard let extensionContext = extensionContext else { return }
print("=== DEBUG: Starting content extraction ===") for item in extensionContext.inputItems {
print("Input items count: \(extensionContext.inputItems.count)")
for (itemIndex, item) in extensionContext.inputItems.enumerated() {
guard let inputItem = item as? NSExtensionItem else { continue } guard let inputItem = item as? NSExtensionItem else { continue }
print("Item \(itemIndex) - attachments: \(inputItem.attachments?.count ?? 0)") for attachment in inputItem.attachments ?? [] {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
// Versuche alle verfügbaren Type Identifiers attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
for (attachmentIndex, provider) in (inputItem.attachments ?? []).enumerated() {
print("Attachment \(attachmentIndex) - registered types: \(provider.registeredTypeIdentifiers)")
// Iteriere durch alle registrierten Type Identifiers
for typeIdentifier in provider.registeredTypeIdentifiers {
print("Trying type identifier: \(typeIdentifier)")
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, error in
if let error = error {
print("Error loading \(typeIdentifier): \(error)")
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self?.processLoadedItem(item, typeIdentifier: typeIdentifier, inputItem: inputItem) if let url = url as? URL {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
}
}
}
}
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
DispatchQueue.main.async {
if let text = text as? String, let url = URL(string: text) {
self?.extractedURL = url.absoluteString
self?.urlLabel?.text = url.absoluteString
}
} }
} }
} }
@ -68,156 +238,119 @@ class ShareViewController: SLComposeServiceViewController {
} }
} }
private func processLoadedItem(_ item: Any?, typeIdentifier: String, inputItem: NSExtensionItem) { // MARK: - Actions
print("Processing item of type \(typeIdentifier): \(type(of: item))")
// URL direkt
if let url = item as? URL {
print("Found URL: \(url.absoluteString)")
extractedURL = url.absoluteString
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// NSURL
if let nsurl = item as? NSURL {
print("Found NSURL: \(nsurl.absoluteString ?? "nil")")
extractedURL = nsurl.absoluteString
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// String (könnte URL sein)
if let text = item as? String {
print("Found String: \(text)")
if URL(string: text) != nil {
extractedURL = text
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
return
}
// Versuche URL aus Text zu extrahieren
if let extractedURL = extractURLFromText(text) {
self.extractedURL = extractedURL
self.extractedTitle = text != extractedURL ? text : nil
return
}
}
// Dictionary (Property List)
if let dictionary = item as? [String: Any] {
print("Found Dictionary: \(dictionary)")
handlePropertyList(dictionary)
return
}
// NSData - versuche als String zu interpretieren
if let data = item as? Data {
if let text = String(data: data, encoding: .utf8) {
print("Found Data as String: \(text)")
if URL(string: text) != nil {
extractedURL = text
return
}
}
}
print("Could not process item of type: \(type(of: item))")
}
private func extractURLFromText(_ text: String) -> String? { @objc private func saveButtonTapped() {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) let title = titleTextField?.text ?? ""
let range = NSRange(text.startIndex..<text.endIndex, in: text)
if let match = detector?.firstMatch(in: text, options: [], range: range), saveButton?.isEnabled = false
let url = match.url { activityIndicator?.startAnimating()
return url.absoluteString
}
return nil Task {
} await addBookmarkViaAPI(title: title)
await MainActor.run {
private func handlePropertyList(_ dictionary: [String: Any]) { self.saveButton?.isEnabled = true
// Safari und andere Browser verwenden oft Property Lists self.activityIndicator?.stopAnimating()
if let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any] {
if let url = results["URL"] as? String {
extractedURL = url
extractedTitle = results["title"] as? String
return
}
}
// Direkte URL im Dictionary
if let url = dictionary["URL"] as? String {
extractedURL = url
extractedTitle = dictionary["title"] as? String
return
}
// Andere mögliche Keys
for key in dictionary.keys {
if let value = dictionary[key] as? String, URL(string: value) != nil {
extractedURL = value
return
} }
} }
} }
private func openMainApp() { @objc private func cancelButtonTapped() {
let url = extractedURL ?? "https://example.com"
let title = extractedTitle ?? ""
print("Opening main app with URL: \(url)")
// Verwende NSUserActivity anstatt URL-Schema
let userActivity = NSUserActivity(activityType: "de.ilyas.readeck")
userActivity.userInfo = [
"url": url,
"title": title
]
userActivity.webpageURL = URL(string: url)
// Extension schließen und Activity übergeben
extensionContext?.completeRequest(returningItems: [userActivity], completionHandler: nil)
}
private func completeRequest() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil) extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
func openParentApp() { // MARK: - API Call
guard let extensionContext = extensionContext else { return } private func addBookmarkViaAPI(title: String) async {
guard let url = extractedURL, !url.isEmpty else {
let url = extractedURL ?? "https://example.com" showStatus("Keine URL gefunden.", error: true)
let title = extractedTitle ?? ""
print("Opening parent app with URL: \(url)")
// URL für die Haupt-App erstellen mit Parametern
var urlComponents = URLComponents(string: "readeck://add-bookmark")
urlComponents?.queryItems = [
URLQueryItem(name: "url", value: url)
]
if !title.isEmpty {
urlComponents?.queryItems?.append(URLQueryItem(name: "title", value: title))
}
guard let finalURL = urlComponents?.url else {
print("Failed to create final URL")
return return
} }
print("Final URL: \(finalURL)") // Token und Endpoint aus KeychainHelper
guard let token = KeychainHelper.shared.loadToken() else {
showStatus("Kein Token gefunden. Bitte in der Haupt-App einloggen.", error: true)
return
}
var responder: UIResponder? = self guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
showStatus("Kein Server-Endpunkt gefunden.", error: true)
return
}
while responder != nil { let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
if let application = responder as? UIApplication { guard let requestData = try? JSONEncoder().encode(requestDto) else {
application.open(finalURL) showStatus("Fehler beim Kodieren der Anfrage.", error: true)
break return
}
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
showStatus("Ungültiger Server-Endpunkt.", error: true)
return
}
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = requestData
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
showStatus("Ungültige Server-Antwort.", error: true)
return
} }
responder = responder?.next
guard 200...299 ~= httpResponse.statusCode else {
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler"
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true)
return
}
// Optional: Response parsen
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
showStatus("Gespeichert: \(resp.message)", error: false)
} else {
showStatus("Lesezeichen gespeichert!", error: false)
}
} catch {
showStatus("Netzwerkfehler: \(error.localizedDescription)", error: true)
} }
} }
private func showStatus(_ message: String, error: Bool) {
DispatchQueue.main.async {
self.statusLabel?.text = message
self.statusLabel?.textColor = error ? UIColor.systemRed : UIColor.systemGreen
self.statusLabel?.backgroundColor = error ? UIColor.systemRed.withAlphaComponent(0.1) : UIColor.systemGreen.withAlphaComponent(0.1)
self.statusLabel?.isHidden = false
if !error {
// Automatically dismiss after success
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
}
// MARK: - DTOs (kopiert)
private struct CreateBookmarkRequestDto: Codable {
let labels: [String]?
let title: String?
let url: String
init(url: String, title: String? = nil, labels: [String]? = nil) {
self.url = url
self.title = title
self.labels = labels
}
}
private struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
}
} }

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
</array>
</dict>
</plist>

View File

@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; }; 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FC2E17C3B3007531C3 /* rswift */; };
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5DA242122E17D31A007531C3 /* Localizable.xcstrings */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -64,6 +68,7 @@
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5DA242122E17D31A007531C3 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -77,7 +82,9 @@
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = { 5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Assets.xcassets,
Data/CoreData/CoreDataManager.swift, Data/CoreData/CoreDataManager.swift,
Data/KeychainHelper.swift,
Domain/Model/Bookmark.swift, Domain/Model/Bookmark.swift,
readeck.xcdatamodeld, readeck.xcdatamodeld,
); );
@ -135,6 +142,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */, 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -158,6 +167,7 @@
5D45F9BF2DF858680048D5B8 = { 5D45F9BF2DF858680048D5B8 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
5D45F9CA2DF858680048D5B8 /* readeck */, 5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */, 5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */, 5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
@ -215,6 +225,7 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */,
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */, 5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
@ -223,6 +234,8 @@
name = readeck; name = readeck;
packageProductDependencies = ( packageProductDependencies = (
5D348CC22E0C9F4F00D0AF21 /* netfox */, 5D348CC22E0C9F4F00D0AF21 /* netfox */,
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
5DA241FC2E17C3B3007531C3 /* rswift */,
); );
productName = readeck; productName = readeck;
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */; productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
@ -306,11 +319,14 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"fr-CA",
de,
); );
mainGroup = 5D45F9BF2DF858680048D5B8; mainGroup = 5D45F9BF2DF858680048D5B8;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */, 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */; productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
@ -330,6 +346,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -337,6 +354,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -403,12 +421,17 @@
target = 5D45F9C72DF858680048D5B8 /* readeck */; target = 5D45F9C72DF858680048D5B8 /* readeck */;
targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */; targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */;
}; };
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
5D2B7FBC2DFA27A400EBDB2B /* Debug */ = { 5D2B7FBC2DFA27A400EBDB2B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
@ -437,6 +460,7 @@
5D2B7FBD2DFA27A400EBDB2B /* Release */ = { 5D2B7FBD2DFA27A400EBDB2B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
@ -520,6 +544,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
name = Debug; name = Debug;
@ -574,6 +599,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
}; };
name = Release; name = Release;
}; };
@ -592,7 +618,6 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist; INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -635,7 +660,6 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist; INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -812,6 +836,14 @@
minimumVersion = 1.21.0; minimumVersion = 1.21.0;
}; };
}; };
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mac-cain13/R.swift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 7.8.0;
};
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -820,6 +852,21 @@
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */; package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */;
productName = netfox; productName = netfox;
}; };
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = RswiftLibrary;
};
5DA241FC2E17C3B3007531C3 /* rswift */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = rswift;
};
5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */ = {
isa = XCSwiftPackageProductDependency;
package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */;
productName = "plugin:RswiftGenerateInternalResources";
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 5D45F9C02DF858680048D5B8 /* Project object */; rootObject = 5D45F9C02DF858680048D5B8 /* Project object */;

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "7374154e7686de69a9f88fbafb081b646b02140f8d82770f46fa750840581e0e", "originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
"pins" : [ "pins" : [
{ {
"identity" : "netfox", "identity" : "netfox",
@ -9,6 +9,33 @@
"revision" : "557576032736fd3140422baefb68b8f76c55088f", "revision" : "557576032736fd3140422baefb68b8f76c55088f",
"version" : "1.21.0" "version" : "1.21.0"
} }
},
{
"identity" : "r.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mac-cain13/R.swift.git",
"state" : {
"revision" : "a9abc6b0afe0fc4a5a71e1d7d2872143dff2d2f1",
"version" : "7.8.0"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
}
},
{
"identity" : "xcodeedit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tomlokhorst/XcodeEdit",
"state" : {
"revision" : "0e550cdee72844b35431afc3a1e176042be6d0f0",
"version" : "2.13.0"
}
} }
], ],
"version" : 3 "version" : 3

View File

@ -23,9 +23,9 @@
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0x5B", "blue" : "0x39",
"green" : "0x4D", "green" : "0x37",
"red" : "0x00" "red" : "0x2E"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -158,28 +158,28 @@ class API: PAPI {
} }
func login(endpoint: String, username: String, password: String) async throws -> UserDto { func login(endpoint: String, username: String, password: String) async throws -> UserDto {
guard let url = URL(string: endpoint + "/api/auth") else { throw APIError.invalidURL }
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password) let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
let requestData = try JSONEncoder().encode(loginRequest) let requestData = try JSONEncoder().encode(loginRequest)
guard let url = URL(string: endpoint + "/api/auth") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = requestData request.httpBody = requestData
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse throw APIError.invalidResponse
} }
guard 200...299 ~= httpResponse.statusCode else { guard 200...299 ~= httpResponse.statusCode else {
throw APIError.serverError(httpResponse.statusCode) throw APIError.serverError(httpResponse.statusCode)
} }
let userDto = try JSONDecoder().decode(UserDto.self, from: data)
// Token NICHT automatisch speichern, da Settings noch nicht existieren return try JSONDecoder().decode(UserDto.self, from: data)
return userDto
} }
// Angepasste getBookmarks-Methode mit Header-Auslesen
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto { func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto {
var endpoint = "/api/bookmarks" var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = [] var queryItems: [URLQueryItem] = []

View File

@ -0,0 +1,58 @@
import Foundation
import Security
class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
private static let accessGroup = "8J69P655GN.de.ilyashallak.readeck2"
@discardableResult
func saveToken(_ token: String) -> Bool {
saveString(token, forKey: "readeck_token")
}
func loadToken() -> String? {
loadString(forKey: "readeck_token")
}
@discardableResult
func saveEndpoint(_ endpoint: String) -> Bool {
saveString(endpoint, forKey: "readeck_endpoint")
}
func loadEndpoint() -> String? {
loadString(forKey: "readeck_endpoint")
}
// MARK: - Private generic helpers
@discardableResult
private func saveString(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessGroup as String: KeychainHelper.accessGroup
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
private func loadString(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: KeychainHelper.accessGroup,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
return String(data: data, encoding: .utf8)
}
return nil
}
}

View File

@ -89,41 +89,3 @@ class BookmarksRepository: PBookmarksRepository {
return bookmarkDtos.toDomain() return bookmarkDtos.toDomain()
} }
} }
struct BookmarkDetail {
let id: String
let title: String
let url: String
let description: String
let siteName: String
let authors: [String]
let created: String
let updated: String
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
let isArchived: Bool
let thumbnailUrl: String
let imageUrl: String
}
enum CreateBookmarkError: Error, LocalizedError {
case invalidURL
case duplicateBookmark
case networkError
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Die eingegebene URL ist ungültig"
case .duplicateBookmark:
return "Dieser Bookmark existiert bereits"
case .networkError:
return "Netzwerkfehler beim Erstellen des Bookmarks"
case .serverError(let message):
return message // Verwende die Server-Nachricht direkt
}
}
}

View File

@ -11,6 +11,7 @@ class CoreDataTokenProvider: TokenProvider {
private let settingsRepository = SettingsRepository() private let settingsRepository = SettingsRepository()
private var cachedSettings: Settings? private var cachedSettings: Settings?
private var isLoaded = false private var isLoaded = false
private let keychainHelper = KeychainHelper.shared
private func loadSettingsIfNeeded() async { private func loadSettingsIfNeeded() async {
guard !isLoaded else { return } guard !isLoaded else { return }
@ -40,6 +41,7 @@ class CoreDataTokenProvider: TokenProvider {
do { do {
try await settingsRepository.saveToken(token) try await settingsRepository.saveToken(token)
saveTokenToKeychain(token: token)
if cachedSettings != nil { if cachedSettings != nil {
cachedSettings!.token = token cachedSettings!.token = token
} }
@ -52,8 +54,19 @@ class CoreDataTokenProvider: TokenProvider {
do { do {
try await settingsRepository.clearSettings() try await settingsRepository.clearSettings()
cachedSettings = nil cachedSettings = nil
saveTokenToKeychain(token: "")
} catch { } catch {
print("Failed to clear settings: \(error)") print("Failed to clear settings: \(error)")
} }
} }
// MARK: - Keychain Support
func saveTokenToKeychain(token: String) {
keychainHelper.saveToken(token)
}
func loadTokenFromKeychain() -> String? {
keychainHelper.loadToken()
}
} }

View File

@ -0,0 +1,21 @@
import Foundation
enum CreateBookmarkError: Error, LocalizedError {
case invalidURL
case duplicateBookmark
case networkError
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Die eingegebene URL ist ungültig"
case .duplicateBookmark:
return "Dieser Bookmark existiert bereits"
case .networkError:
return "Netzwerkfehler beim Erstellen des Bookmarks"
case .serverError(let message):
return message
}
}
}

View File

@ -0,0 +1,39 @@
import Foundation
struct BookmarkDetail {
let id: String
let title: String
let url: String
let description: String
let siteName: String
let authors: [String]
let created: String
let updated: String
let wordCount: Int?
let readingTime: Int?
let hasArticle: Bool
let isMarked: Bool
var isArchived: Bool
let thumbnailUrl: String
let imageUrl: String
}
extension BookmarkDetail {
static let empty = BookmarkDetail(
id: "",
title: "",
url: "",
description: "",
siteName: "",
authors: [],
created: "",
updated: "",
wordCount: 0,
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
}

View File

@ -35,9 +35,12 @@ class LogoutUseCase: LogoutUseCaseProtocol {
try await settingsRepository.saveUsername("") try await settingsRepository.saveUsername("")
try await settingsRepository.savePassword("") try await settingsRepository.savePassword("")
KeychainHelper.shared.saveToken("")
KeychainHelper.shared.saveEndpoint("")
// Note: We keep the endpoint for potential re-login // Note: We keep the endpoint for potential re-login
// but clear the authentication data // but clear the authentication data
print("LogoutUseCase: User logged out successfully") print("LogoutUseCase: User logged out successfully")
} }
} }

View File

@ -9,5 +9,8 @@ class SaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws { func execute(endpoint: String, username: String, password: String, token: String) async throws {
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token) try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
KeychainHelper.shared.saveToken(token)
KeychainHelper.shared.saveEndpoint(endpoint)
print("token saved", KeychainHelper.shared.loadToken())
} }
} }

View File

@ -20,6 +20,7 @@ struct BookmarkDetailView: View {
Divider().padding(.horizontal) Divider().padding(.horizontal)
contentSection contentSection
Spacer(minLength: 40) Spacer(minLength: 40)
archiveSection
} }
} }
} }
@ -190,6 +191,39 @@ struct BookmarkDetailView: View {
} }
return dateString return dateString
} }
private var archiveSection: some View {
VStack(spacing: 12) {
Text("Fertig mit Lesen?")
.font(.headline)
.padding(.top, 24)
if viewModel.bookmarkDetail.isArchived {
Label("Bookmark ist archiviert", systemImage: "archivebox.fill")
} else {
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId)
}
}) {
HStack {
Image(systemName: "archivebox")
Text("Bookmark archivieren")
}
.font(.title3.bold())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.footnote)
}
}
.padding(.horizontal)
.padding(.bottom, 32)
}
} }
#Preview { #Preview {

View File

@ -5,6 +5,7 @@ class BookmarkDetailViewModel {
private let getBookmarkUseCase: GetBookmarkUseCase private let getBookmarkUseCase: GetBookmarkUseCase
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = "" var articleContent: String = ""
@ -20,6 +21,7 @@ class BookmarkDetailViewModel {
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase() self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
} }
@MainActor @MainActor
@ -30,11 +32,6 @@ class BookmarkDetailViewModel {
do { do {
settings = try await loadSettingsUseCase.execute() settings = try await loadSettingsUseCase.execute()
bookmarkDetail = try await getBookmarkUseCase.execute(id: id) bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
// Auch das vollständige Bookmark für readProgress laden
// (Falls GetBookmarkUseCase nur BookmarkDetail zurückgibt)
// Du könntest einen separaten UseCase für das vollständige Bookmark erstellen
} catch { } catch {
errorMessage = "Fehler beim Laden des Bookmarks" errorMessage = "Fehler beim Laden des Bookmarks"
} }
@ -57,31 +54,23 @@ class BookmarkDetailViewModel {
} }
private func processArticleContent() { private func processArticleContent() {
// HTML in Paragraphen aufteilen
let paragraphs = articleContent let paragraphs = articleContent
.components(separatedBy: .newlines) .components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
articleParagraphs = paragraphs articleParagraphs = paragraphs
} }
}
@MainActor
extension BookmarkDetail { func archiveBookmark(id: String) async {
static let empty = BookmarkDetail( isLoading = true
id: "", errorMessage = nil
title: "", do {
url: "", try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
description: "", bookmarkDetail.isArchived = true
siteName: "", } catch {
authors: [], errorMessage = "Fehler beim Archivieren des Bookmarks"
created: "", }
updated: "", isLoading = false
wordCount: 0, }
readingTime: 0,
hasArticle: false,
isMarked: false,
isArchived: false,
thumbnailUrl: "",
imageUrl: ""
)
} }

View File

@ -10,14 +10,14 @@ struct BookmarkCardView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Vorschaubild - verwende image oder thumbnail
AsyncImage(url: imageURL) { image in AsyncImage(url: imageURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(height: 120) .frame(height: 120)
} placeholder: { } placeholder: {
Image("placeholder")
Image(R.image.placeholder.name)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(height: 120) .frame(height: 120)
@ -25,14 +25,12 @@ struct BookmarkCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Titel
Text(bookmark.title) Text(bookmark.title)
.font(.headline) .font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
// Meta-Info mit Datum
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {

View File

@ -2,9 +2,13 @@
<!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.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<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>
<array>
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
</array>
</dict> </dict>
</plist> </plist>