From ee92de18a0be68666553509cc1fe5aad48c1a552 Mon Sep 17 00:00:00 2001 From: Patrick Schiller
Date: Fri, 14 Aug 2026 15:36:26 +0200 Subject: [PATCH] Capture Safari article images Signed-off-by: Patrick Schiller
---
README.de.md | 4 +-
README.md | 6 +-
ios/README.md | 6 +-
ios/SourceBraid/App/ContentView.swift | 2 +-
ios/SourceBraid/Shared/ClipBuilder.swift | 142 +++++++++++++++++-
ios/SourceBraid/Shared/GitHubClient.swift | 7 +
ios/SourceBraidShare/ShareInputResolver.swift | 19 ++-
ios/SourceBraidShare/SharePreprocessing.js | 72 ++++++++-
ios/SourceBraidShare/ShareViewModel.swift | 54 ++++++-
ios/SourceBraidTests/ClipBuilderTests.swift | 37 +++++
tests/test_ios_share_preprocessing.py | 21 ++-
11 files changed, 352 insertions(+), 18 deletions(-)
diff --git a/README.de.md b/README.de.md
index 10e615a..2359783 100644
--- a/README.de.md
+++ b/README.de.md
@@ -343,8 +343,8 @@ zur lokalen Codex-Einrichtung und zum späteren ChatGPT-Endpunkt stehen in
Die native iOS-App und Share Extension liegen unter [`ios/`](ios/README.md).
Nach einmaliger Einrichtung von Repository und Token können URLs, ausgewählter
-Text, Safari-Artikel, PDFs und andere Dateien über das Teilen-Menü an das
-konfigurierte private Archiv gesendet werden.
+Text, Safari-Artikel mit relevanten Bild-Assets, PDFs und andere Dateien über
+das Teilen-Menü an das konfigurierte private Archiv gesendet werden.
## Android-Roadmap
diff --git a/README.md b/README.md
index 5518ba7..6a59159 100644
--- a/README.md
+++ b/README.md
@@ -313,9 +313,9 @@ for the local Codex setup and the later ChatGPT endpoint.
## iOS
The native iOS app and Share Extension live under [`ios/`](ios/README.md).
-After one-time repository and token setup, URLs, selected text, Safari articles,
-PDFs, and other files can be sent to the configured private archive through the
-system share sheet.
+After one-time repository and token setup, URLs, selected text, Safari articles
+with relevant image assets, PDFs, and other files can be sent to the configured
+private archive through the system share sheet.
## Android roadmap
diff --git a/ios/README.md b/ios/README.md
index 6d1b51b..6992959 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -1,6 +1,6 @@
# SourceBraid for iOS
-The iOS project contains a SwiftUI configuration app and a native Share Extension. It saves shared URLs, readable Safari content, selected text, PDFs, and other files into the same GitHub repository and URL-hash-sharded `web-clips/index/*.jsonl` format as the Chrome extension.
+The iOS project contains a SwiftUI configuration app and a native Share Extension. It saves shared URLs, readable Safari content and its relevant image assets, selected text, PDFs, and other files into the same GitHub repository and URL-hash-sharded `web-clips/index/*.jsonl` format as the Chrome extension.
## Open and sign
@@ -63,13 +63,13 @@ python3 ../scripts/validate_ios_release.py \
## Use
-In FAZ, Safari, Files, or another app:
+In Safari, Files, or another app:
1. Tap **Share**.
2. Choose **SourceBraid**. If it is hidden, use **More** to enable it.
3. Edit the title, add optional tags or a note, and tap **Save**.
-Apps such as Chrome and FAZ generally share only a URL. SourceBraid loads public web URLs in an isolated web view and converts the readable page content to Markdown before saving, recording the same extraction methods as the browser extension (including specialized DeepMind captures). Safari can additionally supply its already visible page text through the extension's preprocessing script. Shared PDFs are queued as `pdf-docling-pending`; the PDF is pushed last so the existing GitHub Actions workflow can safely convert it after its Markdown and index metadata exist. Pages that require an authenticated browser session or block the isolated request are saved as clearly labeled link-only clips.
+Apps such as Chrome generally share only a URL. SourceBraid loads public web URLs in an isolated web view and converts the readable page content to Markdown before saving, recording the same extraction methods as the browser extension (including specialized DeepMind captures). Safari can additionally supply its already visible page text and relevant article images through the extension's preprocessing script. Images are copied into the clip's asset folder and linked relatively in Markdown. Shared PDFs are queued as `pdf-docling-pending`; the PDF is pushed last so the existing GitHub Actions workflow can safely convert it after its Markdown and index metadata exist. Pages that require an authenticated browser session or block the isolated request are saved as clearly labeled link-only clips.
## Build without signing
diff --git a/ios/SourceBraid/App/ContentView.swift b/ios/SourceBraid/App/ContentView.swift
index 2fb8623..46a551a 100644
--- a/ios/SourceBraid/App/ContentView.swift
+++ b/ios/SourceBraid/App/ContentView.swift
@@ -14,7 +14,7 @@ struct ContentView: View {
)
.foregroundStyle(model.isConfigured ? Color.green : Color.orange)
- Text("In FAZ or any other app, tap Share and choose “SourceBraid”.")
+ Text("Weave the web into durable Markdown in your own GitHub repository.")
.font(.subheadline)
.foregroundStyle(.secondary)
} header: {
diff --git a/ios/SourceBraid/Shared/ClipBuilder.swift b/ios/SourceBraid/Shared/ClipBuilder.swift
index 2da1a7e..9b1e459 100644
--- a/ios/SourceBraid/Shared/ClipBuilder.swift
+++ b/ios/SourceBraid/Shared/ClipBuilder.swift
@@ -10,6 +10,7 @@ struct CaptureInput {
var fileData: Data?
var filename: String?
var mimeType: String?
+ var images: [CaptureImage] = []
static let empty = CaptureInput(
url: nil,
@@ -22,17 +23,51 @@ struct CaptureInput {
)
}
+struct CaptureImage: Equatable {
+ let url: URL
+ let alt: String
+ let caption: String
+}
+
+struct CapturedImageAsset {
+ let image: CaptureImage
+ let data: Data
+ let mimeType: String
+}
+
struct CaptureAttachment {
let path: String
let data: Data
}
+struct CapturedImageAttachment {
+ let image: CaptureImage
+ let attachment: CaptureAttachment
+ let mimeType: String
+}
+
struct CaptureDraft {
let title: String
let path: String
let markdown: String
let indexEntry: SourceBraidIndexEntry
let attachment: CaptureAttachment?
+ let imageAttachments: [CapturedImageAttachment]
+}
+
+struct SourceBraidImageIndexEntry: Encodable {
+ let url: String
+ let path: String
+ let relativePath: String
+ let alt: String
+ let caption: String
+ let contentType: String
+
+ enum CodingKeys: String, CodingKey {
+ case url, path, alt, caption
+ case relativePath = "relative_path"
+ case contentType = "content_type"
+ }
}
struct SourceBraidIndexEntry: Encodable {
@@ -50,6 +85,7 @@ struct SourceBraidIndexEntry: Encodable {
let capturedAt: String
let attachmentPath: String?
let pdfPath: String?
+ let images: [SourceBraidImageIndexEntry]?
enum CodingKeys: String, CodingKey {
case title, url, path, date, tags, source
@@ -61,6 +97,7 @@ struct SourceBraidIndexEntry: Encodable {
case capturedAt = "captured_at"
case attachmentPath = "attachment_path"
case pdfPath = "pdf_path"
+ case images
}
}
@@ -73,11 +110,12 @@ enum ClipBuilder {
tags: [String],
notes: String,
configuration: SourceBraidConfiguration,
+ imageAssets: [CapturedImageAsset] = [],
now: Date = Date()
) throws -> CaptureDraft {
let normalizedConfiguration = configuration.normalized()
let hasText = !input.sharedText.trimmed.isEmpty || !input.articleText.trimmed.isEmpty
- guard input.url != nil || hasText || input.fileData != nil else {
+ guard input.url != nil || hasText || input.fileData != nil || !input.images.isEmpty else {
throw ClipBuilderError.emptyInput
}
if let data = input.fileData, data.count > maximumAttachmentBytes {
@@ -114,6 +152,10 @@ enum ClipBuilder {
captureMethod = "ios-share-url"
sourceType = "article"
contentFormat = "link"
+ } else if !input.images.isEmpty {
+ captureMethod = "ios-share-images"
+ sourceType = "article"
+ contentFormat = "images"
} else {
captureMethod = "ios-share-text"
sourceType = "note"
@@ -131,6 +173,25 @@ enum ClipBuilder {
attachment = nil
}
+ let imageAttachments = queuesPDFConversion
+ ? []
+ : buildImageAttachments(
+ imageAssets,
+ rootFolder: normalizedConfiguration.rootFolder,
+ captureDate: captureDate,
+ markdownPath: path
+ )
+ let indexImages = imageAttachments.map { image in
+ SourceBraidImageIndexEntry(
+ url: image.image.url.absoluteString,
+ path: image.attachment.path,
+ relativePath: relativePath(from: path, to: image.attachment.path),
+ alt: image.image.alt,
+ caption: image.image.caption,
+ contentType: image.mimeType
+ )
+ }
+
let markdown = buildMarkdown(
input: input,
title: title,
@@ -145,6 +206,7 @@ enum ClipBuilder {
tags: tags,
notes: notes,
attachmentPath: attachment?.path,
+ imageAttachments: imageAttachments,
markdownPath: path
)
let entry = SourceBraidIndexEntry(
@@ -161,9 +223,17 @@ enum ClipBuilder {
converter: queuesPDFConversion ? "docling" : nil,
capturedAt: capturedAt,
attachmentPath: queuesPDFConversion ? nil : attachment?.path,
- pdfPath: queuesPDFConversion ? attachment?.path : nil
+ pdfPath: queuesPDFConversion ? attachment?.path : nil,
+ images: indexImages.isEmpty ? nil : indexImages
+ )
+ return CaptureDraft(
+ title: title,
+ path: path,
+ markdown: markdown,
+ indexEntry: entry,
+ attachment: attachment,
+ imageAttachments: imageAttachments
)
- return CaptureDraft(title: title, path: path, markdown: markdown, indexEntry: entry, attachment: attachment)
}
static func parseTags(_ value: String) -> [String] {
@@ -188,6 +258,7 @@ enum ClipBuilder {
tags: [String],
notes: String,
attachmentPath: String?,
+ imageAttachments: [CapturedImageAttachment],
markdownPath: String
) -> String {
var lines = [
@@ -243,9 +314,46 @@ enum ClipBuilder {
if !sharedText.isEmpty, sharedText != input.url?.absoluteString, sharedText != articleText {
lines.append(contentsOf: ["## Shared text", "", sharedText, ""])
}
+ if !imageAttachments.isEmpty {
+ lines.append(contentsOf: ["## Images", ""])
+ for image in imageAttachments {
+ let alt = markdownImageAlt(image.image.alt)
+ let imagePath = relativePath(from: markdownPath, to: image.attachment.path)
+ lines.append(")")
+ let caption = image.image.caption.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !caption.isEmpty, caption != image.image.alt {
+ lines.append(caption)
+ }
+ lines.append("")
+ }
+ }
return lines.joined(separator: "\n") + "\n"
}
+ private static func buildImageAttachments(
+ _ imageAssets: [CapturedImageAsset],
+ rootFolder: String,
+ captureDate: String,
+ markdownPath: String
+ ) -> [CapturedImageAttachment] {
+ let clipSlug = markdownPath
+ .split(separator: "/")
+ .last
+ .map(String.init)?
+ .replacingOccurrences(of: ".md", with: "") ?? "document"
+ return imageAssets.enumerated().map { index, asset in
+ let filename = String(format: "%02d", index + 1)
+ + "."
+ + imageFileExtension(url: asset.image.url, mimeType: asset.mimeType)
+ let path = "\(rootFolder)/\(captureDate.prefix(4))/\(captureDate.dropFirst(5).prefix(2))/assets/\(clipSlug)/\(filename)"
+ return CapturedImageAttachment(
+ image: asset.image,
+ attachment: CaptureAttachment(path: path, data: asset.data),
+ mimeType: asset.mimeType
+ )
+ }
+ }
+
private static func normalizedTitle(_ suppliedTitle: String, input: CaptureInput) -> String {
let candidates: [String?] = [suppliedTitle, input.suggestedTitle, input.filename, input.url.flatMap(hostname), "Saved item"]
return candidates.compactMap { $0?.trimmed }.first { !$0.isEmpty } ?? "Saved item"
@@ -298,6 +406,34 @@ enum ClipBuilder {
return mimeType == "application/pdf" ? "pdf" : "bin"
}
+ private static func imageFileExtension(url: URL, mimeType: String) -> String {
+ let extensions = [
+ "image/jpeg": "jpg",
+ "image/jpg": "jpg",
+ "image/png": "png",
+ "image/webp": "webp",
+ "image/gif": "gif",
+ "image/svg+xml": "svg",
+ "image/avif": "avif"
+ ]
+ if let value = extensions[mimeType.lowercased()] {
+ return value
+ }
+ let value = url.pathExtension.lowercased()
+ if value.range(of: "^[a-z0-9]{1,8}$", options: .regularExpression) != nil {
+ return value
+ }
+ return "jpg"
+ }
+
+ private static func markdownImageAlt(_ value: String) -> String {
+ value
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "[", with: "\\[")
+ .replacingOccurrences(of: "]", with: "\\]")
+ .replacingOccurrences(of: "\n", with: " ")
+ }
+
private static func relativePath(from markdownPath: String, to attachmentPath: String) -> String {
var from = markdownPath.split(separator: "/").dropLast().map(String.init)
var to = attachmentPath.split(separator: "/").map(String.init)
diff --git a/ios/SourceBraid/Shared/GitHubClient.swift b/ios/SourceBraid/Shared/GitHubClient.swift
index 1d0004e..2215173 100644
--- a/ios/SourceBraid/Shared/GitHubClient.swift
+++ b/ios/SourceBraid/Shared/GitHubClient.swift
@@ -21,6 +21,13 @@ struct GitHubClient {
try await putReplacing(path: attachment.path, data: attachment.data, message: "Queue SourceBraid PDF: \(draft.title)")
return
}
+ for image in draft.imageAttachments {
+ try await putReplacing(
+ path: image.attachment.path,
+ data: image.attachment.data,
+ message: "Save SourceBraid image: \(draft.title)"
+ )
+ }
if let attachment = draft.attachment {
try await putReplacing(path: attachment.path, data: attachment.data, message: "Save SourceBraid attachment: \(draft.title)")
}
diff --git a/ios/SourceBraidShare/ShareInputResolver.swift b/ios/SourceBraidShare/ShareInputResolver.swift
index ac9d059..c7f9593 100644
--- a/ios/SourceBraidShare/ShareInputResolver.swift
+++ b/ios/SourceBraidShare/ShareInputResolver.swift
@@ -49,7 +49,7 @@ enum ShareInputResolver {
input.url = url
}
}
- guard input.url != nil || input.fileData != nil || !input.sharedText.isEmpty || !input.articleText.isEmpty else {
+ guard input.url != nil || input.fileData != nil || !input.sharedText.isEmpty || !input.articleText.isEmpty || !input.images.isEmpty else {
throw ShareInputError.unsupported
}
return input
@@ -69,6 +69,23 @@ enum ShareInputResolver {
if input.articleText.isEmpty, let value = result["articleText"] as? String {
input.articleText = limited(value)
}
+ if let values = result["images"] as? [Any] {
+ var seen = Set