Skip to content
Open
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
36 changes: 36 additions & 0 deletions MARC/App/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import SwiftUI
import SwiftData

struct ContentView: View {
var body: some View {
TabView {
NavigationStack {
TimelineView()
}
.tabItem {
Label("Timeline", systemImage: "clock.arrow.circlepath")
}

NavigationStack {
SearchView()
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}

NavigationStack {
SettingsView()
}
.tabItem {
Label("Settings", systemImage: "gearshape")
}
}
.background(Color.marcBackground.ignoresSafeArea())
}
}

extension Color {
static let marcBackground = Color(red: 10/255, green: 14/255, blue: 26/255)
static let marcAccent = Color(red: 74/255, green: 158/255, blue: 1.0)
static let marcCard = Color.white.opacity(0.08)
}
33 changes: 33 additions & 0 deletions MARC/App/MARCApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import SwiftUI
import SwiftData

@main
struct MARCApp: App {
@State private var hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")

var sharedModelContainer: ModelContainer = {
do {
return try ModelContainer(for: Memory.self, Tag.self)
} catch {
fatalError("Unable to create model container: \(error)")
}
}()

var body: some Scene {
WindowGroup {
Group {
if hasCompletedOnboarding {
ContentView()
} else {
OnboardingView {
hasCompletedOnboarding = true
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
}
}
}
.preferredColorScheme(.dark)
.tint(Color.marcAccent)
}
.modelContainer(sharedModelContainer)
}
}
56 changes: 56 additions & 0 deletions MARC/Models/Memory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import SwiftData

@Model
final class Memory {
@Attribute(.unique) var id: UUID
@Attribute(.unique) var assetIdentifier: String = ""
@Attribute(.externalStorage) var imageData: Data
@Attribute(.externalStorage) var thumbnailData: Data
var extractedText: String
@Attribute(.externalStorage) var embeddingData: Data
@Relationship(deleteRule: .nullify) var tags: [Tag]
var userNote: String?
var createdAt: Date
var importedAt: Date
var reminderDate: Date?
var isFavourite: Bool
var sourceApp: String?

init(
id: UUID = UUID(),
assetIdentifier: String = "",
imageData: Data,
thumbnailData: Data,
extractedText: String,
embedding: [Float],
tags: [Tag] = [],
userNote: String? = nil,
createdAt: Date,
importedAt: Date = .now,
reminderDate: Date? = nil,
isFavourite: Bool = false,
sourceApp: String? = nil
) {
self.id = id
self.assetIdentifier = assetIdentifier
self.imageData = imageData
self.thumbnailData = thumbnailData
self.extractedText = extractedText
self.embeddingData = embedding.withUnsafeBufferPointer { Data(buffer: $0) }
self.tags = tags
self.userNote = userNote
self.createdAt = createdAt
self.importedAt = importedAt
self.reminderDate = reminderDate
self.isFavourite = isFavourite
self.sourceApp = sourceApp
}

var embedding: [Float] {
embeddingData.withUnsafeBytes { raw in
let ptr = raw.bindMemory(to: Float.self)
return Array(ptr)
}
}
}
80 changes: 80 additions & 0 deletions MARC/Models/MemoryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation
import SwiftData
import UIKit

