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
4 changes: 2 additions & 2 deletions README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions ios/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ios/SourceBraid/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
142 changes: 139 additions & 3 deletions ios/SourceBraid/Shared/ClipBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct CaptureInput {
var fileData: Data?
var filename: String?
var mimeType: String?
var images: [CaptureImage] = []

static let empty = CaptureInput(
url: nil,
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -61,6 +97,7 @@ struct SourceBraidIndexEntry: Encodable {
case capturedAt = "captured_at"
case attachmentPath = "attachment_path"
case pdfPath = "pdf_path"
case images
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -145,6 +206,7 @@ enum ClipBuilder {
tags: tags,
notes: notes,
attachmentPath: attachment?.path,
imageAttachments: imageAttachments,
markdownPath: path
)
let entry = SourceBraidIndexEntry(
Expand All @@ -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] {
Expand All @@ -188,6 +258,7 @@ enum ClipBuilder {
tags: [String],
notes: String,
attachmentPath: String?,
imageAttachments: [CapturedImageAttachment],
markdownPath: String
) -> String {
var lines = [
Expand Down Expand Up @@ -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("![\(alt)](\(imagePath))")
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"
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions ios/SourceBraid/Shared/GitHubClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
19 changes: 18 additions & 1 deletion ios/SourceBraidShare/ShareInputResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String>()
input.images = values.compactMap { value in
guard let image = value as? [String: Any],
let urlValue = image["url"] as? String,
let url = URL(string: urlValue),
["http", "https"].contains(url.scheme?.lowercased() ?? ""),
seen.insert(url.absoluteString).inserted else {
return nil
}
return CaptureImage(
url: url,
alt: limited(image["alt"] as? String ?? ""),
caption: limited(image["caption"] as? String ?? "")
)
}
}
}

private static func supportedFileType(_ provider: NSItemProvider) -> String? {
Expand Down
Loading