mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-02 06:39:46 +08:00
feat(ios): add share to support (#15340)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added iOS sharing for URLs, text, web pages, and images. * Shared content can be reviewed, titled, and saved to an AFFiNE workspace. * Choose destinations including workspaces, tags, and collections. * Added previews, attachment handling, import status, retry support, and success/error feedback. * Pending shares are processed when AFFiNE opens or returns to the foreground. * **Bug Fixes** * Improved workspace profile handling across different workspace types. * Onboarding completion is now recorded immediately after successful sign-in verification. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// ShareInboxConstants.swift
|
||||
// Shared between AFFiNE and ShareExtension
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ShareInboxConstants {
|
||||
static let appGroupId = "group.app.affine.pro"
|
||||
static let inboxDirectoryName = "ShareInbox"
|
||||
static let attachmentsDirectoryName = "Attachments"
|
||||
static let invalidDirectoryName = "Invalid"
|
||||
static let openInboxURL = URL(string: "affine://share-inbox")!
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
struct ShareInboxAttachment: Codable, Equatable {
|
||||
var fileName: String
|
||||
var mimeType: String
|
||||
var relativePath: String
|
||||
}
|
||||
|
||||
enum ShareInboxContentKind: String, Codable {
|
||||
case url
|
||||
case text
|
||||
case image
|
||||
}
|
||||
|
||||
struct ShareInboxContent: Codable, Equatable {
|
||||
var kind: ShareInboxContentKind
|
||||
var url: String?
|
||||
var text: String?
|
||||
}
|
||||
|
||||
struct ShareInboxTarget: Codable, Equatable {
|
||||
var workspaceId: String
|
||||
var workspaceFlavour: String
|
||||
var tagIds: [String]
|
||||
var collectionId: String?
|
||||
}
|
||||
|
||||
struct ShareInboxResult: Codable, Equatable {
|
||||
var docId: String
|
||||
var committedAt: Date
|
||||
}
|
||||
|
||||
struct ShareInboxItem: Codable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var documentId: String
|
||||
var createdAt: Date
|
||||
var title: String
|
||||
var content: ShareInboxContent
|
||||
var target: ShareInboxTarget?
|
||||
var previewText: String?
|
||||
var attachments: [ShareInboxAttachment]
|
||||
var result: ShareInboxResult?
|
||||
var lastError: String?
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
documentId: String = UUID().uuidString,
|
||||
createdAt: Date = Date(),
|
||||
title: String,
|
||||
content: ShareInboxContent,
|
||||
target: ShareInboxTarget? = nil,
|
||||
previewText: String? = nil,
|
||||
attachments: [ShareInboxAttachment] = [],
|
||||
result: ShareInboxResult? = nil,
|
||||
lastError: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.documentId = documentId
|
||||
self.createdAt = createdAt
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.target = target
|
||||
self.previewText = previewText
|
||||
self.attachments = attachments
|
||||
self.result = result
|
||||
self.lastError = lastError
|
||||
}
|
||||
}
|
||||
|
||||
struct SharePayloadFile: Equatable {
|
||||
var data: Data
|
||||
var mimeType: String
|
||||
var fileName: String
|
||||
}
|
||||
|
||||
struct SharePayloadDraft: Equatable {
|
||||
var title: String
|
||||
var content: ShareInboxContent?
|
||||
var previewText: String
|
||||
var file: SharePayloadFile?
|
||||
var errorMessage: String?
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
enum ShareInboxSafety {
|
||||
static func normalizedManifestID(_ value: String) -> String? {
|
||||
UUID(uuidString: value)?.uuidString
|
||||
}
|
||||
|
||||
static func normalizedWebURL(_ value: String) -> String? {
|
||||
guard
|
||||
let components = URLComponents(
|
||||
string: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
components.host?.isEmpty == false,
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
let url = components.url
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
static func detectRasterImageMimeType(_ data: Data) -> String? {
|
||||
let bytes = [UInt8](data.prefix(12))
|
||||
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
return "image/png"
|
||||
}
|
||||
if bytes.starts(with: Array("GIF87a".utf8)) || bytes.starts(with: Array("GIF89a".utf8)) {
|
||||
return "image/gif"
|
||||
}
|
||||
if bytes.count >= 12,
|
||||
Array(bytes[0..<4]) == Array("RIFF".utf8),
|
||||
Array(bytes[8..<12]) == Array("WEBP".utf8)
|
||||
{
|
||||
return "image/webp"
|
||||
}
|
||||
if bytes.count >= 12, Array(bytes[4..<8]) == Array("ftyp".utf8) {
|
||||
let brand = String(decoding: bytes[8..<12], as: UTF8.self).lowercased()
|
||||
if ["heic", "heix", "hevc", "hevx", "mif1", "msf1"].contains(brand) {
|
||||
return "image/heic"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// ShareInboxStore.swift
|
||||
// Shared between AFFiNE and ShareExtension
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class ShareInboxStore {
|
||||
static let shared = ShareInboxStore()
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
private let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
var containerURL: URL? {
|
||||
fileManager.containerURL(forSecurityApplicationGroupIdentifier: ShareInboxConstants.appGroupId)
|
||||
}
|
||||
|
||||
private var inboxDirectoryURL: URL? {
|
||||
guard let containerURL else { return nil }
|
||||
return containerURL
|
||||
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
private var attachmentsDirectoryURL: URL? {
|
||||
guard let inboxDirectoryURL else { return nil }
|
||||
return inboxDirectoryURL
|
||||
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
private var invalidDirectoryURL: URL? {
|
||||
guard let inboxDirectoryURL else { return nil }
|
||||
return inboxDirectoryURL
|
||||
.appendingPathComponent(ShareInboxConstants.invalidDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureDirectories() -> Bool {
|
||||
guard let inboxDirectoryURL, let attachmentsDirectoryURL, let invalidDirectoryURL else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try fileManager.createDirectory(at: inboxDirectoryURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: attachmentsDirectoryURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: invalidDirectoryURL, withIntermediateDirectories: true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(_ item: ShareInboxItem, attachmentData: [(ShareInboxAttachment, Data)] = []) throws {
|
||||
guard ensureDirectories() else {
|
||||
throw ShareInboxError.containerUnavailable
|
||||
}
|
||||
|
||||
var writtenURLs: [URL] = []
|
||||
var writtenParentURLs: [URL] = []
|
||||
do {
|
||||
for (attachment, data) in attachmentData {
|
||||
guard let destination = attachmentURL(for: attachment) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
let parent = destination.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
writtenParentURLs.append(parent)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
writtenURLs.append(destination)
|
||||
}
|
||||
|
||||
guard let fileURL = manifestURL(for: item.id) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
let data = try encoder.encode(item)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
for url in writtenURLs {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
for url in writtenParentURLs.reversed() {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ item: ShareInboxItem) throws {
|
||||
guard ensureDirectories(), let fileURL = manifestURL(for: item.id) else {
|
||||
throw ShareInboxError.containerUnavailable
|
||||
}
|
||||
try encoder.encode(item).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
func pendingItems() -> [ShareInboxItem] {
|
||||
guard ensureDirectories(), let inboxDirectoryURL else { return [] }
|
||||
guard let urls = try? fileManager.contentsOfDirectory(
|
||||
at: inboxDirectoryURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return urls
|
||||
.filter { $0.pathExtension.lowercased() == "json" }
|
||||
.compactMap { url -> ShareInboxItem? in
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
quarantine(url)
|
||||
return nil
|
||||
}
|
||||
guard let item = try? decoder.decode(ShareInboxItem.self, from: data),
|
||||
let expectedURL = manifestURL(for: item.id),
|
||||
expectedURL.lastPathComponent.caseInsensitiveCompare(url.lastPathComponent) == .orderedSame
|
||||
else {
|
||||
quarantine(url)
|
||||
return nil
|
||||
}
|
||||
return item
|
||||
}
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
func attachmentURL(for attachment: ShareInboxAttachment) -> URL? {
|
||||
guard let attachmentsDirectoryURL else { return nil }
|
||||
guard !attachment.relativePath.isEmpty,
|
||||
!attachment.relativePath.hasPrefix("/"),
|
||||
!attachment.relativePath.split(separator: "/").contains("..")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let base = attachmentsDirectoryURL.standardizedFileURL
|
||||
let candidate = base.appendingPathComponent(attachment.relativePath).standardizedFileURL
|
||||
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
func remove(_ item: ShareInboxItem) throws {
|
||||
guard let fileURL = manifestURL(for: item.id) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
|
||||
for attachment in item.attachments {
|
||||
if let url = attachmentURL(for: attachment) {
|
||||
try? fileManager.removeItem(at: url)
|
||||
try? fileManager.removeItem(at: url.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func manifestURL(for itemId: String) -> URL? {
|
||||
guard let inboxDirectoryURL,
|
||||
let normalizedId = ShareInboxSafety.normalizedManifestID(itemId)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let base = inboxDirectoryURL.standardizedFileURL
|
||||
let candidate = base
|
||||
.appendingPathComponent("\(normalizedId).json")
|
||||
.standardizedFileURL
|
||||
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func quarantine(_ url: URL) {
|
||||
guard let invalidDirectoryURL else { return }
|
||||
let destination = invalidDirectoryURL.appendingPathComponent(url.lastPathComponent)
|
||||
try? fileManager.removeItem(at: destination)
|
||||
try? fileManager.moveItem(at: url, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
enum ShareInboxError: Error {
|
||||
case containerUnavailable
|
||||
case invalidPayload
|
||||
case payloadTooLarge
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SharePayloadBuilder {
|
||||
private static let maxImageBytes = 12 * 1024 * 1024
|
||||
private static let maxTextCharacters = 250_000
|
||||
|
||||
static func build(from extensionItems: [NSExtensionItem]) async -> SharePayloadDraft {
|
||||
var title = "Shared"
|
||||
var url: String?
|
||||
var text: String?
|
||||
var fallbackText: String?
|
||||
var file: SharePayloadFile?
|
||||
var imageProviderCount = 0
|
||||
|
||||
for item in extensionItems {
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier),
|
||||
let page = try? await loadSafariPage(from: provider)
|
||||
{
|
||||
title = page.title ?? title
|
||||
url = page.url ?? url
|
||||
text = page.selectedText.map {
|
||||
String($0.prefix(maxTextCharacters))
|
||||
} ?? text
|
||||
}
|
||||
|
||||
if url == nil,
|
||||
provider.hasItemConformingToTypeIdentifier(UTType.url.identifier),
|
||||
let loadedURL = try? await loadURL(from: provider),
|
||||
let normalized = ShareInboxSafety.normalizedWebURL(loadedURL.absoluteString)
|
||||
{
|
||||
url = normalized
|
||||
if title == "Shared" {
|
||||
title = loadedURL.host ?? normalized
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackText == nil,
|
||||
provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier),
|
||||
let loadedText = try? await loadText(from: provider)
|
||||
{
|
||||
let trimmed = loadedText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, trimmed != url {
|
||||
fallbackText = String(trimmed.prefix(maxTextCharacters))
|
||||
}
|
||||
}
|
||||
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
imageProviderCount += 1
|
||||
if imageProviderCount > 1 {
|
||||
return failure(title: title, message: "Share one image at a time.")
|
||||
}
|
||||
do {
|
||||
file = try await loadImage(from: provider)
|
||||
} catch ShareInboxError.payloadTooLarge {
|
||||
return failure(title: title, message: "The image must be smaller than 12 MB.")
|
||||
} catch {
|
||||
return failure(title: title, message: "This image format is not supported.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let attributedText = nonEmpty(item.attributedContentText?.string),
|
||||
attributedText != url
|
||||
{
|
||||
if title == "Shared" {
|
||||
title = firstNonEmptyLine(attributedText)
|
||||
}
|
||||
if fallbackText == nil {
|
||||
fallbackText = String(attributedText.prefix(maxTextCharacters))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if text == nil, url == nil {
|
||||
text = fallbackText
|
||||
}
|
||||
if title == "Shared" {
|
||||
if let file {
|
||||
title = (file.fileName as NSString).deletingPathExtension
|
||||
} else if let url, let host = URL(string: url)?.host {
|
||||
title = host
|
||||
} else if let fallbackText {
|
||||
title = firstNonEmptyLine(fallbackText)
|
||||
}
|
||||
}
|
||||
|
||||
let content: ShareInboxContent?
|
||||
if file != nil {
|
||||
content = ShareInboxContent(kind: .image, url: url, text: text)
|
||||
} else if let url {
|
||||
content = ShareInboxContent(kind: .url, url: url, text: text)
|
||||
} else if let text {
|
||||
content = ShareInboxContent(kind: .text, url: nil, text: text)
|
||||
} else {
|
||||
content = nil
|
||||
}
|
||||
|
||||
guard let content else {
|
||||
return failure(
|
||||
title: title,
|
||||
message: "AFFiNE can currently save links, text, or one image."
|
||||
)
|
||||
}
|
||||
|
||||
let preview = text ?? url ?? file?.fileName ?? "Shared content"
|
||||
return SharePayloadDraft(
|
||||
title: sanitizeTitle(title),
|
||||
content: content,
|
||||
previewText: String(preview.prefix(280)),
|
||||
file: file,
|
||||
errorMessage: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func failure(title: String, message: String) -> SharePayloadDraft {
|
||||
SharePayloadDraft(
|
||||
title: sanitizeTitle(title),
|
||||
content: nil,
|
||||
previewText: "",
|
||||
file: nil,
|
||||
errorMessage: message
|
||||
)
|
||||
}
|
||||
|
||||
private struct SafariPage {
|
||||
var title: String?
|
||||
var url: String?
|
||||
var selectedText: String?
|
||||
}
|
||||
|
||||
private static func loadSafariPage(from provider: NSItemProvider) async throws -> SafariPage {
|
||||
let item: Any = try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(
|
||||
forTypeIdentifier: UTType.propertyList.identifier,
|
||||
options: nil
|
||||
) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let item {
|
||||
continuation.resume(returning: item)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dictionary: [String: Any]?
|
||||
if let value = item as? [String: Any] {
|
||||
dictionary = value
|
||||
} else if let data = item as? Data {
|
||||
dictionary = try? PropertyListSerialization.propertyList(
|
||||
from: data,
|
||||
options: [],
|
||||
format: nil
|
||||
) as? [String: Any]
|
||||
} else {
|
||||
dictionary = nil
|
||||
}
|
||||
guard let dictionary else { throw ShareInboxError.invalidPayload }
|
||||
let result =
|
||||
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any]
|
||||
?? dictionary
|
||||
let pageURL = (result["url"] as? String).flatMap(ShareInboxSafety.normalizedWebURL)
|
||||
return SafariPage(
|
||||
title: nonEmpty(result["title"] as? String),
|
||||
url: pageURL,
|
||||
selectedText: nonEmpty(result["selectedText"] as? String)
|
||||
)
|
||||
}
|
||||
|
||||
private static func loadURL(from provider: NSItemProvider) async throws -> URL {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let url = item as? URL {
|
||||
continuation.resume(returning: url)
|
||||
} else if let value = item as? String, let url = URL(string: value) {
|
||||
continuation.resume(returning: url)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadText(from provider: NSItemProvider) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(
|
||||
forTypeIdentifier: UTType.plainText.identifier,
|
||||
options: nil
|
||||
) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let text = item as? String {
|
||||
continuation.resume(returning: text)
|
||||
} else if let attributed = item as? NSAttributedString {
|
||||
continuation.resume(returning: attributed.string)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadImage(from provider: NSItemProvider) async throws -> SharePayloadFile {
|
||||
let item: Any = try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let item {
|
||||
continuation.resume(returning: item)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let suggestedName: String?
|
||||
if let value = item as? Data {
|
||||
data = value
|
||||
suggestedName = provider.suggestedName
|
||||
} else if let url = item as? URL, url.isFileURL {
|
||||
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
||||
if let fileSize = values.fileSize, fileSize > maxImageBytes {
|
||||
throw ShareInboxError.payloadTooLarge
|
||||
}
|
||||
data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
suggestedName = url.lastPathComponent
|
||||
} else if let image = item as? UIImage, let jpeg = image.jpegData(compressionQuality: 0.9) {
|
||||
data = jpeg
|
||||
suggestedName = provider.suggestedName
|
||||
} else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
|
||||
guard data.count <= maxImageBytes else { throw ShareInboxError.payloadTooLarge }
|
||||
guard let mimeType = ShareInboxSafety.detectRasterImageMimeType(data) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
let fileExtension = fileExtension(for: mimeType)
|
||||
let baseName = nonEmpty(suggestedName)
|
||||
.map { ($0 as NSString).lastPathComponent }
|
||||
.flatMap { nonEmpty(($0 as NSString).deletingPathExtension) }
|
||||
?? "shared-image"
|
||||
return SharePayloadFile(
|
||||
data: data,
|
||||
mimeType: mimeType,
|
||||
fileName: "\(baseName).\(fileExtension)"
|
||||
)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func sanitizeTitle(_ value: String) -> String {
|
||||
String((nonEmpty(value) ?? "Shared").prefix(120))
|
||||
}
|
||||
|
||||
private static func firstNonEmptyLine(_ value: String) -> String {
|
||||
value
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.lazy
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first { !$0.isEmpty }
|
||||
.map { String($0.prefix(120)) }
|
||||
?? "Shared text"
|
||||
}
|
||||
|
||||
private static func fileExtension(for mimeType: String) -> String {
|
||||
switch mimeType {
|
||||
case "image/png": "png"
|
||||
case "image/gif": "gif"
|
||||
case "image/webp": "webp"
|
||||
case "image/heic": "heic"
|
||||
default: "jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user