Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: [main, development]
pull_request:

jobs:
test:
name: Build, Test & Lint (macOS)
# macOS 26 SDK is required to compile NSGlassEffectView (Liquid Glass).
runs-on: macos-26
steps:
- uses: actions/checkout@v4

- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest

- name: Verify toolchain
run: swift --version

- name: Build (warnings are errors)
run: swift build -Xswiftc -warnings-as-errors

- name: Test
run: swift test

- name: Lint
run: |
brew install swiftlint
swiftlint --reporter github-actions-logging
10 changes: 7 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ permissions:

jobs:
build-and-release:
runs-on: macos-14
# macOS 26 SDK is required to compile NSGlassEffectView (Liquid Glass).
runs-on: macos-26

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Select Xcode 16
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.2'
xcode-version: latest

- name: Verify toolchain
run: swift --version

- name: Build and package release
run: |
Expand Down
69 changes: 69 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# clap — SwiftLint configuration.
# Rules are deliberately conservative: anything enabled here must pass on the
# current codebase so CI stays green (errors block; warnings stay visible).

included:
- Sources
- Tests
- Package.swift

excluded:
- .build
- dist
- Scripts

opt_in_rules:
- empty_count
- explicit_init
- closure_spacing
- first_where
- last_where
- toggle_bool
- contains_over_first_not_nil
- sorted_first_last

line_length:
warning: 130
error: 160
ignores_function_declarations: true
ignores_comments: true

type_body_length:
warning: 400
error: 600

function_body_length:
warning: 80
error: 150

file_length:
warning: 900
error: 1200

identifier_name:
# SQL bind helpers and short math locals use compact names by design.
min_length: 1
max_length: 60
excluded:
- id
- db
- x
- y

cyclomatic_complexity:
warning: 16
error: 25

large_tuple:
# Public store APIs intentionally return labeled result tuples.
warning: 4
error: 5

function_parameter_count:
# Row-insert helpers legitimately ship one parameter per column.
warning: 11
error: 12

nesting:
type_level:
warning: 3
103 changes: 81 additions & 22 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ this file.
## Targets

- `ClapCore` (library): SQLite storage, FTS5 search, normalization, hashing,
dedup, LRU eviction, settings, image file store, stats, doctor checks.
**No AppKit/SwiftUI imports** (Foundation + CoreGraphics/ImageIO allowed for
thumbnailing).
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`).
- `ClapApp` (executable): NSApplication accessory app. Pasteboard monitor,
Carbon global hotkey (Cmd+Shift+V), SwiftUI floating panel (Classic/Media
tabs), menu bar item, settings window.
- `clap` (executable, Sources/ClapCLI): subcommand CLI. Hand-rolled argument
parsing (no external dependencies). May import AppKit only for
NSPasteboard writes (`clap copy`).
Carbon global hotkey (configurable), SwiftUI floating panel (Classic/Media/
Shell/Favs tabs), menu bar item, settings window.
- `ClapCLIKit` (library): all `clap` command logic and output formatting as a
unit-testable library. May import AppKit only for NSPasteboard writes
(`clap copy`) and NSWorkspace process probing.
- `clap` (executable, Sources/ClapCLI): thin entry point delegating to
ClapCLIKit.

## Data locations

Expand All @@ -26,29 +30,33 @@ this file.
- Images: `<base>/images/<content_hash>.<ext>` (original data, written atomically).
- Thumbnails: `<base>/thumbnails/<content_hash>.png` (max 400px long edge).

## Database schema (SQLite, user_version = 1)
## Database schema (SQLite, user_version = 2)

```sql
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- 'text' | 'image'
content TEXT, -- normalized text; NULL for images
type TEXT NOT NULL, -- 'text' | 'image' | 'shell'
content TEXT, -- normalized text / OCR text; NULL for images
image_path TEXT, -- relative path under images/; NULL for text
image_format TEXT, -- 'png','jpeg','tiff',...
content_hash TEXT NOT NULL, -- 64-bit FNV-1a hex for text, SHA256 hex for images
created_at REAL NOT NULL, -- unix epoch seconds
last_used_at REAL NOT NULL,
size_bytes INTEGER NOT NULL,
is_pinned INTEGER NOT NULL DEFAULT 0,
is_favorite INTEGER NOT NULL DEFAULT 0,
use_count INTEGER NOT NULL DEFAULT 1,
source_app TEXT -- bundle id of frontmost app at capture, optional
source_app TEXT, -- bundle id of frontmost app at capture, optional
shortcut TEXT -- snippet abbreviation trigger, e.g. ';email'
);
-- Non-unique: dedup is enforced by lookup-inside-transaction (BEGIN IMMEDIATE
-- serializes writers across processes) with content equality verified, so a
-- 64-bit hash collision stores both entries rather than discarding one.
CREATE INDEX IF NOT EXISTS idx_entries_hash ON entries(type, content_hash);
CREATE INDEX IF NOT EXISTS idx_entries_lru ON entries(is_pinned, last_used_at);
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type, last_used_at DESC);
CREATE INDEX IF NOT EXISTS idx_entries_shortcut ON entries(shortcut);
CREATE INDEX IF NOT EXISTS idx_entries_fav ON entries(is_favorite, last_used_at DESC);

CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
content, content='entries', content_rowid='id', tokenize='unicode61'
Expand All @@ -63,6 +71,14 @@ CREATE TABLE IF NOT EXISTS stats_counters (
key TEXT PRIMARY KEY, -- e.g. 'events:2026-08-15', 'dups:2026-08-15'
value INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS entry_tags (
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
tag TEXT NOT NULL COLLATE NOCASE,
created_at REAL NOT NULL,
PRIMARY KEY (entry_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_entry_tags_entry ON entry_tags(entry_id);
```

## Config keys (strings in `config` table, typed accessors in Settings)
Expand Down Expand Up @@ -126,15 +142,18 @@ public struct StoreStats: Sendable {
/// The single entry point. An actor so all DB access is serialized per process.
/// Multi-process safety comes from SQLite WAL + busy_timeout.
public actor ClipboardStore {
public init(dataDir: URL? = nil) throws // nil → default/env resolution
public init(dataDir: URL? = nil,
now: @escaping @Sendable () -> Date = { Date() },
ocr: any OCREngine = VisionOCREngine()) throws
public nonisolated let dataDir: URL

// Capture path (fast): normalize → hash → indexed lookup → insert or touch.
// Returns the entry and whether it was a duplicate (touched, not inserted).
// OCR runs OUTSIDE the write transaction and off the actor executor.
@discardableResult
public func captureText(_ raw: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)? // nil if empty after normalization
@discardableResult
public func captureImage(data: Data, format: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)?
public func captureImage(data: Data, format: String, sourceApp: String?) async throws -> (entry: ClipboardEntry, wasDuplicate: Bool)?

// Queries
public func list(type: EntryType?, limit: Int, offset: Int) throws -> [ClipboardEntry]
Expand Down Expand Up @@ -182,6 +201,36 @@ public enum SafeRegex {
/// Never throws at match time; invalid pattern → .invalidPattern error on compile.
public static func compile(_ pattern: String) throws -> NSRegularExpression
}
public enum TextSummaries {
public static func singleLine(_ s: String, maxChars: Int) -> String // collapse ws/control chars, "…" truncate
public static func relativeTime(_ date: Date, now: Date) -> String // "now", "5m", "2h", "3d", else "yyyy-MM-dd"
}
public enum ImageFormats {
public static func uti(forFormat format: String) -> String? // 'gif' → 'com.compuserve.gif'
}
public enum ConfigKey { /* typed constants for every config-table key */ }

/// Injectable OCR seam (Vision-backed default; tests use stubs).
public protocol OCREngine: Sendable {
func recognizeText(from imageData: Data) async -> String?
}
public struct VisionOCREngine: OCREngine {}

// Shared clipboard content analysis (used by app UI and available to CLI):
public struct ParsedColor: Sendable, Equatable {} // r/g/b/a components
public enum ColorParser { public static func parse(_ raw: String?) -> ParsedColor? }
public enum CaseConverter { /* camel/pascal/snake/kebab/constant/upper/lower/title */ }
public enum TextTransformer { /* Base64 + URL encode/decode with length guards */ }
public struct JWTData: Sendable, Equatable { public static func parse(_ text: String?) -> JWTData? }
public struct EpochData: Sendable, Equatable { public static func parse(_ text: String?) -> EpochData? }

// IPC names shared by both processes:
public enum ClapIdentity { public static let bundleID = "com.spongycode.clap" }
public enum IPCNotifications {
public static let openUI = "com.spongycode.clap.openUI"
public static let storeChanged = "com.spongycode.clap.storeChanged"
public static let configChanged = "com.spongycode.clap.configChanged"
}
```

Notes:
Expand Down Expand Up @@ -244,7 +293,7 @@ pgrep ClapApp), print hint to start the app.
- UI lists are paged: fetch 100 rows, fetch more as selection/scroll nears the
end. Media tab = LazyVGrid of thumbnails.
- Keys: ↑/↓ navigate, Enter copy+close, Esc close, Cmd+F focus search,
Cmd+1/Cmd+2 tabs, Cmd+P pin toggle, Cmd+D or Option+Delete delete (the
Cmd+1-Cmd+4 tabs, Cmd+P pin toggle, Cmd+D or Option+Delete delete (the
latter yields to delete-word while editing a non-empty search), hover
selects a row (pointer-driven selection never auto-scrolls), Cmd+R
regex-mode toggle
Expand Down Expand Up @@ -291,10 +340,20 @@ clap pause / clap resume
- All commands honor `--data-dir <path>` and `CLAP_DATA_DIR`.
- Exit codes: 0 ok, 1 not found / no match, 2 usage error.

## Testing

Tests use a temp `CLAP_DATA_DIR`. Cover: normalization, hashing stability,
dedup (capture same text twice → 1 row, recency bumped), recency ordering,
count eviction, byte-size eviction, pinned immunity, clear, search (terms,
phrase, type filter), regex search incl. invalid pattern error, ByteSize
parse/format, SearchQuery.parse, retention.
## Testing & quality gates

Tests use a temp `CLAP_DATA_DIR` (via `withStore`) plus injected `now:` clock
and stub `OCREngine`. Cover: normalization, hashing stability, dedup (capture
same text twice → 1 row, recency bumped), recency ordering, count eviction,
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).

CI (`.github/workflows/ci.yml`) enforces three gates on every push:
`swift build -Xswiftc -warnings-as-errors`, full `swift test`, and a zero-
violation `swiftlint` pass (config in `.swiftlint.yml`). The UI is English-
only by design; SwiftUI text literals are already localization-ready should
translations ever be added.
25 changes: 22 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ let package = Package(
.macOS(.v14)
],
targets: [
// Core engine: database, search, dedup, eviction, settings, image store.
// No UI. Shared by the app and the CLI.
// Core engine: database, search, dedup, eviction, settings, image store,
// text analysis. No UI. Shared by the app and the CLI.
.target(
name: "ClapCore",
swiftSettings: [.swiftLanguageMode(.v5)],
Expand All @@ -20,10 +20,17 @@ let package = Package(
dependencies: ["ClapCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
// CLI command logic as a library so it is unit-testable; the executable
// target below stays a thin entry point.
.target(
name: "ClapCLIKit",
dependencies: ["ClapCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
// Command-line interface. The binary is named `clap`.
.executableTarget(
name: "clap",
dependencies: ["ClapCore"],
dependencies: ["ClapCLIKit"],
path: "Sources/ClapCLI",
swiftSettings: [.swiftLanguageMode(.v5)]
),
Expand All @@ -32,5 +39,17 @@ let package = Package(
dependencies: ["ClapCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "ClapCLITests",
dependencies: ["ClapCLIKit"],
path: "Tests/ClapCLITests",
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "ClapAppTests",
dependencies: ["ClapApp"],
path: "Tests/ClapAppTests",
swiftSettings: [.swiftLanguageMode(.v5)]
)
]
)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
- **Keyboard-first & Fast** — `⌘⇧V` opens the panel; single click or `Enter` copies, closes, and pastes directly.
- **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` / `⌘4`).
- **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.
- **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.
Expand Down Expand Up @@ -108,7 +108,7 @@ Press **⌘⇧V** to open the panel (configurable in Settings to `⌘⇧B`, `⌘
| **Single Click** | Copy entry, close panel, and paste directly into active app |
| **↑ / ↓** | Navigate entries |
| **Enter** | Copy selected entry, close, and paste into active app |
| **⌘1 / ⌘2 / ⌘3 / ⌘4** | Switch tabs: **Classic (⌘1)** · **Media (⌘2)** · **Shell (⌘3)** · **Favs (⌘4)** |
| **⌘1 / ⌘2 / ⌘3 / ⌘4** | Switch tabs: **Classic (⌘1)** · **Shell (⌘2)** · **Favs (⌘3)** · **Media (⌘4)** |
| **⌘S** or **⌘B** | Toggle Favorite / Bookmark on selected entry (marked with ❤️) |
| **⌘P** | Pin / unpin selected entry (pinned items stick to top of Classic view) |
| **⌘F** | Focus search bar |
Expand Down
4 changes: 2 additions & 2 deletions Scripts/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.1</string>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>4</string>
<string>5</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
Loading
Loading