feat(share): dup-aware interactive save card with AI tag suggestions
All checks were successful
CI / build-and-deploy (push) Successful in 20s
All checks were successful
CI / build-and-deploy (push) Successful in 20s
Replace the fire-and-forget share toast with an interactive SwiftUI card. On open it checks linkding's /api/bookmarks/check/ so an already-saved URL routes to an Update (no more silent duplicates) and a new URL shows a save form with title, tags, notes, and a read-later toggle. Tag suggestions are hybrid: fetch the user's existing tag vocabulary (/api/tags/) and AI tag ideas (/v1/marks/enrich via anonymous device auth), auto-fill known-vocabulary matches into the field and surface net-new ideas as tappable chips. Best-effort and non-blocking — failures never block save. Also: - Refresh bookmarks when the app returns to the foreground (scenePhase). - Render bookmark dates as a static relative label instead of the live ticking RelativeDateTime timer. - Share LinkdingAPI/Bookmark/ServerConfig/MarksAuth into the ShareExtension target via project.yml (durable across xcodegen regen) and regenerate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,23 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
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()
|
||||
extractURL()
|
||||
}
|
||||
|
||||
private func extractURLAndSave() {
|
||||
private func extractURL() {
|
||||
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = item.attachments else {
|
||||
showResult(success: false, message: "Nothing to save")
|
||||
present(url: nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,99 +29,36 @@ class ShareViewController: UIViewController {
|
||||
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") }
|
||||
}
|
||||
DispatchQueue.main.async { self?.present(url: urlString) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
||||
showResult(success: false, message: "No URL found")
|
||||
present(url: nil)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
|
||||
private func present(url: String?) {
|
||||
let root = ShareView(url: url ?? "") { [weak self] _ in
|
||||
self?.close()
|
||||
}
|
||||
let host = UIHostingController(rootView: root)
|
||||
host.view.backgroundColor = .clear
|
||||
addChild(host)
|
||||
host.view.frame = view.bounds
|
||||
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
|
||||
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
|
||||
if url == nil {
|
||||
// No URL to work with — let the card render its failure state briefly.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
private func close() {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user