@MainActor
final class MemoryStore: ObservableObject {
@Published var isProcessingImport = false

private let ocrService = OCRService()
private let embeddingService = EmbeddingService()
private let autoTagger = AutoTagger()

func importImage(
_ image: UIImage,
createdAt: Date = .now,
assetIdentifier: String = "",
context: ModelContext
) async {
isProcessingImport = true
defer { isProcessingImport = false }

let compressed = image.jpegData(compressionQuality: 0.82) ?? Data()
let thumbnail = image.thumbnailJPEGData(maxWidth: 300) ?? Data()

let text: String
do {
text = try await ocrService.extractText(from: image)
} catch {
text = ""
}

let vector = embeddingService.generateEmbedding(for: text) ?? []
let autoTagNames = autoTagger.tags(for: text)

let tags: [Tag] = autoTagNames.map { name in
let descriptor = FetchDescriptor<Tag>(predicate: #Predicate { $0.name == name })
if let existing = try? context.fetch(descriptor).first {
return existing
}

let newTag = Tag(name: name)
context.insert(newTag)
return newTag
}

let resolvedIdentifier = assetIdentifier.isEmpty ? "manual-\(UUID().uuidString)" : assetIdentifier

let memory = Memory(
assetIdentifier: resolvedIdentifier,
imageData: compressed,
thumbnailData: thumbnail,
extractedText: text,
embedding: vector,
tags: tags,
createdAt: createdAt
)

context.insert(memory)

do {
try context.save()
let impact = UIImpactFeedbackGenerator(style: .medium)
impact.impactOccurred()
} catch {
context.delete(memory)
}
}
}

private extension UIImage {
func thumbnailJPEGData(maxWidth: CGFloat) -> Data? {
let ratio = maxWidth / size.width
let targetSize = CGSize(width: maxWidth, height: size.height * ratio)
let renderer = UIGraphicsImageRenderer(size: targetSize)
let image = renderer.image { _ in
draw(in: CGRect(origin: .zero, size: targetSize))
}
return image.jpegData(compressionQuality: 0.72)
}
}
17 changes: 17 additions & 0 deletions MARC/Models/Tag.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import SwiftData

@Model
final class Tag {
var id: UUID
@Attribute(.unique) var name: String
var colorHex: String
var isUserDefined: Bool

init(id: UUID = UUID(), name: String, colorHex: String = "#4A9EFF", isUserDefined: Bool = false) {
self.id = id
self.name = name
self.colorHex = colorHex
self.isUserDefined = isUserDefined
}
}
62 changes: 62 additions & 0 deletions MARC/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# MARC — Memory Augmented Recall Companion

MARC is a local-first iOS app that ingests screenshots into a searchable personal memory index.

## Privacy promise

- No accounts
- No cloud sync
- No analytics/telemetry
- No network calls

## Requirements

- Xcode 15+
- iOS 17+ deployment target
- Swift 5.9+

## Project structure

```
MARC/
├── App/
├── Models/
├── Services/
├── Utilities/
└── Views/
```

## Setup

1. In Xcode, create a new iOS App target named `MARC`.
2. Drag the `MARC/` source folders into the target.
3. Enable capabilities:
- Photos
- Notifications
4. Add usage strings to `Info.plist`:
- `NSPhotoLibraryUsageDescription`
- `NSPhotoLibraryAddUsageDescription`
5. Build and run on iOS 17 simulator/device.

## Embeddings

MVP uses `NaturalLanguage.NLEmbedding.sentenceEmbedding(for: .english)` for on-device sentence vectors.

### Optional Core ML upgrade

If you want stronger retrieval quality, convert a quantized MiniLM model to Core ML and place it in `MARC/Resources/`.

Example conversion entry point:

```bash
python -m pip install coremltools transformers optimum
python convert_minilm_to_coreml.py
```

Then update `EmbeddingService` to call the generated Core ML model and keep vector dimension fixed (for example 384).

## Notes

- OCR is powered by Vision (`VNRecognizeTextRequest`) with `.accurate` mode.
- Cosine similarity uses Accelerate (`vDSP.dot`, `vDSP.sumOfSquares`).
- Screenshot import is event-driven via `PHPhotoLibraryChangeObserver`.
25 changes: 25 additions & 0 deletions MARC/Services/AutoTagger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

struct AutoTagger {
func tags(for text: String) -> [String] {
let lower = text.lowercased()
let words = lower.split(whereSeparator: { $0.isWhitespace || $0.isNewline })
var output: [String] = []

if hasAny(lower, ["total", "£", "$", "payment", "order", "invoice", "vat"]) { output.append("Receipt/Finance") }
if hasAny(lower, ["menu", "restaurant", "book a table", "pasta", "pizza", "brunch"]) { output.append("Food/Restaurant") }
if hasAny(lower, ["flight", "booking", "hotel", "departure", "gate"]) { output.append("Travel") }
if hasAny(lower, ["@", ".com"]) || lower.range(of: #"\+?\d[\d\s\-]{7,}"#, options: .regularExpression) != nil { output.append("Contact") }
if hasAny(lower, ["street", "road", "avenue", "postcode", "zip"]) { output.append("Location") }
if lower.range(of: #"(func\s+\w+|let\s+\w+|class\s+\w+|\{.*\})"#, options: .regularExpression) != nil { output.append("Code") }
if lower.range(of: #"^\w+:|\d{1,2}:\d{2}"#, options: [.regularExpression, .anchorsMatchLines]) != nil { output.append("Conversation") }
if words.count > 200 { output.append("Web/Article") }
if words.count < 50 && output.isEmpty { output.append("Idea/Note") }

return output.isEmpty ? ["Uncategorised"] : output
}

private func hasAny(_ text: String, _ keywords: [String]) -> Bool {
keywords.contains { text.contains($0) }
}
}
13 changes: 13 additions & 0 deletions MARC/Services/EmbeddingService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation
import NaturalLanguage

struct EmbeddingService {
private let sentenceEmbedding = NLEmbedding.sentenceEmbedding(for: .english)

func generateEmbedding(for text: String) -> [Float]? {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return nil }
guard let vector = sentenceEmbedding?.vector(for: normalized) else { return nil }
return vector.map(Float.init)
}
}
41 changes: 41 additions & 0 deletions MARC/Services/OCRService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import UIKit
import Vision

struct OCRService {
func extractText(from image: UIImage) async throws -> String {
guard let cgImage = image.cgImage else { return "" }

return try await withCheckedThrowingContinuation { continuation in
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}

let observations = (request.results as? [VNRecognizedTextObservation]) ?? []
let ordered = observations.sorted {
let lhs = $0.boundingBox
let rhs = $1.boundingBox
if abs(lhs.midY - rhs.midY) > 0.03 {
return lhs.midY > rhs.midY
}
return lhs.minX < rhs.minX
}

let lines = ordered.compactMap { $0.topCandidates(1).first?.string }
continuation.resume(returning: lines.joined(separator: "\n"))
}

request.recognitionLevel = .accurate
request.recognitionLanguages = ["en-GB", "en"]
request.usesLanguageCorrection = true

let handler = VNImageRequestHandler(cgImage: cgImage)
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
}
25 changes: 25 additions & 0 deletions MARC/Services/ReminderService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import UserNotifications

struct ReminderService {
func requestPermission() async throws {
let center = UNUserNotificationCenter.current()
_ = try await center.requestAuthorization(options: [.alert, .sound, .badge])
}

func scheduleReminder(for memory: Memory, at date: Date) async throws {
let content = UNMutableNotificationContent()
content.title = "MARC reminder"
content.body = memory.extractedText.isEmpty ? "A saved memory is ready to revisit." : String(memory.extractedText.prefix(120))
content.userInfo = ["memoryId": memory.id.uuidString]

let interval = max(date.timeIntervalSinceNow, 1)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false)
let request = UNNotificationRequest(identifier: memory.id.uuidString, content: content, trigger: trigger)
try await UNUserNotificationCenter.current().add(request)
}

func pendingRequests() async -> [UNNotificationRequest] {
await UNUserNotificationCenter.current().pendingNotificationRequests()
}
}
Loading