feat(share): dup-aware interactive save card with AI tag suggestions
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:
Krishna Kumar
2026-06-23 20:36:46 -05:00
parent a3059f25fc
commit e82e69c5f3
10 changed files with 454 additions and 88 deletions

View File

@@ -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)
}
}