From f49af2c7d808af23f743e73bc67b38445713aa5b Mon Sep 17 00:00:00 2001 From: spongycode Date: Tue, 15 Sep 2026 14:52:48 +0530 Subject: [PATCH] add developer smart cards, backup/restore, and sensory copy feedback --- ARCHITECTURE.md | 9 +- README.md | 3 +- Sources/ClapApp/AppState+Pasteboard.swift | 18 + Sources/ClapApp/CopyFeedback.swift | 13 + Sources/ClapApp/PreviewCards.swift | 559 ++++++++++++++++++ Sources/ClapApp/PreviewPanel.swift | 430 ++------------ .../ClapApp/SettingsView+Persistence.swift | 53 ++ Sources/ClapApp/SettingsView.swift | 2 + Sources/ClapCLIKit/ClapCLI.swift | 3 + .../ClapCLIKit/Commands/BackupCommand.swift | 48 ++ Sources/ClapCore/ClipboardStore+Backup.swift | 202 +++++++ Sources/ClapCore/ClipboardStore+Capture.swift | 9 +- Sources/ClapCore/JSONRepair.swift | 378 ++++++++++++ Sources/ClapCore/QRCodeBuilder.swift | 50 ++ Sources/ClapCore/TextAnalysis.swift | 111 ++++ Tests/ClapCoreTests/BackupTests.swift | 118 ++++ Tests/ClapCoreTests/JSONRepairTests.swift | 135 +++++ Tests/ClapCoreTests/QRCodeTests.swift | 37 ++ Tests/ClapCoreTests/TextAnalysisTests.swift | 68 +++ 19 files changed, 1864 insertions(+), 382 deletions(-) create mode 100644 Sources/ClapApp/CopyFeedback.swift create mode 100644 Sources/ClapApp/PreviewCards.swift create mode 100644 Sources/ClapCLIKit/Commands/BackupCommand.swift create mode 100644 Sources/ClapCore/ClipboardStore+Backup.swift create mode 100644 Sources/ClapCore/JSONRepair.swift create mode 100644 Sources/ClapCore/QRCodeBuilder.swift create mode 100644 Tests/ClapCoreTests/BackupTests.swift create mode 100644 Tests/ClapCoreTests/JSONRepairTests.swift create mode 100644 Tests/ClapCoreTests/QRCodeTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 872a99b..b146c5b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,8 +11,8 @@ this file. dedup, LRU eviction, settings, image file store, stats, doctor checks, OCR text extraction, and shared text analysis (color/case/Base64/URL/JWT/epoch). **No AppKit/SwiftUI imports** (Foundation + CoreGraphics/ImageIO + - UniformTypeIdentifiers + CryptoKit + Vision allowed — Vision powers the - injectable `OCREngine`). + UniformTypeIdentifiers + CryptoKit + CoreImage + Vision allowed — Vision + powers the injectable `OCREngine`, CoreImage the QR generator). - `ClapApp` (executable): NSApplication accessory app. Pasteboard monitor, Carbon global hotkey (configurable), SwiftUI floating panel (Classic/Media/ Shell/Favs tabs), menu bar item, settings window. @@ -350,8 +350,9 @@ byte-size eviction, pinned immunity, clear, search (terms, phrase, type filter), regex search incl. invalid pattern error, ByteSize parse/format, SearchQuery.parse, retention, OCR seam (mocked engine stores searchable text), injected-clock determinism, text analysis (color/case/transform/JWT/epoch), -TextSummaries, ImageFormats, CLI ArgParser/OutputFormatter, and the app's -AppState logic (hover-selection gate, tab→query mapping). +TextSummaries, ImageFormats, QR generation, JSON detection, color format +rendering, backup export/restore round-trip, CLI ArgParser/OutputFormatter, +and the app's AppState logic (hover-selection gate, tab→query mapping). CI (`.github/workflows/ci.yml`) enforces three gates on every push: `swift build -Xswiftc -warnings-as-errors`, full `swift test`, and a zero- diff --git a/README.md b/README.md index 8250c2a..28018ad 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - **Screenshot OCR & Search** — Apple Vision extracts text from screenshots in the background; search inside images (`⌘F`) or copy text with one click. - **Global Snippet Expansion** — Assign trigger abbreviations (e.g. `;email`, `!zoom`, `brb`) to saved snippets to auto-expand them anywhere as you type. - **Permanent Favorites & Snippets** — Bookmark canned replies, email signatures, code snippets, and commands (`⌘S` / `⌘3`). -- **Developer Smart Cards** — Automatic previews and one-click actions for Base64, URL encoding, JWT tokens, and Unix timestamps. +- **Developer Smart Cards** — Automatic previews and one-click actions for Base64, URL encoding, JWT tokens, Unix timestamps, **JSON** (pretty/minify), **QR codes** (scan-to-phone), and **color formats** (HEX/RGB/HSL). - **Text Case & Encoding Transforms** — Convert text on the fly (`camelCase`, `snake_case`, Base64, URL encode/decode). - **Smart Color Swatch Detection** — Recognizes `#hex`, `rgb()`, `rgba()`, `hsl()` color codes with live inline circle swatches. - **Search Match Highlighting** — High-contrast match highlighting across list items and preview pane for terms, phrases, and regex. @@ -139,6 +139,7 @@ clap add | - # Insert an entry (alias: clap echo "piped" | clap add - # Insert from a pipe clap delete | --text | --regex # Delete entries (alias: clap out) clap pin / clap unpin # Pin/unpin entries +clap backup / clap restore # Full-history backup & restore clap clear [--force] # Wipe history (preserves pinned) clap stats [--json] # Storage and activity metrics clap config get [key] / set # Manage limits, retention, exclusions diff --git a/Sources/ClapApp/AppState+Pasteboard.swift b/Sources/ClapApp/AppState+Pasteboard.swift index b0dad14..fc86462 100644 --- a/Sources/ClapApp/AppState+Pasteboard.swift +++ b/Sources/ClapApp/AppState+Pasteboard.swift @@ -26,6 +26,12 @@ extension AppState { /// Returns false when an image copy failed (panel stays open so the user /// sees that nothing happened). private func writeToPasteboard(_ entry: ClipboardEntry) async -> Bool { + let succeeded = await writeToPasteboardInner(entry) + if succeeded { CopyFeedback.play() } + return succeeded + } + + private func writeToPasteboardInner(_ entry: ClipboardEntry) async -> Bool { let pasteboard = NSPasteboard.general switch entry.type { case .text: @@ -110,6 +116,18 @@ extension AppState { return image } + /// Copies generated image data (e.g. a QR PNG) to the pasteboard. No + /// monitor coordination: the pasteboard monitor captures it as a new + /// image entry, mirroring copyTransformedText's behavior for text. + func copyGeneratedImage(_ data: Data) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + if pasteboard.setData(data, forType: .png) { + CopyFeedback.play() + } + IPC.post(.storeChanged) + } + /// Loads the full-resolution image for preview rendering (not cached). func fullImage(for entry: ClipboardEntry) async -> NSImage? { guard let url = await store.imageFileURL(for: entry) else { return nil } diff --git a/Sources/ClapApp/CopyFeedback.swift b/Sources/ClapApp/CopyFeedback.swift new file mode 100644 index 0000000..96788d2 --- /dev/null +++ b/Sources/ClapApp/CopyFeedback.swift @@ -0,0 +1,13 @@ +import AppKit + +/// Sensory confirmation for successful copies: a subtle system sound plus a +/// trackpad haptic tick. Both are best-effort — silence on any failure. +enum CopyFeedback { + private static let sound = NSSound(named: "Pop") + + static func play() { + sound?.play() + NSHapticFeedbackManager.defaultPerformer.perform( + .alignment, performanceTime: .now) + } +} diff --git a/Sources/ClapApp/PreviewCards.swift b/Sources/ClapApp/PreviewCards.swift new file mode 100644 index 0000000..cd9558f --- /dev/null +++ b/Sources/ClapApp/PreviewCards.swift @@ -0,0 +1,559 @@ +import AppKit +import SwiftUI +import ClapCore + +// MARK: - Feature cards + +struct ColorCardView: View { + @EnvironmentObject private var state: AppState + let color: ParsedColor + let source: String + + private var formats: ParsedColor.Formats { color.formats } + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(red: color.red, green: color.green, blue: color.blue, + opacity: color.alpha)) + .frame(width: 46, height: 46) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.18), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1) + + VStack(alignment: .leading, spacing: 3) { + Text("Color Preview") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Text(source.trimmingCharacters(in: .whitespacesAndNewlines)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + + HStack(spacing: 6) { + formatCopyButton("HEX", formats.hex) + formatCopyButton("RGB", formats.rgb) + formatCopyButton("HSL", formats.hsl) + } + .padding(.top, 2) + } + Spacer() + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("Color preview: \(source)") + } + + private func formatCopyButton(_ label: String, _ value: String) -> some View { + Button { + state.copyTransformedText(value) + } label: { + Text(label) + .font(.system(size: 9.5, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule().fill(Color.primary.opacity(AppAlpha.Fill.soft)) + ) + } + .buttonStyle(.plain) + .help("Copy as \(label)") + .accessibilityLabel("Copy color as \(label)") + } +} + +struct DecodedCardView: View { + let icon: String + let tint: Color + let title: String + let decoded: String + let onCopy: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(tint) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Button(action: onCopy) { + Label("Copy Decoded", systemImage: "doc.on.doc") + .font(.system(size: 10)) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + Text(decoded) + .font(.system(size: 11.5, design: .monospaced)) + .lineLimit(3) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tint.opacity(0.06)) + ) + } +} + +struct JWTCardView: View { + @EnvironmentObject private var state: AppState + let jwt: JWTData + let onCopy: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "key.horizontal.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.indigo) + Text("JWT Inspector") + .font(.system(size: 12.5, weight: .bold)) + .foregroundStyle(.primary) + + Text(jwt.algorithm) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.primary.opacity(0.08)) + ) + + if let isExp = jwt.isExpired { + HStack(spacing: 3) { + Circle() + .fill(isExp ? Color.red : Color.green) + .frame(width: 6, height: 6) + Text(isExp ? "Expired" : "Valid") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(isExp ? .red : .green) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill((isExp ? Color.red : Color.green).opacity(0.12)) + ) + } + + Spacer() + + Menu { + Button("Copy Payload JSON") { onCopy(jwt.payloadJSON) } + Button("Copy Header JSON") { onCopy(jwt.headerJSON) } + } label: { + Label("Copy JSON", systemImage: "doc.on.doc") + .font(.system(size: 10)) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + + if jwt.subject != nil || jwt.issuer != nil || jwt.expirationDate != nil { + VStack(alignment: .leading, spacing: 3) { + if let sub = jwt.subject { + claimRow(label: "Subject:", value: sub, monospaced: true) + } + if let iss = jwt.issuer { + claimRow(label: "Issuer:", value: iss, monospaced: true) + } + if let expDate = jwt.expirationDate { + HStack(spacing: 6) { + Text("Expires:") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.secondary) + Text(Self.dateFormatter.string(from: expDate)) + .font(.system(size: 11)) + } + } + } + } + + Divider() + + Text("Decoded Payload:") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.secondary) + + Text(jwt.payloadJSON) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) + ) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.indigo.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.indigo.opacity(0.18), lineWidth: 1) + ) + ) + } + + private func claimRow(label: String, value: String, monospaced: Bool) -> some View { + HStack(spacing: 6) { + Text(label) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 11, design: monospaced ? .monospaced : .default)) + .lineLimit(1) + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +struct EpochCardView: View { + @EnvironmentObject private var state: AppState + let epoch: EpochData + let onCopy: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "clock.badge.checkmark.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 3) { + Text("Epoch Timestamp") + .font(.system(size: 12.5, weight: .bold)) + .foregroundStyle(.primary) + + Text(epoch.unitDescription) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.orange.opacity(0.12)) + ) + } + + Spacer() + + Menu { + Button("Copy ISO 8601 (\(epoch.iso8601))") { onCopy(epoch.iso8601) } + Button("Copy Local Date") { onCopy(epoch.localFormatted) } + if epoch.unitDescription.contains("Seconds") { + Button("Copy as Milliseconds (\(epoch.unixMillis))") { + onCopy(String(epoch.unixMillis)) + } + } else { + Button("Copy as Seconds (\(epoch.unixSeconds))") { + onCopy(String(epoch.unixSeconds)) + } + } + } label: { + Label("Copy Date", systemImage: "doc.on.doc") + .font(.system(size: 10)) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + + VStack(alignment: .leading, spacing: 5) { + Text(epoch.localFormatted) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(.primary) + .textSelection(.enabled) + + HStack(spacing: 5) { + Text("UTC:") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.secondary) + Text(epoch.iso8601) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + HStack(spacing: 5) { + Text("Relative:") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.secondary) + Text(epoch.relativeFormatted) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) + ) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.orange.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.orange.opacity(0.18), lineWidth: 1) + ) + ) + } +} + +struct ImageContentView: View { + let entry: ClipboardEntry + let image: NSImage? + var showsQR: Bool = false + var onCopyImage: (Data) -> Void = { _ in } + let onCopyText: (String) -> Void + + var body: some View { + ScrollView([.vertical]) { + VStack(spacing: 12) { + ZStack { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.panelBorder), lineWidth: 0.5) + ) + } else { + ProgressView() + .frame(height: 140) + } + } + .frame(maxWidth: .infinity) + .accessibilityLabel("Image preview, \(entry.imageFormat?.uppercased() ?? "unknown format")") + .padding(.top, 4) + + if showsQR, let ocrSource = entry.content, QRCodeBuilder.canEncode(ocrSource) { + QRCardView(content: ocrSource) { png in + onCopyImage(png) + } + } + + if let ocrText = entry.content, !ocrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label("Extracted Text (OCR)", systemImage: "doc.text.viewfinder") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Button { onCopyText(ocrText) } label: { + Label("Copy Text", systemImage: "doc.on.doc") + .font(.system(size: 10)) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + + Text(ocrText) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) + ) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.03)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5) + ) + ) + } + } + .padding(14) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - QR code card + +/// Renders clipboard text as a scannable QR — send links/text to a phone +/// with no network dependency. Content fades in once generated. +struct QRCardView: View { + @EnvironmentObject private var state: AppState + let content: String + let onCopyImage: (Data) -> Void + + @State private var qrImage: NSImage? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.green) + Text("QR Code") + .font(.system(size: 12.5, weight: .bold)) + .foregroundStyle(.primary) + Spacer() + if qrImage != nil { + Button { + if let png = QRCodeBuilder.pngData(for: content) { + onCopyImage(png) + } + } label: { + Label("Copy Image", systemImage: "doc.on.doc") + .font(.system(size: 10)) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .help("Copy the QR image to the clipboard") + } + } + + HStack(alignment: .center, spacing: 12) { + Group { + if let qrImage { + Image(nsImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + } else { + Color.clear.overlay(ProgressView()) + } + } + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.hairline), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 4) { + Text("Scan with a phone camera") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.primary) + Text("Opens this content on another device — no network needed.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.green.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.green.opacity(0.18), lineWidth: 1) + ) + ) + .task(id: content) { + qrImage = nil + let generated = await Task.detached(priority: .userInitiated) { + QRCodeBuilder.pngData(for: content) + }.value + if let data = generated, let image = NSImage(data: data) { + qrImage = image + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("QR code for clipboard content") + } +} + +// MARK: - JSON card + +/// Validated JSON with pretty/minified renderings and one-tap copies. +struct JSONCardView: View { + @EnvironmentObject private var state: AppState + let json: JSONData + var repairedFixes: [String] = [] + let onCopy: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: repairedFixes.isEmpty ? "curlybraces" : "wand.and.rays") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(repairedFixes.isEmpty ? .mint : .orange) + Text(repairedFixes.isEmpty ? "JSON" : "JSON · Repaired") + .font(.system(size: 12.5, weight: .bold)) + .foregroundStyle(.primary) + + Text(json.isTopLevelObject + ? "object · \(json.valueCount) keys" + : "array · \(json.valueCount) items") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill( + (repairedFixes.isEmpty ? Color.mint : Color.orange).opacity(0.12))) + + Spacer() + } + if !repairedFixes.isEmpty { + Text("Fixed: " + repairedFixes.joined(separator: ", ")) + .font(.system(size: 10)) + .foregroundStyle(.orange) + .help("Fixes applied by JSON repair") + } + + HStack(spacing: 6) { + formatCopyButton("Copy Pretty", json.pretty) + formatCopyButton("Copy Minified", json.minified) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill((repairedFixes.isEmpty ? Color.mint : Color.orange).opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder((repairedFixes.isEmpty ? Color.mint : Color.orange).opacity(0.18), lineWidth: 1) + ) + ) + } + + private func formatCopyButton(_ label: String, _ value: String) -> some View { + Button { + onCopy(value) + } label: { + Text(label) + .font(.system(size: 9.5, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(Color.primary.opacity(AppAlpha.Fill.soft))) + } + .buttonStyle(.plain) + .help(label) + .accessibilityLabel(label) + } +} diff --git a/Sources/ClapApp/PreviewPanel.swift b/Sources/ClapApp/PreviewPanel.swift index 28f8a2b..40f11d3 100644 --- a/Sources/ClapApp/PreviewPanel.swift +++ b/Sources/ClapApp/PreviewPanel.swift @@ -131,22 +131,36 @@ private struct ParsedEntryContent { var urlDecoded: String? var jwt: JWTData? var epoch: EpochData? + var json: JSONData? + /// Set when strict parse failed but JSONRepair recovered the document. + var jsonRepair: JSONRepairResult? var hasCards: Bool { - color != nil || base64Decoded != nil || urlDecoded != nil || jwt != nil || epoch != nil + color != nil || base64Decoded != nil || urlDecoded != nil || jwt != nil + || epoch != nil || json != nil || jsonRepair != nil } static let empty = ParsedEntryContent() static func parse(_ content: String?) -> ParsedEntryContent { guard let content, !content.isEmpty, content.count <= 20_000 else { return .empty } - return ParsedEntryContent( + var result = ParsedEntryContent( color: ColorParser.parse(content), base64Decoded: TextTransformer.decodeBase64(content), urlDecoded: TextTransformer.decodeURL(content), jwt: JWTData.parse(content), - epoch: EpochData.parse(content) + epoch: EpochData.parse(content), + json: JSONData.parse(content) ) + if result.json == nil { + result.jsonRepair = JSONRepair.repair(content) + } else if let repair = JSONRepair.repair(content), repair.repaired != content { + // Newer macOS JSONSerialization accepts JSON5-ish input (trailing + // commas etc.). Parse success alone doesn't mean strict JSON — + // prefer the repaired card so sloppiness is still surfaced. + result.jsonRepair = repair + } + return result } } @@ -156,6 +170,7 @@ struct PreviewView: View { @State private var image: NSImage? @State private var parsed: ParsedEntryContent = .empty + @State private var qrVisible = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -166,6 +181,7 @@ struct PreviewView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task(id: entry.id) { + qrVisible = false parsed = await Task.detached(priority: .userInitiated) { ParsedEntryContent.parse(entry.content) }.value @@ -181,7 +197,9 @@ struct PreviewView: View { private var contentSection: some View { if entry.type == .text || entry.type == .shell { VStack(alignment: .leading, spacing: 0) { - if parsed.hasCards { + // QR is user-requested (toggle), not content-detected — so it + // must render even when no smart cards were detected. + if parsed.hasCards || qrVisible { VStack(alignment: .leading, spacing: 10) { if let color = parsed.color { ColorCardView(color: color, source: entry.content ?? "") @@ -204,6 +222,23 @@ struct PreviewView: View { if let epoch = parsed.epoch { EpochCardView(epoch: epoch) { text in state.copyTransformedText(text) } } + if qrVisible, let content = entry.content, + QRCodeBuilder.canEncode(content) { + QRCardView(content: content) { png in + state.copyGeneratedImage(png) + } + } + if let repair = parsed.jsonRepair, + let repaired = JSONData.parse(repair.repaired) { + JSONCardView(json: repaired, + repairedFixes: repair.fixes) { text in + state.copyTransformedText(text) + } + } else if let json = parsed.json { + JSONCardView(json: json) { text in + state.copyTransformedText(text) + } + } } .padding(.horizontal, 14) .padding(.top, 12) @@ -229,9 +264,10 @@ struct PreviewView: View { } } } else { - ImageContentView(entry: entry, image: image) { text in - state.copyTransformedText(text) - } + ImageContentView(entry: entry, image: image, + showsQR: qrVisible, + onCopyImage: { state.copyGeneratedImage($0) }, + onCopyText: { state.copyTransformedText($0) }) } } @@ -286,6 +322,14 @@ struct PreviewView: View { .accessibilityLabel("Copy encoded or decoded text") } + if let qrContent = entry.content, QRCodeBuilder.canEncode(qrContent) { + IconActionButton(systemImage: "qrcode", + help: qrVisible ? "Hide QR code" : "Show QR code", + isSelected: qrVisible) { + qrVisible.toggle() + } + } + if entry.type == .text || entry.type == .shell { IconActionButton(systemImage: entry.shortcut != nil ? "bolt.fill" : "bolt", help: entry.shortcut != nil @@ -459,376 +503,13 @@ struct PreviewView: View { } } -// MARK: - Feature cards - -private struct ColorCardView: View { - let color: ParsedColor - let source: String - - var body: some View { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(red: color.red, green: color.green, blue: color.blue, - opacity: color.alpha)) - .frame(width: 46, height: 46) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.primary.opacity(0.18), lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1) - - VStack(alignment: .leading, spacing: 3) { - Text("Color Preview") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) - Text(source.trimmingCharacters(in: .whitespacesAndNewlines)) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel("Color preview: \(source)") - } -} - -private struct DecodedCardView: View { - let icon: String - let tint: Color - let title: String - let decoded: String - let onCopy: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: icon) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(tint) - .frame(width: 22, height: 22) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(title) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.primary) - Spacer() - Button(action: onCopy) { - Label("Copy Decoded", systemImage: "doc.on.doc") - .font(.system(size: 10)) - } - .buttonStyle(.bordered) - .controlSize(.mini) - } - Text(decoded) - .font(.system(size: 11.5, design: .monospaced)) - .lineLimit(3) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(tint.opacity(0.06)) - ) - } -} - -private struct JWTCardView: View { - @EnvironmentObject private var state: AppState - let jwt: JWTData - let onCopy: (String) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "key.horizontal.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.indigo) - Text("JWT Inspector") - .font(.system(size: 12.5, weight: .bold)) - .foregroundStyle(.primary) - - Text(jwt.algorithm) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.primary.opacity(0.08)) - ) - - if let isExp = jwt.isExpired { - HStack(spacing: 3) { - Circle() - .fill(isExp ? Color.red : Color.green) - .frame(width: 6, height: 6) - Text(isExp ? "Expired" : "Valid") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(isExp ? .red : .green) - } - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill((isExp ? Color.red : Color.green).opacity(0.12)) - ) - } - - Spacer() - - Menu { - Button("Copy Payload JSON") { onCopy(jwt.payloadJSON) } - Button("Copy Header JSON") { onCopy(jwt.headerJSON) } - } label: { - Label("Copy JSON", systemImage: "doc.on.doc") - .font(.system(size: 10)) - } - .buttonStyle(.bordered) - .controlSize(.mini) - } - - if jwt.subject != nil || jwt.issuer != nil || jwt.expirationDate != nil { - VStack(alignment: .leading, spacing: 3) { - if let sub = jwt.subject { - claimRow(label: "Subject:", value: sub, monospaced: true) - } - if let iss = jwt.issuer { - claimRow(label: "Issuer:", value: iss, monospaced: true) - } - if let expDate = jwt.expirationDate { - HStack(spacing: 6) { - Text("Expires:") - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(.secondary) - Text(Self.dateFormatter.string(from: expDate)) - .font(.system(size: 11)) - } - } - } - } - - Divider() - - Text("Decoded Payload:") - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(.secondary) - - Text(jwt.payloadJSON) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) - ) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.indigo.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.indigo.opacity(0.18), lineWidth: 1) - ) - ) - } - - private func claimRow(label: String, value: String, monospaced: Bool) -> some View { - HStack(spacing: 6) { - Text(label) - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(size: 11, design: monospaced ? .monospaced : .default)) - .lineLimit(1) - } - } - - private static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter - }() -} - -private struct EpochCardView: View { - @EnvironmentObject private var state: AppState - let epoch: EpochData - let onCopy: (String) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 8) { - Image(systemName: "clock.badge.checkmark.fill") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.orange) - - VStack(alignment: .leading, spacing: 3) { - Text("Epoch Timestamp") - .font(.system(size: 12.5, weight: .bold)) - .foregroundStyle(.primary) - - Text(epoch.unitDescription) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .lineLimit(1) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.orange.opacity(0.12)) - ) - } - - Spacer() - - Menu { - Button("Copy ISO 8601 (\(epoch.iso8601))") { onCopy(epoch.iso8601) } - Button("Copy Local Date") { onCopy(epoch.localFormatted) } - if epoch.unitDescription.contains("Seconds") { - Button("Copy as Milliseconds (\(epoch.unixMillis))") { - onCopy(String(epoch.unixMillis)) - } - } else { - Button("Copy as Seconds (\(epoch.unixSeconds))") { - onCopy(String(epoch.unixSeconds)) - } - } - } label: { - Label("Copy Date", systemImage: "doc.on.doc") - .font(.system(size: 10)) - } - .buttonStyle(.bordered) - .controlSize(.mini) - } - - VStack(alignment: .leading, spacing: 5) { - Text(epoch.localFormatted) - .font(.system(size: 12.5, weight: .medium)) - .foregroundStyle(.primary) - .textSelection(.enabled) - - HStack(spacing: 5) { - Text("UTC:") - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(.secondary) - Text(epoch.iso8601) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - HStack(spacing: 5) { - Text("Relative:") - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle(.secondary) - Text(epoch.relativeFormatted) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) - ) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.orange.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.orange.opacity(0.18), lineWidth: 1) - ) - ) - } -} - -private struct ImageContentView: View { - let entry: ClipboardEntry - let image: NSImage? - let onCopyText: (String) -> Void - - var body: some View { - ScrollView([.vertical]) { - VStack(spacing: 12) { - ZStack { - if let image { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.panelBorder), lineWidth: 0.5) - ) - } else { - ProgressView() - .frame(height: 140) - } - } - .frame(maxWidth: .infinity) - .accessibilityLabel("Image preview, \(entry.imageFormat?.uppercased() ?? "unknown format")") - .padding(.top, 4) - - if let ocrText = entry.content, !ocrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - VStack(alignment: .leading, spacing: 6) { - HStack { - Label("Extracted Text (OCR)", systemImage: "doc.text.viewfinder") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.primary) - Spacer() - Button { onCopyText(ocrText) } label: { - Label("Copy Text", systemImage: "doc.on.doc") - .font(.system(size: 10)) - } - .buttonStyle(.bordered) - .controlSize(.mini) - } - - Text(ocrText) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(AppAlpha.Fill.subtle)) - ) - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(0.03)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5) - ) - ) - } - } - .padding(14) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - // MARK: - Actions-row icon buttons /// Circular hover-highlighting icon button used across the Actions row. private struct IconActionButton: View { let systemImage: String let help: String + var isSelected: Bool = false let action: () -> Void @State private var isHovered = false @@ -838,11 +519,13 @@ private struct IconActionButton: View { Image(systemName: systemImage) .font(.system(size: 12)) .symbolRenderingMode(.monochrome) - .foregroundStyle(isHovered ? Color.primary : Color.secondary) + .foregroundStyle(isSelected || isHovered ? Color.primary : Color.secondary) .frame(width: 26, height: 26) .background( Circle() - .fill(isHovered ? Color.primary.opacity(AppAlpha.Hover.fill) : Color.clear) + .fill(isSelected || isHovered + ? Color.primary.opacity(AppAlpha.Hover.fill) + : Color.clear) ) .contentShape(Circle()) } @@ -850,6 +533,7 @@ private struct IconActionButton: View { .onHover { isHovered = $0 } .help(help) .accessibilityLabel(help) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/Sources/ClapApp/SettingsView+Persistence.swift b/Sources/ClapApp/SettingsView+Persistence.swift index b243c6d..0e65903 100644 --- a/Sources/ClapApp/SettingsView+Persistence.swift +++ b/Sources/ClapApp/SettingsView+Persistence.swift @@ -87,3 +87,56 @@ extension SettingsView { } } } + +// MARK: - Backup / restore + +extension SettingsView { + + @ViewBuilder + var backupSection: some View { + HStack(spacing: 8) { + Button("Export Backup…") { runBackupExport() } + .help("Export full history (backup.json + images) to a folder") + Button("Restore Backup…") { runBackupRestore() } + .help("Import a backup folder created by clap; duplicates merge") + } + if let backupStatus { + Text(backupStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func runBackupExport() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "clap-backup" + panel.message = "Choose an empty folder for the backup (backup.json + images/)" + panel.canCreateDirectories = true + guard let directory = panel.runModal() == .OK ? panel.url : nil else { return } + Task { + do { + let count = try await store.exportBackup(to: directory) + backupStatus = "Exported \(count) entries." + } catch { + backupStatus = "Export failed: \(error.localizedDescription)" + } + } + } + + private func runBackupRestore() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.message = "Choose a clap backup folder (must contain backup.json)" + guard let directory = panel.runModal() == .OK ? panel.url : nil else { return } + Task { + do { + let result = try await store.importBackup(from: directory) + backupStatus = "Restored \(result.imported) entries (\(result.merged) merged, \(result.skipped) skipped)." + IPC.post(.storeChanged) + } catch { + backupStatus = "Restore failed: \(error.localizedDescription)" + } + } + } +} diff --git a/Sources/ClapApp/SettingsView.swift b/Sources/ClapApp/SettingsView.swift index ab195eb..daf50dd 100644 --- a/Sources/ClapApp/SettingsView.swift +++ b/Sources/ClapApp/SettingsView.swift @@ -49,6 +49,7 @@ struct SettingsView: View { @State private var suppressLoginToggle = false @State private var launchError: String? @State var saveError: String? + @State var backupStatus: String? @State var hotKeyOK = true @State var snippetTapOK = true @State private var paused = false @@ -213,6 +214,7 @@ struct SettingsView: View { Section("Retention") { retentionPicker } Section("General") { generalSection } Section("Excluded apps") { exclusionsSection } + Section("Backup & restore") { backupSection } } } diff --git a/Sources/ClapCLIKit/ClapCLI.swift b/Sources/ClapCLIKit/ClapCLI.swift index 62e5dc9..0335615 100644 --- a/Sources/ClapCLIKit/ClapCLI.swift +++ b/Sources/ClapCLIKit/ClapCLI.swift @@ -24,6 +24,8 @@ public enum ClapCLI { "unpin": { await PinCommand.run($0, pinned: false, context: $1) }, "tag": { await TagCommand.run($0, context: $1) }, "tags": { await TagCommand.run(["list"] + $0, context: $1) }, + "backup": { await BackupCommand.run($0, mode: .backup, context: $1) }, + "restore": { await BackupCommand.run($0, mode: .restore, context: $1) }, "clear": { await ClearCommand.run($0, context: $1) }, "stats": { await StatsCommand.run($0, context: $1) }, "config": { await ConfigCommand.run($0, context: $1) }, @@ -108,6 +110,7 @@ enum HelpText { clap pin / clap unpin clap tag add / clap tag remove clap tags / clap tag list [id] + clap backup / clap restore Full-history backup & restore clap clear [--force] clap stats [--json] clap config get [key] diff --git a/Sources/ClapCLIKit/Commands/BackupCommand.swift b/Sources/ClapCLIKit/Commands/BackupCommand.swift new file mode 100644 index 0000000..76b6e92 --- /dev/null +++ b/Sources/ClapCLIKit/Commands/BackupCommand.swift @@ -0,0 +1,48 @@ +import Foundation +import ClapCore + +/// `clap backup ` — export full history (backup.json + images/). +/// `clap restore ` — import a backup directory. Duplicates merge. +enum BackupCommand { + enum Mode { + case backup, restore + } + + static let usage = """ + Usage: clap backup + clap restore + + backup Export the full history to /backup.json plus an + images/ folder. Zip it and it travels anywhere. + restore Import a backup directory produced by `clap backup`. + Duplicates merge; nothing is deleted. + """ + + static func run(_ args: [String], mode: Mode, context: CLIContext) async { + let parsed = ArgParser.parse(args, usage: usage) + guard parsed.positionals.count == 1 else { + CLI.usageError("backup/restore require exactly one directory path", usage: usage) + } + let rawPath = parsed.positionals[0] + let directory = URL(fileURLWithPath: (rawPath as NSString).expandingTildeInPath, + isDirectory: true) + + switch mode { + case .backup: + let count = await CLI.run { + let store = try context.makeStore() + let exported = try await store.exportBackup(to: directory) + return exported + } + print("Exported \(count) entries to \(directory.path)") + + case .restore: + let result = await CLI.run { + let store = try context.makeStore() + return try await store.importBackup(from: directory) + } + Notify.storeChanged() + print("Restored \(result.imported) entries (\(result.merged) merged, \(result.skipped) skipped).") + } + } +} diff --git a/Sources/ClapCore/ClipboardStore+Backup.swift b/Sources/ClapCore/ClipboardStore+Backup.swift new file mode 100644 index 0000000..57fbaca --- /dev/null +++ b/Sources/ClapCore/ClipboardStore+Backup.swift @@ -0,0 +1,202 @@ +import Foundation + +// MARK: - Full-history backup & restore +// +// Backup format: a single JSON file plus an `images/` sibling folder. +// { +// "format": "clap-backup", +// "version": 1, +// "exportedAt": , +// "entries": [ { type, content?, imagePath?, imageFormat?, createdAt, +// lastUsedAt, sizeBytes, isPinned, isFavorite, useCount, +// sourceApp?, shortcut?, tags: [] } ] +// } +// Image rows reference `images/` next to the JSON. Restore replays +// every row through importText/importImage so dedup, FTS indexing, and +// metadata merge all behave exactly like the Maccy importer. + +extension ClipboardStore { + + struct BackupDocument: Codable { + let format: String + let version: Int + let exportedAt: Date + let entries: [BackupEntry] + } + + struct BackupEntry: Codable { + let type: String + let content: String? + let imagePath: String? + let imageFormat: String? + let createdAt: Date + let lastUsedAt: Date + let sizeBytes: Int64 + let isPinned: Bool + let isFavorite: Bool + let useCount: Int + let sourceApp: String? + let shortcut: String? + let tags: [String] + } + + static let backupFormatIdentifier = "clap-backup" + static let backupFormatVersion = 1 + static let backupImagesFolderName = "images" + + /// Writes `backup.json` + `images/` into `directory` (created if needed). + /// Returns the number of entries exported. Image bytes are copied from + /// the store's image folder; missing files fail the export loudly rather + /// than producing a partial backup. + @discardableResult + public func exportBackup(to directory: URL) throws -> Int { + let fm = FileManager.default + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + let imagesDir = directory.appendingPathComponent(Self.backupImagesFolderName, isDirectory: true) + try fm.createDirectory(at: imagesDir, withIntermediateDirectories: true) + + var backupEntries: [BackupEntry] = [] + var copiedImageFiles = Set() + + var offset = 0 + while true { + let batch = try list(type: nil, limit: 500, offset: offset) + if batch.isEmpty { break } + for entry in batch { + if let imagePath = entry.imagePath { + let source = dataDir.appendingPathComponent("images", isDirectory: true) + .appendingPathComponent(imagePath) + let destination = imagesDir.appendingPathComponent(imagePath) + if !copiedImageFiles.contains(imagePath) { + if fm.fileExists(atPath: destination.path) { + try? fm.removeItem(at: destination) + } + try fm.copyItem(at: source, to: destination) + copiedImageFiles.insert(imagePath) + } + } + backupEntries.append(BackupEntry( + type: entry.type.rawValue, + content: entry.content, + imagePath: entry.imagePath, + imageFormat: entry.imageFormat, + createdAt: entry.createdAt, + lastUsedAt: entry.lastUsedAt, + sizeBytes: entry.sizeBytes, + isPinned: entry.isPinned, + isFavorite: entry.isFavorite, + useCount: entry.useCount, + sourceApp: entry.sourceApp, + shortcut: entry.shortcut, + tags: entry.tags)) + } + offset += batch.count + } + + let document = BackupDocument( + format: Self.backupFormatIdentifier, + version: Self.backupFormatVersion, + exportedAt: clock(), + entries: backupEntries) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(document) + + let jsonURL = directory.appendingPathComponent("backup.json") + try data.write(to: jsonURL, options: .atomic) + try fm.setAttributes(CoreConstants.ownerOnlyFileAttributes, ofItemAtPath: jsonURL.path) + + return backupEntries.count + } + + /// Restores a backup produced by `exportBackup`. Returns + /// (imported, merged) counts. Rows missing their referenced image file + /// are skipped (reported in `skipped`). + @discardableResult + public func importBackup(from directory: URL) async throws -> (imported: Int, merged: Int, skipped: Int) { + let jsonURL = directory.appendingPathComponent("backup.json") + let data = try Data(contentsOf: jsonURL) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document: BackupDocument + do { + document = try decoder.decode(BackupDocument.self, from: data) + } catch { + throw ClapCoreError.io("invalid clap backup file") + } + guard document.format == Self.backupFormatIdentifier else { + throw ClapCoreError.io("not a clap backup file") + } + + let imagesDir = directory.appendingPathComponent(Self.backupImagesFolderName, isDirectory: true) + var imported = 0 + var merged = 0 + var skipped = 0 + + for entry in document.entries { + guard let type = EntryType(rawValue: entry.type) else { + skipped += 1 + continue + } + + switch type { + case .text, .shell: + guard let content = entry.content else { + skipped += 1 + continue + } + if let result = try await importText( + content, + createdAt: entry.createdAt, + lastUsedAt: entry.lastUsedAt, + useCount: entry.useCount, + pinned: entry.isPinned, + sourceApp: entry.sourceApp, + as: type) { + if result.merged { merged += 1 } else { imported += 1 } + _ = try? setFavorite(entry.isFavorite, id: result.id) + if let shortcut = entry.shortcut { + _ = try? setShortcut(shortcut, id: result.id) + } + for tag in entry.tags { + _ = try? addTag(tag, entryID: result.id) + } + } else { + skipped += 1 + } + + case .image: + guard let imagePath = entry.imagePath, let format = entry.imageFormat else { + skipped += 1 + continue + } + let fileURL = imagesDir.appendingPathComponent(imagePath) + guard let imageBytes = try? Data(contentsOf: fileURL), !imageBytes.isEmpty else { + skipped += 1 + continue + } + if let result = try await importImage( + data: imageBytes, + format: format, + createdAt: entry.createdAt, + lastUsedAt: entry.lastUsedAt, + useCount: entry.useCount, + pinned: entry.isPinned, + sourceApp: entry.sourceApp) { + if result.merged { merged += 1 } else { imported += 1 } + _ = try? setFavorite(entry.isFavorite, id: result.id) + for tag in entry.tags { + _ = try? addTag(tag, entryID: result.id) + } + } else { + skipped += 1 + } + } + } + + return (imported, merged, skipped) + } +} diff --git a/Sources/ClapCore/ClipboardStore+Capture.swift b/Sources/ClapCore/ClipboardStore+Capture.swift index 206cda8..44c355d 100644 --- a/Sources/ClapCore/ClipboardStore+Capture.swift +++ b/Sources/ClapCore/ClipboardStore+Capture.swift @@ -155,7 +155,8 @@ extension ClipboardStore { /// Returns nil when the text is empty after normalization or oversize. @discardableResult public func importText(_ raw: String, createdAt: Date, lastUsedAt: Date, - useCount: Int, pinned: Bool, sourceApp: String?) async throws -> (id: Int64, merged: Bool)? { + useCount: Int, pinned: Bool, sourceApp: String?, + as type: EntryType = .text) async throws -> (id: Int64, merged: Bool)? { let normalized = TextNormalizer.normalize(raw) guard !normalized.isEmpty else { return nil } let sizeBytes = Int64(normalized.utf8.count) @@ -163,13 +164,13 @@ extension ClipboardStore { let hash = ContentHasher.textHash(normalized) return try db.transaction { - if let existing = try firstEntry("type = 'text' AND content_hash = ? AND content = ?", - [.text(hash), .text(normalized)]) { + if let existing = try firstEntry("type = ? AND content_hash = ? AND content = ?", + [.text(EntryType.text.rawValue), .text(hash), .text(normalized)]) { try mergeImported(into: existing.id, createdAt: createdAt, lastUsedAt: lastUsedAt, useCount: useCount, pinned: pinned) return (existing.id, true) } - let id = try insertEntry(type: .text, content: normalized, imagePath: nil, imageFormat: nil, + let id = try insertEntry(type: type, content: normalized, imagePath: nil, imageFormat: nil, hash: hash, createdAt: createdAt.timeIntervalSince1970, lastUsedAt: lastUsedAt.timeIntervalSince1970, sizeBytes: sizeBytes, pinned: pinned, useCount: useCount, diff --git a/Sources/ClapCore/JSONRepair.swift b/Sources/ClapCore/JSONRepair.swift new file mode 100644 index 0000000..77c1e19 --- /dev/null +++ b/Sources/ClapCore/JSONRepair.swift @@ -0,0 +1,378 @@ +import Foundation + +// MARK: - JSON repair +// +// Best-effort transformation of common broken-JSON patterns into valid JSON. +// Design guarantees (edge cases drawn from jsonlint-core's hardening notes): +// - String-aware: NOTHING inside a double-quoted string is ever transformed +// (a trailing comma inside "a,}" or an apostrophe in "it's" is safe). +// - Escape-aware: \" inside strings does not terminate them. +// - Every fix is reported, and the result is re-validated as JSON before it +// is returned; if it still doesn't parse, repair returns nil. + +public struct JSONRepairResult: Sendable, Equatable { + public let repaired: String + /// Human-readable labels of the fixes applied, in application order. + public let fixes: [String] +} + +public enum JSONRepair { + + /// Attempts to repair `text` into valid JSON. Returns nil when no fix + /// applies or the result still fails validation. + public static func repair(_ text: String) -> JSONRepairResult? { + var body = text.trimmingCharacters(in: .whitespacesAndNewlines) + var strippedBOM = false + if body.hasPrefix("\u{FEFF}") { + body.removeFirst() + strippedBOM = true + } + // Cheap pre-filter only — leading comments/BOM are stripped by the + // scanner; the final gate does the real validation. + guard body.contains("{") || body.contains("[") else { return nil } + + var fixes: [String] = [] + if strippedBOM { fixes.append("stripped byte-order mark") } + + let scanned = scanPhase(body, fixes: &fixes) + let repaired = transformOutsideStrings(scanned.output, + stringRanges: scanned.stringRanges) { + transformSegment($0, fixes: &fixes) + } + + // Final gate: must now be valid JSON, and must actually differ. + guard JSONData.parse(repaired) != nil, repaired != text else { return nil } + var seen = Set() + let uniqueFixes = fixes.filter { seen.insert($0).inserted } + return JSONRepairResult(repaired: repaired, fixes: uniqueFixes) + } + + // MARK: - Phase 1: scanner + + /// Strips comments, normalizes smart quotes, converts single-quoted + /// strings, and records the double-quoted string ranges of the output. + private static func scanPhase( + _ body: String, fixes: inout [String] + ) -> (output: String, stringRanges: [Range]) { + var out: [Character] = [] + out.reserveCapacity(body.count) + var stringRanges: [Range] = [] + var inString = false + var stringStart = 0 + var inLineComment = false + var inBlockComment = false + var strippedComments = false + var smartQuotesFixed = false + var singleQuotesFixed = false + + var i = body.startIndex + while i < body.endIndex { + let ch = body[i] + + if inString { + i = scanStringChar(ch, body: body, i: i, out: &out, + stringStart: stringStart, stringRanges: &stringRanges, + inString: &inString) + continue + } + + switch scanComment(ch, body: body, i: i, + inLineComment: &inLineComment, + inBlockComment: &inBlockComment, + stripped: &strippedComments) { + case .skipped(let next): + i = next + continue + case .newlineEmitted: + out.append("\n") + i = body.index(after: i) + continue + case .notComment: + break + } + + switch ch { + case "\"": + inString = true + stringStart = out.count + out.append(ch) + case "“", "”": + smartQuotesFixed = true + inString = true + stringStart = out.count + out.append("\"") + case "‘": + smartQuotesFixed = true + out.append("'") + case "'": + if let close = closingSingleQuote(body, from: body.index(after: i)) { + singleQuotesFixed = true + i = convertSingleQuoted(body, open: i, close: close, + out: &out, stringStart: out.count, + stringRanges: &stringRanges) + continue + } else { + out.append(ch) + } + default: + out.append(ch) + } + i = body.index(after: i) + } + + if strippedComments { fixes.append("removed comments") } + if smartQuotesFixed { fixes.append("converted smart quotes") } + if singleQuotesFixed { fixes.append("normalized single-quoted strings") } + return (String(out), stringRanges) + } + + private enum CommentScan { + case notComment + case skipped(String.Index) + case newlineEmitted + } + + /// Handles comment state for the scanner. + private static func scanComment( + _ ch: Character, body: String, i: String.Index, + inLineComment: inout Bool, inBlockComment: inout Bool, stripped: inout Bool + ) -> CommentScan { + if inLineComment { + if ch == "\n" { inLineComment = false; return .newlineEmitted } + stripped = true + return .skipped(body.index(after: i)) + } + if inBlockComment { + stripped = true + if ch == "*", body.index(after: i) < body.endIndex, + body[body.index(after: i)] == "/" { + inBlockComment = false + return .skipped(body.index(i, offsetBy: 2, limitedBy: body.endIndex) ?? body.endIndex) + } + return .skipped(body.index(after: i)) + } + guard ch == "/", body.index(after: i) < body.endIndex else { return .notComment } + let next = body[body.index(after: i)] + if next == "/" { + inLineComment = true + stripped = true + return .skipped(body.index(i, offsetBy: 2, limitedBy: body.endIndex) ?? body.endIndex) + } + if next == "*" { + inBlockComment = true + stripped = true + return .skipped(body.index(i, offsetBy: 2, limitedBy: body.endIndex) ?? body.endIndex) + } + return .notComment + } + + /// Consumes one character inside a double-quoted string. Returns the next + /// index. Smart closers terminate the string as ASCII quotes. + private static func scanStringChar( + _ ch: Character, body: String, i: String.Index, out: inout [Character], + stringStart: Int, stringRanges: inout [Range], inString: inout Bool + ) -> String.Index { + if ch == "\\", i < body.index(before: body.endIndex) { + let next = body.index(after: i) + out.append(ch) + out.append(body[next]) + return body.index(after: next) + } + out.append(ch == "“" || ch == "”" ? "\"" : ch) + if ch == "\"" || ch == "“" || ch == "”" { + stringRanges.append(stringStart..] + ) -> String.Index { + out.append("\"") + var j = body.index(after: open) + while j < close { + let c = body[j] + if c == "\"" { out.append("\\") } + out.append(c) + j = body.index(after: j) + } + out.append("\"") + stringRanges.append(stringStart.. String.Index? { + var j = from + while j < text.endIndex { + let c = text[j] + if c == "\n" || c == "\"" { return nil } + if c == "'" { return j } + j = text.index(after: j) + } + return nil + } + + // MARK: - Phase 2: outside-string transforms + + /// Applies `transform` to every segment OUTSIDE the double-quoted string + /// ranges, reassembling the string afterwards. + private static func transformOutsideStrings( + _ input: String, stringRanges: [Range], transform: (String) -> String + ) -> String { + let chars = Array(input) + var result: [Character] = [] + var cursor = 0 + + for stringRange in stringRanges { + let upper = min(stringRange.upperBound, chars.count) + if cursor < stringRange.lowerBound { + result.append(contentsOf: transform(String(chars[cursor.. String { + var s = segment + s = replaceWordBoundaries(s) { word in + switch word { + case "True": return "true" + case "False": return "false" + case "None", "undefined", "NaN", "Infinity", "-Infinity": return "null" + default: return nil + } + } + if s != segment { fixes.append("converted Python/JS literals") } + + if convertHexNumbers(&s) { fixes.append("converted hexadecimal numbers") } + if quoteBareKeys(&s) { fixes.append("quoted bare keys") } + if removeTrailingCommas(&s) { fixes.append("removed trailing commas") } + return s + } + + private static func replaceWordBoundaries( + _ input: String, _ replacement: (String) -> String? + ) -> String { + var result = "" + var word = "" + for ch in input { + if ch.isLetter || ch.isNumber || ch == "_" { + word.append(ch) + } else { + result.append(contentsOf: replacement(word) ?? word) + word = "" + result.append(ch) + } + } + result.append(contentsOf: replacement(word) ?? word) + return result + } + + private static func convertHexNumbers(_ input: inout String) -> Bool { + var fixed = false + var result = "" + var i = input.startIndex + while i < input.endIndex { + if input[i] == "0", input.index(after: i) < input.endIndex, + "xX".contains(input[input.index(after: i)]) { + var j = input.index(i, offsetBy: 2) + var hex = "" + while j < input.endIndex, input[j].isHexDigit { + hex.append(input[j]) + j = input.index(after: j) + } + if !hex.isEmpty, let value = Int(hex, radix: 16), + i == input.startIndex || !isWordChar(input[input.index(before: i)]), + j == input.endIndex || !isWordChar(input[j]) { + result += String(value) + fixed = true + i = j + continue + } + } + result.append(input[i]) + i = input.index(after: i) + } + if fixed { input = result } + return fixed + } + + private static func isWordChar(_ ch: Character) -> Bool { + ch.isLetter || ch.isNumber || ch == "_" + } + + private static func quoteBareKeys(_ input: inout String) -> Bool { + var chars = Array(input) + var fixed = false + var i = 0 + while i < chars.count { + if chars[i] == "{" || chars[i] == "," { + var j = i + 1 + while j < chars.count, chars[j].isWhitespace { j += 1 } + if j < chars.count, isKeyStart(chars[j]) { + var k = j + while k < chars.count, isKeyChar(chars[k]) { k += 1 } + if k > j { + var m = k + while m < chars.count, chars[m].isWhitespace { m += 1 } + if m < chars.count, chars[m] == ":" { + chars.insert("\"", at: j) + chars.insert("\"", at: k + 1) + fixed = true + i = k + 2 + continue + } + } + } + } + i += 1 + } + if fixed { input = String(chars) } + return fixed + } + + private static func isKeyStart(_ ch: Character) -> Bool { + ch.isLetter || ch == "_" || ch == "$" + } + + private static func isKeyChar(_ ch: Character) -> Bool { + ch.isLetter || ch.isNumber || ch == "_" || ch == "$" + } + + private static func removeTrailingCommas(_ input: inout String) -> Bool { + let chars = Array(input) + var result: [Character] = [] + var fixed = false + var i = 0 + while i < chars.count { + if chars[i] == "," { + var j = i + 1 + while j < chars.count, chars[j].isWhitespace { j += 1 } + if j < chars.count, chars[j] == "}" || chars[j] == "]" { + fixed = true + i += 1 + continue + } + } + result.append(chars[i]) + i += 1 + } + if fixed { input = String(result) } + return fixed + } +} diff --git a/Sources/ClapCore/QRCodeBuilder.swift b/Sources/ClapCore/QRCodeBuilder.swift new file mode 100644 index 0000000..0cd5dd5 --- /dev/null +++ b/Sources/ClapCore/QRCodeBuilder.swift @@ -0,0 +1,50 @@ +import Foundation +import CoreImage +import CoreImage.CIFilterBuiltins +import CoreGraphics +import ImageIO + +/// Generates QR code PNGs for clipboard content — scannable and shareable +/// to phones without any network dependency. +public enum QRCodeBuilder { + + /// QR byte capacity at level "M"; beyond this the payload won't fit. + public static let maxPayloadBytes = 2_000 + + /// True when `text` can be encoded (non-empty, within capacity). + public static func canEncode(_ text: String) -> Bool { + let payload = Data(text.utf8) + return !payload.isEmpty && payload.count <= maxPayloadBytes + } + + /// Renders `text` as a QR code PNG. Returns nil for empty or oversized + /// payloads (or the unlikely case generation fails). + public static func pngData(for text: String, pixelScale: Int = 8) -> Data? { + let payload = Data(text.utf8) + guard !payload.isEmpty, payload.count <= maxPayloadBytes else { return nil } + + let filter = CIFilter.qrCodeGenerator() + filter.message = payload + filter.correctionLevel = "M" + + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed( + by: CGAffineTransform(scaleX: CGFloat(pixelScale), y: CGFloat(pixelScale))) + + guard let cgImage = Self.context.createCGImage(scaled, from: scaled.extent) else { + return nil + } + return pngData(from: cgImage) + } + + private static let context = CIContext() + + private static func pngData(from image: CGImage) -> Data? { + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + data, "public.png" as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { return nil } + return data as Data + } +} diff --git a/Sources/ClapCore/TextAnalysis.swift b/Sources/ClapCore/TextAnalysis.swift index a697962..73e1527 100644 --- a/Sources/ClapCore/TextAnalysis.swift +++ b/Sources/ClapCore/TextAnalysis.swift @@ -16,6 +16,61 @@ public struct ParsedColor: Equatable, Sendable { self.blue = blue self.alpha = alpha } + + public struct Formats: Equatable, Sendable { + public let hex: String + public let rgb: String + public let hsl: String + } + + /// The same color rendered in the three formats designers pass around: + /// `#RRGGBB[AA]`, `rgb[r](r, g, b[, a])`, `hsl[hsla](h, s%, l%[, a])`. + public var formats: Formats { + let to255 = { (v: Double) in Int((v * 255).rounded()) } + let r = to255(red), g = to255(green), b = to255(blue) + + var hex = String(format: "#%02X%02X%02X", r, g, b) + if alpha < 1.0 { + hex += String(format: "%02X", Int((alpha * 255).rounded())) + } + + let rgb: String + if alpha < 1.0 { + rgb = String(format: "rgba(%d, %d, %d, %.2f)", r, g, b, alpha) + } else { + rgb = String(format: "rgb(%d, %d, %d)", r, g, b) + } + + // RGB -> HSL (standard Foley/van-Dam inverse). + let maxC = max(red, green, blue) + let minC = min(red, green, blue) + let delta = maxC - minC + let lightness = (maxC + minC) / 2 + var hue: Double + let saturation: Double + if delta == 0 { + hue = 0 + saturation = 0 + } else { + saturation = delta / (1 - abs(2 * lightness - 1)) + switch maxC { + case red: hue = ((green - blue) / delta).truncatingRemainder(dividingBy: 6) + case green: hue = (blue - red) / delta + 2 + default: hue = (red - green) / delta + 4 + } + hue = (hue * 60).truncatingRemainder(dividingBy: 360) + if hue < 0 { hue += 360 } + } + + let hsl: String + if alpha < 1.0 { + hsl = String(format: "hsla(%.0f, %.0f%%, %.0f%%, %.2f)", hue, saturation * 100, lightness * 100, alpha) + } else { + hsl = String(format: "hsl(%.0f, %.0f%%, %.0f%%)", hue, saturation * 100, lightness * 100) + } + + return Formats(hex: hex, rgb: rgb, hsl: hsl) + } } public enum ColorParser { @@ -424,3 +479,59 @@ public struct EpochData: Sendable, Equatable { ) } } + +// MARK: - JSON detection & reformatting + +/// Validated JSON extracted from clipboard content, with the two renderings +/// developers actually copy: pretty (sorted keys) and minified. +public struct JSONData: Sendable, Equatable { + public static let maxParseLength = 100_000 + + public let pretty: String + public let minified: String + public let isTopLevelObject: Bool + public let valueCount: Int + + /// Parses `text` as JSON. Accepts objects and arrays only (scalars like + /// `42` or `"hi"` are not interesting clipboard JSON). Returns nil for + /// anything invalid or oversized. + public static func parse(_ text: String?) -> JSONData? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 2, trimmed.count <= maxParseLength else { return nil } + guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return nil } + + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []), + JSONSerialization.isValidJSONObject(object) else { + return nil + } + + let prettyData = (try? JSONSerialization.data( + withJSONObject: object, options: [.prettyPrinted, .sortedKeys])) ?? data + // sortedKeys on both renderings: deterministic output for tests, + // diffs and caches. + let minifiedData = (try? JSONSerialization.data( + withJSONObject: object, options: [.sortedKeys])) ?? data + + guard let pretty = String(data: prettyData, encoding: .utf8), + let minified = String(data: minifiedData, encoding: .utf8) else { + return nil + } + + let valueCount: Int + if let array = object as? [Any] { + valueCount = array.count + } else if let dict = object as? [String: Any] { + valueCount = dict.count + } else { + valueCount = 0 + } + + return JSONData( + pretty: pretty, + minified: minified, + isTopLevelObject: object is [String: Any], + valueCount: valueCount) + } +} diff --git a/Tests/ClapCoreTests/BackupTests.swift b/Tests/ClapCoreTests/BackupTests.swift new file mode 100644 index 0000000..72431e6 --- /dev/null +++ b/Tests/ClapCoreTests/BackupTests.swift @@ -0,0 +1,118 @@ +import Testing +import Foundation +@testable import ClapCore + +@Suite("Backup export & restore") +struct BackupTests { + + @Test func exportThenImportRoundTripsEverything() async throws { + let png = makePNG() + try await withStore { source, _ in + // Seed a rich history: text, image, shell, pinned, favorite, + // tagged, shortcut, multiple sources. + let text = try #require(try await source.captureText( + "backup me", sourceApp: "com.apple.Notes")).entry + _ = try await source.setPinned(true, id: text.id) + _ = try await source.addTag("work", entryID: text.id) + + let image = try #require(try await source.captureImage( + data: png, format: "png", sourceApp: nil)).entry + _ = try await source.setFavorite(true, id: image.id) + _ = try await source.addTag("shots", entryID: image.id) + + _ = try await source.ingestShell("docker compose up", executedAt: nil) + + // Export. + let backupDir = FileManager.default.temporaryDirectory + .appendingPathComponent("clap-backup-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: backupDir) } + let exported = try await source.exportBackup(to: backupDir) + #expect(exported == 3) + #expect(FileManager.default.fileExists( + atPath: backupDir.appendingPathComponent("backup.json").path)) + #expect(FileManager.default.fileExists( + atPath: backupDir.appendingPathComponent("images").path)) + + // Restore into a FRESH store. + try await withStore { target, _ in + let result = try await target.importBackup(from: backupDir) + #expect(result.imported == 3) + #expect(result.merged == 0) + #expect(result.skipped == 0) + + // Text entry: content, pin, tag survive. + let restoredText = try #require( + try await target.search(SearchQuery(text: "backup me", limit: 1, offset: 0)).first) + #expect(restoredText.isPinned) + #expect(restoredText.tags == ["work"]) + #expect(restoredText.sourceApp == "com.apple.Notes") + + // Image entry: bytes are identical, favorite + tag survive, + // thumbnail regenerates. + let restoredImage = try #require( + try await target.list(type: .image, limit: 1, offset: 0).first) + #expect(restoredImage.isFavorite) + #expect(restoredImage.tags == ["shots"]) + #expect(restoredImage.contentHash == image.contentHash) + let restoredPNG = try #require(await target.imageFileURL(for: restoredImage)) + #expect(try Data(contentsOf: restoredPNG) == png) + #expect(try await target.thumbnailURL(for: restoredImage) != nil) + + // Shell entry survives with its type. + let shell = try await target.list(type: .shell, limit: 10, offset: 0) + #expect(shell.contains { $0.content == "docker compose up" }) + } + } + } + + @Test func importIsIdempotentViaMerge() async throws { + try await withStore { source, _ in + _ = try await source.captureText("dedupe me", sourceApp: nil) + let backupDir = FileManager.default.temporaryDirectory + .appendingPathComponent("clap-backup-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: backupDir) } + _ = try await source.exportBackup(to: backupDir) + + try await withStore { target, _ in + _ = try await target.importBackup(from: backupDir) + // Second import must MERGE, not duplicate. + let second = try await target.importBackup(from: backupDir) + #expect(second.imported == 0) + #expect(second.merged == 1) + let all = try await target.list(type: .text, limit: 10, offset: 0) + #expect(all.count == 1) + } + } + } + + @Test func importRejectsForeignFilesAndSkipsMissingImages() async throws { + try await withStore { target, _ in + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("clap-backup-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + try #"{"format": "something-else", "entries": []}"#.write( + to: dir.appendingPathComponent("backup.json"), + atomically: true, encoding: .utf8) + await #expect(throws: ClapCoreError.self) { + try await target.importBackup(from: dir) + } + + // Entry referencing a missing image file is skipped, not fatal. + let payload = """ + {"format":"clap-backup","version":1,"exportedAt":"2026-01-01T00:00:00Z", + "entries":[{"type":"image","content":null,"imagePath":"ghost.png", + "imageFormat":"png","createdAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z","sizeBytes":10, + "isPinned":false,"isFavorite":false,"useCount":1, + "sourceApp":null,"shortcut":null,"tags":[]}]} + """ + try payload.write(to: dir.appendingPathComponent("backup.json"), + atomically: true, encoding: .utf8) + let result = try await target.importBackup(from: dir) + #expect(result.skipped == 1) + #expect(result.imported == 0) + } + } +} diff --git a/Tests/ClapCoreTests/JSONRepairTests.swift b/Tests/ClapCoreTests/JSONRepairTests.swift new file mode 100644 index 0000000..3d3b263 --- /dev/null +++ b/Tests/ClapCoreTests/JSONRepairTests.swift @@ -0,0 +1,135 @@ +import Testing +import Foundation +@testable import ClapCore + +@Suite("JSON repair") +struct JSONRepairTests { + + private func repaired(_ source: String) throws -> String { + try #require(JSONRepair.repair(source)).repaired + } + + @Test func trailingCommas() throws { + #expect(try repaired(#"{"a": 1,}"#) == #"{"a": 1}"#) + #expect(try repaired("[1, 2, 3,]") == "[1, 2, 3]") + #expect(try repaired(#"{ "a": [1,], "b": 2, }"#) == #"{ "a": [1], "b": 2 }"#) + } + + @Test func singleQuotes() throws { + #expect(try repaired("{'a': 'x'}") == #"{"a": "x"}"#) + // Inner double quotes get escaped during conversion. + #expect(try repaired("{'say': \"hi\"}") == #"{"say": "hi"}"#) + } + + @Test func bareKeys() throws { + #expect(try repaired("{a: 1, bb: 2}") == #"{"a": 1, "bb": 2}"#) + #expect(try repaired("{_private$: true}") == #"{"_private$": true}"#) + } + + @Test func pythonAndJSLiterals() throws { + #expect(try repaired(#"{"a": True, "b": False, "c": None}"#) + == #"{"a": true, "b": false, "c": null}"#) + #expect(try repaired(#"{"x": undefined, "y": NaN}"#) + == #"{"x": null, "y": null}"#) + } + + @Test func hexNumbers() throws { + #expect(try repaired("{\"color\": 0xFF0000}") == #"{"color": 16711680}"#) + } + + @Test func comments() throws { + let source = """ + { + // line comment + "a": 1, /* block */ + "b": 2 + } + """ + let result = try repaired(source) + let parsed = try #require(JSONData.parse(result)) + #expect(parsed.isTopLevelObject) + #expect(parsed.valueCount == 2) + #expect(!result.contains("//")) + } + + @Test func smartQuotes() throws { + #expect(try repaired("{“a”: “x”}") == #"{"a": "x"}"#) + } + + @Test func bomStripped() throws { + let withBOM = "\u{FEFF}{\"a\": 1}" + #expect(try repaired(withBOM) == #"{"a": 1}"#) + } + + @Test func stringsAreProtected() throws { + // Trailing comma INSIDE a string must survive. + let result = try repaired(#"{"msg": "keep, me}", "n": 1,}"#) + #expect(result.contains("keep, me}")) + + // Apostrophe inside a double-quoted string: input is ALREADY valid + // JSON, so repair correctly reports nothing to do. + #expect(JSONRepair.repair(#"{"msg": "it's fine"}"#) == nil) + } + + @Test func combinedNastyCase() throws { + let source = """ + // config + { + 'enabled': True, + retries: 0xFF, + tags: ['a', 'b',], + meta: { “note”: None }, + } + """ + let result = try repaired(source) + let parsed = try #require(JSONData.parse(result)) + #expect(parsed.isTopLevelObject) + #expect(parsed.valueCount == 4) + } + + @Test func validJsonIsUntouchedAndReportsNil() { + // Already-valid JSON: repair returns nil (nothing to do, no fixes). + #expect(JSONRepair.repair(#"{"a": 1}"#) == nil) + } + + @Test func unrepairableReturnsNil() { + #expect(JSONRepair.repair("just words") == nil) + #expect(JSONRepair.repair("{") == nil) + #expect(JSONRepair.repair("{\"a\": }") == nil) // missing value + } + + @Test func fixesAreReported() throws { + let result = try #require(JSONRepair.repair("{'a': True,}")) + #expect(result.fixes.contains("normalized single-quoted strings")) + #expect(result.fixes.contains("converted Python/JS literals")) + #expect(result.fixes.contains("removed trailing commas")) + } +} + +@Test func trailingCommaInPrettyMultiline() throws { + let source = """ + { + "name": "John", + "age": 30, + "active": true, + } + """ + let result = try #require(JSONRepair.repair(source)) + let parsed = try #require(JSONData.parse(result.repaired)) + #expect(parsed.valueCount == 3) +} + +@Test func lenientPlatformParseStillOffersRepair() { + // macOS 26 JSONSerialization accepts trailing commas, so parse succeeds — + // but repair must STILL be able to normalize the document. + let source = """ + { + "name": "John", + "age": 30, + "active": true, + } + """ + let result = JSONRepair.repair(source) + #expect(result != nil) + #expect(JSONData.parse(result?.repaired ?? "") != nil) +} diff --git a/Tests/ClapCoreTests/QRCodeTests.swift b/Tests/ClapCoreTests/QRCodeTests.swift new file mode 100644 index 0000000..21f3a4b --- /dev/null +++ b/Tests/ClapCoreTests/QRCodeTests.swift @@ -0,0 +1,37 @@ +import Testing +import Foundation +import CoreGraphics +import ImageIO +@testable import ClapCore + +@Suite("QR code generation") +struct QRCodeTests { + + private func pngSize(_ data: Data) -> (Int, Int)? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let w = props[kCGImagePropertyPixelWidth] as? Int, + let h = props[kCGImagePropertyPixelHeight] as? Int else { return nil } + return (w, h) + } + + @Test func generatesSquarePngForText() throws { + let png = try #require(QRCodeBuilder.pngData(for: "https://clap.example/hello")) + let size = try #require(pngSize(png)) + #expect(size.0 == size.1) + #expect(size.0 >= 100) // 21-module QR at 8x scale minimum + } + + @Test func rejectsEmptyAndOversized() { + #expect(QRCodeBuilder.pngData(for: "") == nil) + #expect(QRCodeBuilder.pngData(for: String(repeating: "x", count: 3_000)) == nil) + } + + @Test func largerScaleProducesLargerImage() throws { + let small = try #require(QRCodeBuilder.pngData(for: "hi", pixelScale: 4)) + let large = try #require(QRCodeBuilder.pngData(for: "hi", pixelScale: 12)) + let smallWidth = try #require(pngSize(small)).0 + let largeWidth = try #require(pngSize(large)).0 + #expect(largeWidth > smallWidth) + } +} diff --git a/Tests/ClapCoreTests/TextAnalysisTests.swift b/Tests/ClapCoreTests/TextAnalysisTests.swift index acc4a8c..f63344d 100644 --- a/Tests/ClapCoreTests/TextAnalysisTests.swift +++ b/Tests/ClapCoreTests/TextAnalysisTests.swift @@ -154,3 +154,71 @@ struct TextAnalysisTests { #expect(ImageFormats.uti(forFormat: "bogus") == nil) } } + +@Suite("ParsedColor formats") +struct ColorFormatTests { + + @Test func primaryColorsRenderInAllFormats() throws { + let red = try #require(ColorParser.parse("#f00")).formats + #expect(red.hex == "#FF0000") + #expect(red.rgb == "rgb(255, 0, 0)") + #expect(red.hsl == "hsl(0, 100%, 50%)") + + let green = try #require(ColorParser.parse("hsl(120, 100%, 50%)")).formats + #expect(green.hex == "#00FF00") + #expect(green.hsl == "hsl(120, 100%, 50%)") + + let white = try #require(ColorParser.parse("rgb(255,255,255)")).formats + #expect(white.hex == "#FFFFFF") + #expect(white.hsl == "hsl(0, 0%, 100%)") + } + + @Test func alphaIsPreservedInFormats() throws { + let translucent = try #require(ColorParser.parse("#FF000080")).formats + #expect(translucent.hex == "#FF000080") + #expect(translucent.rgb == "rgba(255, 0, 0, 0.50)") + #expect(translucent.hsl.hasPrefix("hsla(0, 100%, 50%, 0.50)")) + } + + @Test func hslRoundTripMatchesInput() throws { + // Parse an HSL string, re-render it — must be identical. + let source = "hsl(210, 80%, 40%)" + let parsed = try #require(ColorParser.parse(source)) + #expect(parsed.formats.hsl == source) + } +} + +@Suite("JSON smart card") +struct JSONDataTests { + + @Test func parsesObjectAndArray() throws { + let object = try #require(JSONData.parse(#"{"b":1,"a":2}"#)) + #expect(object.isTopLevelObject) + #expect(object.valueCount == 2) + #expect(object.pretty.contains("\"a\"")) + // sortedKeys: "a" renders before "b" + let aRange = try #require(object.pretty.range(of: "\"a\"")) + let bRange = try #require(object.pretty.range(of: "\"b\"")) + #expect(aRange.lowerBound < bRange.lowerBound) + #expect(object.minified == #"{"a":2,"b":1}"#) + + let array = try #require(JSONData.parse("[1, 2, 3]")) + #expect(!array.isTopLevelObject) + #expect(array.valueCount == 3) + } + + @Test func rejectsNonJSONAndScalars() { + #expect(JSONData.parse("hello") == nil) + #expect(JSONData.parse("{\"broken\": }") == nil) + #expect(JSONData.parse("42") == nil) // bare scalar + #expect(JSONData.parse("\"just a string\"") == nil) + #expect(JSONData.parse(nil) == nil) + #expect(JSONData.parse(String(repeating: "{", count: 60_000)) == nil) + } + + @Test func doesNotClaimJWTEpochOrColorStrings() { + #expect(JSONData.parse("1700000000") == nil) + #expect(JSONData.parse("#FF0000") == nil) + #expect(JSONData.parse("eyJhbGciOiJub25lIn0.eyJhIjoxfQ.x") == nil) + } +}