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
11 changes: 6 additions & 5 deletions ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ The iOS project contains a SwiftUI configuration app and a native Share Extensio
2. Select the **SourceBraid** project and the **SourceBraid** target.
3. Under **Signing & Capabilities**, select your Apple Developer team.
4. Repeat for the **SourceBraidShare** target.
5. If the bundle identifiers are unavailable, replace `de.patrickschiller.sourcebraid` and `de.patrickschiller.sourcebraid.share` with identifiers owned by your team.
6. Keep the App Group identical in both targets: `group.de.patrickschiller.sourcebraid`.
5. The existing TestFlight app keeps its pre-rebrand bundle identifiers `de.patrickschiller.stowmark` and `de.patrickschiller.stowmark.share`; changing them would create a different app instead of an update.
6. Keep the pre-rebrand App Group identical in both targets: `group.de.patrickschiller.stowmark`. This preserves settings and Keychain access across the SourceBraid rename.
7. Run the SourceBraid app on your iPhone once and configure GitHub.

The fine-grained GitHub token needs `Contents: Read and write` for the private SourceBraid repository. It is stored in a Keychain access group shared only by the app and extension.
Expand Down Expand Up @@ -38,8 +38,9 @@ xcodebuild \
-allowProvisioningUpdates
```

The export options upload the archive directly to App Store Connect. The app
record there must use bundle ID `de.patrickschiller.sourcebraid`.
The export options upload the archive directly to App Store Connect. The existing
app record uses bundle ID `de.patrickschiller.stowmark`; its customer-facing name
is **SourceBraid**.

## Use

Expand All @@ -49,7 +50,7 @@ In FAZ, Safari, Files, or another app:
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. Safari can additionally supply its already visible page text through the extension's preprocessing script. Pages that require an authenticated browser session or block the isolated request are saved as clearly labeled link-only clips.
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.

## Build without signing

Expand Down
20 changes: 10 additions & 10 deletions ios/SourceBraid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SourceBraid/Resources/SourceBraid.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = H76K2HQUFB;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
Expand All @@ -462,7 +462,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.sourcebraid;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.stowmark;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
Expand All @@ -480,7 +480,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SourceBraid/Resources/SourceBraid.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = H76K2HQUFB;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = SourceBraid/Resources/Info.plist;
Expand All @@ -490,7 +490,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.sourcebraid;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.stowmark;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
Expand All @@ -507,7 +507,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = SourceBraidShare/SourceBraidShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = H76K2HQUFB;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = SourceBraidShare/Info.plist;
Expand All @@ -516,7 +516,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.sourcebraid.share;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.stowmark.share;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand All @@ -534,7 +534,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = SourceBraidShare/SourceBraidShare.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = H76K2HQUFB;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = SourceBraidShare/Info.plist;
Expand All @@ -543,7 +543,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.sourcebraid.share;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.stowmark.share;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand All @@ -566,7 +566,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.sourcebraid.tests;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.stowmark.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphonesimulator;
SWIFT_VERSION = 5.0;
Expand All @@ -586,7 +586,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.sourcebraid.tests;
PRODUCT_BUNDLE_IDENTIFIER = de.patrickschiller.stowmark.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphonesimulator;
SWIFT_VERSION = 5.0;
Expand Down
2 changes: 1 addition & 1 deletion ios/SourceBraid/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>SourceBraidKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)de.patrickschiller.sourcebraid.shared</string>
<string>$(AppIdentifierPrefix)de.patrickschiller.stowmark.shared</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
Expand Down
4 changes: 2 additions & 2 deletions ios/SourceBraid/Resources/SourceBraid.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.patrickschiller.sourcebraid</string>
<string>group.de.patrickschiller.stowmark</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)de.patrickschiller.sourcebraid.shared</string>
<string>$(AppIdentifierPrefix)de.patrickschiller.stowmark.shared</string>
</array>
</dict>
</plist>
35 changes: 29 additions & 6 deletions ios/SourceBraid/Shared/ClipBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,22 @@ struct SourceBraidIndexEntry: Encodable {
let captureMethod: String
let sourceType: String
let contentFormat: String
let conversionStatus: String?
let converter: String?
let capturedAt: String
let attachmentPath: String?
let pdfPath: String?

enum CodingKeys: String, CodingKey {
case title, url, path, date, tags, source
case captureMethod = "capture_method"
case sourceType = "source_type"
case contentFormat = "content_format"
case conversionStatus = "conversion_status"
case converter
case capturedAt = "captured_at"
case attachmentPath = "attachment_path"
case pdfPath = "pdf_path"
}
}

Expand Down Expand Up @@ -90,11 +96,16 @@ enum ClipBuilder {
let captureMethod: String
let sourceType: String
let contentFormat: String
let queuesPDFConversion = input.fileData != nil && input.mimeType == "application/pdf"

if input.fileData != nil {
if queuesPDFConversion {
captureMethod = "pdf-docling-pending"
sourceType = "pdf"
contentFormat = "pdf"
} else if input.fileData != nil {
captureMethod = "ios-share-file"
sourceType = "document"
contentFormat = input.mimeType == "application/pdf" ? "pdf" : "file"
contentFormat = "file"
} else if !input.articleText.trimmed.isEmpty {
captureMethod = input.articleCaptureMethod ?? "ios-share-safari"
sourceType = "article"
Expand All @@ -113,7 +124,8 @@ enum ClipBuilder {
if let data = input.fileData {
let ext = safeFileExtension(input.filename, mimeType: input.mimeType)
let clipSlug = path.split(separator: "/").last.map(String.init)?.replacingOccurrences(of: ".md", with: "") ?? "document"
let attachmentPath = "\(normalizedConfiguration.rootFolder)/\(captureDate.prefix(4))/\(captureDate.dropFirst(5).prefix(2))/assets/\(clipSlug)/original.\(ext)"
let attachmentName = queuesPDFConversion ? "source.pdf" : "original.\(ext)"
let attachmentPath = "\(normalizedConfiguration.rootFolder)/\(captureDate.prefix(4))/\(captureDate.dropFirst(5).prefix(2))/assets/\(clipSlug)/\(attachmentName)"
attachment = CaptureAttachment(path: attachmentPath, data: data)
} else {
attachment = nil
Expand All @@ -129,6 +141,7 @@ enum ClipBuilder {
captureMethod: captureMethod,
sourceType: sourceType,
contentFormat: contentFormat,
queuesPDFConversion: queuesPDFConversion,
tags: tags,
notes: notes,
attachmentPath: attachment?.path,
Expand All @@ -144,8 +157,11 @@ enum ClipBuilder {
captureMethod: captureMethod,
sourceType: sourceType,
contentFormat: contentFormat,
conversionStatus: queuesPDFConversion ? "pending" : nil,
converter: queuesPDFConversion ? "docling" : nil,
capturedAt: capturedAt,
attachmentPath: attachment?.path
attachmentPath: queuesPDFConversion ? nil : attachment?.path,
pdfPath: queuesPDFConversion ? attachment?.path : nil
)
return CaptureDraft(title: title, path: path, markdown: markdown, indexEntry: entry, attachment: attachment)
}
Expand All @@ -168,6 +184,7 @@ enum ClipBuilder {
captureMethod: String,
sourceType: String,
contentFormat: String,
queuesPDFConversion: Bool,
tags: [String],
notes: String,
attachmentPath: String?,
Expand All @@ -184,7 +201,11 @@ enum ClipBuilder {
"source_type: \(yamlQuote(sourceType))",
"content_format: \(yamlQuote(contentFormat))"
]
if let attachmentPath {
if queuesPDFConversion, let attachmentPath {
lines.append("conversion_status: \(yamlQuote("pending"))")
lines.append("converter: \(yamlQuote("docling"))")
lines.append("pdf_path: \(yamlQuote(attachmentPath))")
} else if let attachmentPath {
lines.append("attachment_path: \(yamlQuote(attachmentPath))")
}
if !tags.isEmpty {
Expand All @@ -208,7 +229,9 @@ enum ClipBuilder {
if let url = input.url {
lines.append(contentsOf: ["[Open original source](\(url.absoluteString))", ""])
}
if let attachmentPath {
if queuesPDFConversion {
lines.append(contentsOf: ["> The original PDF has been uploaded. GitHub Actions will replace this placeholder with Docling Markdown.", ""])
} else if let attachmentPath {
lines.append(contentsOf: ["[Open saved attachment](\(relativePath(from: markdownPath, to: attachmentPath)))", ""])
}

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 @@ -14,6 +14,13 @@ struct GitHubClient {
}

func save(_ draft: CaptureDraft) async throws {
if draft.indexEntry.captureMethod == "pdf-docling-pending", let attachment = draft.attachment {
// The PDF push triggers the conversion workflow, so its metadata must exist first.
try await putReplacing(path: draft.path, data: Data(draft.markdown.utf8), message: "Queue PDF SourceBraid capture: \(draft.title)")
try await updateIndex(with: draft.indexEntry)
try await putReplacing(path: attachment.path, data: attachment.data, message: "Queue SourceBraid PDF: \(draft.title)")
return
}
if let attachment = draft.attachment {
try await putReplacing(path: attachment.path, data: attachment.data, message: "Save SourceBraid attachment: \(draft.title)")
}
Expand Down
5 changes: 3 additions & 2 deletions ios/SourceBraid/Shared/SourceBraidEnvironment.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Foundation

enum SourceBraidEnvironment {
static let appGroupIdentifier = "group.de.patrickschiller.sourcebraid"
static let keychainService = "de.patrickschiller.sourcebraid"
// Keep the pre-rebrand identifiers so TestFlight upgrades retain settings and credentials.
static let appGroupIdentifier = "group.de.patrickschiller.stowmark"
static let keychainService = "de.patrickschiller.stowmark"
static let keychainAccount = "github-token"
static let recentCapturesKey = "recent-captures"

Expand Down
2 changes: 1 addition & 1 deletion ios/SourceBraidShare/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>SourceBraidKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)de.patrickschiller.sourcebraid.shared</string>
<string>$(AppIdentifierPrefix)de.patrickschiller.stowmark.shared</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
Expand Down
2 changes: 1 addition & 1 deletion ios/SourceBraidShare/ShareViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ final class ShareViewModel: ObservableObject {
let result = try await capture.capture(url: url)
input.articleText = result.markdown
input.articleContentFormat = "markdown"
input.articleCaptureMethod = "ios-share-web"
input.articleCaptureMethod = result.captureMethod
if input.suggestedTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
input.suggestedTitle = result.title
}
Expand Down
4 changes: 2 additions & 2 deletions ios/SourceBraidShare/SourceBraidShare.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.patrickschiller.sourcebraid</string>
<string>group.de.patrickschiller.stowmark</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)de.patrickschiller.sourcebraid.shared</string>
<string>$(AppIdentifierPrefix)de.patrickschiller.stowmark.shared</string>
</array>
</dict>
</plist>
32 changes: 29 additions & 3 deletions ios/SourceBraidShare/WebPageCapture.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,33 @@
return candidates.sort(function (left, right) { return rootScore(right) - rootScore(left); })[0];
}

var root = chooseRoot();
if (!root) return JSON.stringify({ title: document.title || "", url: location.href, markdown: "" });
function chooseGoogleDeepMindRoot() {
if (location.hostname.toLowerCase() !== "deepmind.google" || location.pathname.indexOf("/blog/") !== 0) {
return null;
}
var main = document.querySelector("main");
if (!main) return null;

var content = document.createElement("div");
Array.from(main.children).some(function (section) {
if (section.tagName !== "SECTION") return false;
var heading = cleanText((section.querySelector("h1, h2, h3") || {}).textContent || "");
if (/^related posts$/i.test(heading)) return true;
if (section.matches(".section-cover") || section.querySelector("h1")) return false;
var clone = section.cloneNode(true);
clone.querySelectorAll([
"script", "style", "nav", "aside", "form", "iframe", "noscript", "svg", "button", "[aria-label='Share']"
].join(",")).forEach(function (node) { node.remove(); });
if (cleanText(clone.textContent).length >= 40 || clone.querySelector("img, video")) content.appendChild(clone);
return false;
});
return cleanText(content.textContent).length >= 500 ? content : null;
}

var specializedRoot = chooseGoogleDeepMindRoot();
var root = specializedRoot || chooseRoot();
var captureMethod = specializedRoot ? "google-deepmind-dom" : "dom-readable";
if (!root) return JSON.stringify({ title: document.title || "", url: location.href, markdown: "", captureMethod: captureMethod });

var clone = root.cloneNode(true);
clone.querySelectorAll([
Expand All @@ -198,6 +223,7 @@
return JSON.stringify({
title: title,
url: location.href,
markdown: markdown
markdown: markdown,
captureMethod: captureMethod
});
})();
1 change: 1 addition & 0 deletions ios/SourceBraidShare/WebPageCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct WebPageCaptureResult: Decodable {
let title: String
let url: String
let markdown: String
let captureMethod: String
}

@MainActor
Expand Down
15 changes: 11 additions & 4 deletions ios/SourceBraidTests/ClipBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,16 @@ final class ClipBuilderTests: XCTestCase {
)

XCTAssertEqual(draft.attachment?.data, Data("pdf".utf8))
XCTAssertEqual(draft.attachment?.path.split(separator: ".").last, "pdf")
XCTAssertTrue(draft.markdown.contains("[Open saved attachment](assets/"))
XCTAssertTrue(draft.attachment?.path.hasSuffix("/source.pdf") == true)
XCTAssertTrue(draft.markdown.contains("capture_method: \"pdf-docling-pending\""))
XCTAssertTrue(draft.markdown.contains("content_format: \"pdf\""))
XCTAssertTrue(draft.markdown.contains("conversion_status: \"pending\""))
XCTAssertTrue(draft.markdown.contains("converter: \"docling\""))
XCTAssertTrue(draft.markdown.contains("pdf_path: \"") && draft.markdown.contains("/source.pdf\""))
XCTAssertEqual(draft.indexEntry.captureMethod, "pdf-docling-pending")
XCTAssertEqual(draft.indexEntry.conversionStatus, "pending")
XCTAssertEqual(draft.indexEntry.converter, "docling")
XCTAssertEqual(draft.indexEntry.pdfPath, draft.attachment?.path)
}

func testFetchedWebCaptureStoresMarkdownMetadataAndBody() throws {
Expand All @@ -68,7 +75,7 @@ final class ClipBuilderTests: XCTestCase {
sharedText: "https://blog.example.com/article",
articleText: "## Introduction\n\nA [useful link](https://example.com).",
articleContentFormat: "markdown",
articleCaptureMethod: "ios-share-web",
articleCaptureMethod: "dom-readable",
fileData: nil,
filename: nil,
mimeType: nil
Expand All @@ -83,7 +90,7 @@ final class ClipBuilderTests: XCTestCase {
now: date
)

XCTAssertTrue(draft.markdown.contains("capture_method: \"ios-share-web\""))
XCTAssertTrue(draft.markdown.contains("capture_method: \"dom-readable\""))
XCTAssertTrue(draft.markdown.contains("content_format: \"markdown\""))
XCTAssertTrue(draft.markdown.contains("## Captured content\n\n## Introduction"))
XCTAssertFalse(draft.markdown.contains("## Shared text"))
Expand Down