Compare commits
2 Commits
e88693363b
...
624816d914
| Author | SHA1 | Date | |
|---|---|---|---|
| 624816d914 | |||
| 1763dd6fa1 |
290
Localizable.xcstrings
Normal file
290
Localizable.xcstrings
Normal 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"
|
||||
}
|
||||
49
README.md
49
README.md
@ -10,32 +10,23 @@ https://codeberg.org/readeck/readeck
|
||||
|
||||
## 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
|
||||
- Swipe actions for quick bookmark management
|
||||
- Native iOS design with Dark Mode support
|
||||
|
||||
## Requirements
|
||||
|
||||
- iOS 17.0+
|
||||
- Xcode 15.0+
|
||||
- Swift 5.9+
|
||||
- Full iPad Support with Multi-Column Split View
|
||||
- Font Customization
|
||||
- Article View with Reading Time and Word Count
|
||||
- Search functionality
|
||||
|
||||
## Configuration
|
||||
|
||||
After installing the app:
|
||||
|
||||
1. Open the readeck app
|
||||
2. Go to the **Settings** tab
|
||||
3. Enter your readeck server URL and credentials
|
||||
4. The app will automatically sync your bookmarks
|
||||
2. Enter your readeck server URL and credentials
|
||||
3. The app will automatically load your bookmarks
|
||||
|
||||
## Architecture
|
||||
|
||||
- **SwiftUI** for UI
|
||||
- **Core Data** for local storage
|
||||
- **MVVM** architecture pattern
|
||||
- **Repository pattern** for data access
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
@ -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`)
|
||||
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
|
||||
|
||||
|
||||
@ -9,58 +9,228 @@ import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private var extractedURL: 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() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
extractSharedContent()
|
||||
|
||||
// Automatisch die Haupt-App öffnen, sobald URL extrahiert wurde
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.openParentApp()
|
||||
}
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
return true // Immer true, damit der Button funktioniert
|
||||
|
||||
|
||||
// MARK: - UI Setup
|
||||
private func setupUI() {
|
||||
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
|
||||
|
||||
// Add cancel button
|
||||
let cancelButton = UIBarButtonItem(title: "Abbrechen", style: .plain, target: self, action: #selector(cancelButtonTapped))
|
||||
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 didSelectPost() {
|
||||
openMainApp()
|
||||
private func setupConstraints() {
|
||||
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)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
// MARK: - Content Extraction
|
||||
private func extractSharedContent() {
|
||||
guard let extensionContext = extensionContext else { return }
|
||||
|
||||
print("=== DEBUG: Starting content extraction ===")
|
||||
print("Input items count: \(extensionContext.inputItems.count)")
|
||||
|
||||
for (itemIndex, item) in extensionContext.inputItems.enumerated() {
|
||||
for item in extensionContext.inputItems {
|
||||
guard let inputItem = item as? NSExtensionItem else { continue }
|
||||
|
||||
print("Item \(itemIndex) - attachments: \(inputItem.attachments?.count ?? 0)")
|
||||
|
||||
// Versuche alle verfügbaren Type Identifiers
|
||||
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
|
||||
}
|
||||
|
||||
for attachment in inputItem.attachments ?? [] {
|
||||
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
|
||||
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) {
|
||||
print("Processing item of type \(typeIdentifier): \(type(of: item))")
|
||||
// MARK: - Actions
|
||||
|
||||
// URL direkt
|
||||
if let url = item as? URL {
|
||||
print("Found URL: \(url.absoluteString)")
|
||||
extractedURL = url.absoluteString
|
||||
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
|
||||
return
|
||||
}
|
||||
@objc private func saveButtonTapped() {
|
||||
let title = titleTextField?.text ?? ""
|
||||
|
||||
// NSURL
|
||||
if let nsurl = item as? NSURL {
|
||||
print("Found NSURL: \(nsurl.absoluteString ?? "nil")")
|
||||
extractedURL = nsurl.absoluteString
|
||||
extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string
|
||||
return
|
||||
}
|
||||
saveButton?.isEnabled = false
|
||||
activityIndicator?.startAnimating()
|
||||
|
||||
// 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
|
||||
Task {
|
||||
await addBookmarkViaAPI(title: title)
|
||||
await MainActor.run {
|
||||
self.saveButton?.isEnabled = true
|
||||
self.activityIndicator?.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("Could not process item of type: \(type(of: item))")
|
||||
}
|
||||
|
||||
private func extractURLFromText(_ text: String) -> String? {
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
|
||||
if let match = detector?.firstMatch(in: text, options: [], range: range),
|
||||
let url = match.url {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handlePropertyList(_ dictionary: [String: Any]) {
|
||||
// Safari und andere Browser verwenden oft Property Lists
|
||||
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() {
|
||||
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() {
|
||||
@objc private func cancelButtonTapped() {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
|
||||
func openParentApp() {
|
||||
guard let extensionContext = extensionContext else { return }
|
||||
|
||||
let url = extractedURL ?? "https://example.com"
|
||||
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")
|
||||
// MARK: - API Call
|
||||
private func addBookmarkViaAPI(title: String) async {
|
||||
guard let url = extractedURL, !url.isEmpty else {
|
||||
showStatus("Keine URL gefunden.", error: true)
|
||||
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 {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(finalURL)
|
||||
break
|
||||
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
|
||||
guard let requestData = try? JSONEncoder().encode(requestDto) else {
|
||||
showStatus("Fehler beim Kodieren der Anfrage.", error: true)
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
10
URLShare/URLShare.entitlements
Normal file
10
URLShare/URLShare.entitlements
Normal 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>
|
||||
@ -9,6 +9,10 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -64,6 +68,7 @@
|
||||
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; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -77,7 +82,9 @@
|
||||
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Assets.xcassets,
|
||||
Data/CoreData/CoreDataManager.swift,
|
||||
Data/KeychainHelper.swift,
|
||||
Domain/Model/Bookmark.swift,
|
||||
readeck.xcdatamodeld,
|
||||
);
|
||||
@ -135,6 +142,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||
5DA241FD2E17C3B3007531C3 /* rswift in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -158,6 +167,7 @@
|
||||
5D45F9BF2DF858680048D5B8 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5DA242122E17D31A007531C3 /* Localizable.xcstrings */,
|
||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
||||
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
||||
@ -215,6 +225,7 @@
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */,
|
||||
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
@ -223,6 +234,8 @@
|
||||
name = readeck;
|
||||
packageProductDependencies = (
|
||||
5D348CC22E0C9F4F00D0AF21 /* netfox */,
|
||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||
5DA241FC2E17C3B3007531C3 /* rswift */,
|
||||
);
|
||||
productName = readeck;
|
||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||
@ -306,11 +319,14 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"fr-CA",
|
||||
de,
|
||||
);
|
||||
mainGroup = 5D45F9BF2DF858680048D5B8;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */,
|
||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||
@ -330,6 +346,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5DA242132E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -337,6 +354,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5DA242142E17D31A007531C3 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -403,12 +421,17 @@
|
||||
target = 5D45F9C72DF858680048D5B8 /* readeck */;
|
||||
targetProxy = 5D45F9E92DF8586A0048D5B8 /* PBXContainerItemProxy */;
|
||||
};
|
||||
5DA241FF2E17C3CE007531C3 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 5DA241FE2E17C3CE007531C3 /* RswiftGenerateInternalResources */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
5D2B7FBC2DFA27A400EBDB2B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
@ -437,6 +460,7 @@
|
||||
5D2B7FBD2DFA27A400EBDB2B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
@ -520,6 +544,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
@ -574,6 +599,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@ -592,7 +618,6 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = readeck/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@ -635,7 +660,6 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = readeck/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@ -812,6 +836,14 @@
|
||||
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 */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@ -820,6 +852,21 @@
|
||||
package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "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 */
|
||||
};
|
||||
rootObject = 5D45F9C02DF858680048D5B8 /* Project object */;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "7374154e7686de69a9f88fbafb081b646b02140f8d82770f46fa750840581e0e",
|
||||
"originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "netfox",
|
||||
@ -9,6 +9,33 @@
|
||||
"revision" : "557576032736fd3140422baefb68b8f76c55088f",
|
||||
"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
|
||||
|
||||
@ -23,9 +23,9 @@
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x5B",
|
||||
"green" : "0x4D",
|
||||
"red" : "0x00"
|
||||
"blue" : "0x39",
|
||||
"green" : "0x37",
|
||||
"red" : "0x2E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
||||
@ -158,28 +158,28 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
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 requestData = try JSONEncoder().encode(loginRequest)
|
||||
guard let url = URL(string: endpoint + "/api/auth") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = requestData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
let userDto = try JSONDecoder().decode(UserDto.self, from: data)
|
||||
// Token NICHT automatisch speichern, da Settings noch nicht existieren
|
||||
return userDto
|
||||
|
||||
return try JSONDecoder().decode(UserDto.self, from: data)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var endpoint = "/api/bookmarks"
|
||||
var queryItems: [URLQueryItem] = []
|
||||
|
||||
58
readeck/Data/KeychainHelper.swift
Normal file
58
readeck/Data/KeychainHelper.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -89,41 +89,3 @@ class BookmarksRepository: PBookmarksRepository {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ class CoreDataTokenProvider: TokenProvider {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private var cachedSettings: Settings?
|
||||
private var isLoaded = false
|
||||
private let keychainHelper = KeychainHelper.shared
|
||||
|
||||
private func loadSettingsIfNeeded() async {
|
||||
guard !isLoaded else { return }
|
||||
@ -40,6 +41,7 @@ class CoreDataTokenProvider: TokenProvider {
|
||||
|
||||
do {
|
||||
try await settingsRepository.saveToken(token)
|
||||
saveTokenToKeychain(token: token)
|
||||
if cachedSettings != nil {
|
||||
cachedSettings!.token = token
|
||||
}
|
||||
@ -52,8 +54,19 @@ class CoreDataTokenProvider: TokenProvider {
|
||||
do {
|
||||
try await settingsRepository.clearSettings()
|
||||
cachedSettings = nil
|
||||
saveTokenToKeychain(token: "")
|
||||
} catch {
|
||||
print("Failed to clear settings: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain Support
|
||||
|
||||
func saveTokenToKeychain(token: String) {
|
||||
keychainHelper.saveToken(token)
|
||||
}
|
||||
|
||||
func loadTokenFromKeychain() -> String? {
|
||||
keychainHelper.loadToken()
|
||||
}
|
||||
}
|
||||
|
||||
21
readeck/Domain/Error/CreateBookmarkError.swift
Normal file
21
readeck/Domain/Error/CreateBookmarkError.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
39
readeck/Domain/Model/BookmarkDetail.swift
Normal file
39
readeck/Domain/Model/BookmarkDetail.swift
Normal 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: ""
|
||||
)
|
||||
}
|
||||
@ -35,6 +35,9 @@ class LogoutUseCase: LogoutUseCaseProtocol {
|
||||
try await settingsRepository.saveUsername("")
|
||||
try await settingsRepository.savePassword("")
|
||||
|
||||
KeychainHelper.shared.saveToken("")
|
||||
KeychainHelper.shared.saveEndpoint("")
|
||||
|
||||
// Note: We keep the endpoint for potential re-login
|
||||
// but clear the authentication data
|
||||
|
||||
|
||||
@ -9,5 +9,8 @@ class SaveServerSettingsUseCase {
|
||||
|
||||
func execute(endpoint: String, username: String, password: String, token: String) async throws {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ struct BookmarkDetailView: View {
|
||||
Divider().padding(.horizontal)
|
||||
contentSection
|
||||
Spacer(minLength: 40)
|
||||
archiveSection
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -190,6 +191,39 @@ struct BookmarkDetailView: View {
|
||||
}
|
||||
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 {
|
||||
|
||||
@ -5,6 +5,7 @@ class BookmarkDetailViewModel {
|
||||
private let getBookmarkUseCase: GetBookmarkUseCase
|
||||
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||
private let updateBookmarkUseCase: UpdateBookmarkUseCase
|
||||
|
||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||
var articleContent: String = ""
|
||||
@ -20,6 +21,7 @@ class BookmarkDetailViewModel {
|
||||
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
|
||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -30,11 +32,6 @@ class BookmarkDetailViewModel {
|
||||
do {
|
||||
settings = try await loadSettingsUseCase.execute()
|
||||
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 {
|
||||
errorMessage = "Fehler beim Laden des Bookmarks"
|
||||
}
|
||||
@ -57,31 +54,23 @@ class BookmarkDetailViewModel {
|
||||
}
|
||||
|
||||
private func processArticleContent() {
|
||||
// HTML in Paragraphen aufteilen
|
||||
let paragraphs = articleContent
|
||||
.components(separatedBy: .newlines)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
articleParagraphs = paragraphs
|
||||
}
|
||||
}
|
||||
|
||||
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: ""
|
||||
)
|
||||
@MainActor
|
||||
func archiveBookmark(id: String) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true)
|
||||
bookmarkDetail.isArchived = true
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Archivieren des Bookmarks"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,14 +10,14 @@ struct BookmarkCardView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Vorschaubild - verwende image oder thumbnail
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
} placeholder: {
|
||||
Image("placeholder")
|
||||
|
||||
Image(R.image.placeholder.name)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
@ -25,14 +25,12 @@ struct BookmarkCardView: View {
|
||||
.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 {
|
||||
|
||||
|
||||
@ -6,5 +6,9 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)de.ilyashallak.readeck2</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user