Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart codebase entirely in favour of a lean native app with no third-party dependencies. Features shipped: - Bookmark list with pagination, pull-to-refresh, swipe delete/archive - Add bookmark form + iOS share extension (zero-tap save from any app) - Tags tab — all tags sorted by count, tap to browse filtered bookmarks - Native search tab (Tab role: .search) with instant client-side filtering - AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary and tag generation, semantic search, smart collections - Settings: linkding server config, OpenRouter API key - App Groups for credential sharing between main app and share extension - Swift 6 strict concurrency throughout (@Observable, @MainActor) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
5.3 KiB
Swift
130 lines
5.3 KiB
Swift
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
@objc(ShareViewController)
|
|
class ShareViewController: UIViewController {
|
|
|
|
private let appGroupId = "group.com.magicive.marks"
|
|
private let configKey = "serverConfig"
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
|
blur.frame = view.bounds
|
|
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
view.addSubview(blur)
|
|
extractURLAndSave()
|
|
}
|
|
|
|
private func extractURLAndSave() {
|
|
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
|
let attachments = item.attachments else {
|
|
showResult(success: false, message: "Nothing to save")
|
|
return
|
|
}
|
|
|
|
func load(typeId: String) -> Bool {
|
|
guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else {
|
|
return false
|
|
}
|
|
attachment.loadItem(forTypeIdentifier: typeId, options: nil) { [weak self] item, _ in
|
|
let urlString = typeId == UTType.url.identifier
|
|
? ((item as? URL)?.absoluteString ?? (item as? String))
|
|
: (item as? String)
|
|
DispatchQueue.main.async {
|
|
if let url = urlString { self?.save(url: url) }
|
|
else { self?.showResult(success: false, message: "No URL found") }
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
|
showResult(success: false, message: "No URL found")
|
|
}
|
|
}
|
|
|
|
private func save(url: String) {
|
|
guard let groupDefaults = UserDefaults(suiteName: appGroupId),
|
|
let data = groupDefaults.data(forKey: configKey),
|
|
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let host = config["host"] as? String,
|
|
let token = config["token"] as? String,
|
|
let useHttps = config["useHttps"] as? Bool else {
|
|
showResult(success: false, message: "Open Marks first")
|
|
return
|
|
}
|
|
|
|
var base = (useHttps ? "https" : "http") + "://" + host
|
|
if let port = config["port"] as? Int { base += ":\(port)" }
|
|
if let path = config["path"] as? String, !path.isEmpty { base += path }
|
|
|
|
guard let apiUrl = URL(string: "\(base)/api/bookmarks/") else {
|
|
showResult(success: false, message: "Bad server URL")
|
|
return
|
|
}
|
|
|
|
var req = URLRequest(url: apiUrl)
|
|
req.httpMethod = "POST"
|
|
req.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
|
"url": url, "is_archived": false, "unread": false, "shared": false
|
|
])
|
|
|
|
URLSession.shared.dataTask(with: req) { [weak self] _, response, error in
|
|
DispatchQueue.main.async {
|
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
let ok = error == nil && (code == 200 || code == 201)
|
|
self?.showResult(success: ok, message: ok ? "Bookmarked" : "Failed to save")
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func showResult(success: Bool, message: String) {
|
|
let card = UIView()
|
|
card.backgroundColor = success ? .systemGreen : .systemRed
|
|
card.layer.cornerRadius = 16
|
|
card.layer.cornerCurve = .continuous
|
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
card.alpha = 0
|
|
|
|
let stack = UIStackView()
|
|
stack.axis = .horizontal
|
|
stack.spacing = 10
|
|
stack.alignment = .center
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let icon = UIImageView(image: UIImage(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill"))
|
|
icon.tintColor = .white
|
|
icon.preferredSymbolConfiguration = .init(pointSize: 22, weight: .semibold)
|
|
|
|
let label = UILabel()
|
|
label.text = message
|
|
label.textColor = .white
|
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
|
|
|
stack.addArrangedSubview(icon)
|
|
stack.addArrangedSubview(label)
|
|
card.addSubview(stack)
|
|
view.addSubview(card)
|
|
|
|
NSLayoutConstraint.activate([
|
|
stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
|
stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
|
stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 24),
|
|
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
card.heightAnchor.constraint(equalToConstant: 56),
|
|
card.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
|
])
|
|
|
|
UIView.animate(withDuration: 0.2) { card.alpha = 1 }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.8 : 2.0)) { [weak self] in
|
|
UIView.animate(withDuration: 0.15, animations: { card.alpha = 0 }) { _ in
|
|
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|