Skip to content
Closed
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
226 changes: 226 additions & 0 deletions Sources/Engine/ClipItemSaveToFilePresenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import AppKit
import UniformTypeIdentifiers

/// Presents Save / Choose Folder panels to write a clip's payload to disk.
@MainActor
enum ClipItemSaveToFilePresenter {

static func canSaveAsFile(_ item: ClipItem) -> Bool {
guard !item.isDeleted else { return false }
if !resolvedExistingPaths(for: item).isEmpty { return true }
if item.imageBytesForExport() != nil { return true }
let trimmed = item.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if trimmed == "[Image]", item.imageBytesForExport() == nil { return false }
return true
}

static func beginSave(_ item: ClipItem) {
guard canSaveAsFile(item) else { return }
NSApp.activate(ignoringOtherApps: true)

let paths = resolvedExistingPaths(for: item)
if paths.count > 1 {
presentChooseFolderAndCopy(paths: paths)
return
}
if paths.count == 1 {
presentSaveCopy(ofFileAt: paths[0], item: item)
return
}

if shouldPreferImageExport(for: item), let data = item.imageBytesForExport() {
presentSaveImage(data: data, item: item)
return
}

presentSaveText(item)
}

// MARK: - Path resolution

private static func resolvedExistingPaths(for item: ClipItem) -> [String] {
var collected: [String] = []
if item.contentType.isFileBased && item.content != "[Image]" {
collected.append(contentsOf: item.content.components(separatedBy: "\n"))
}
collected.append(contentsOf: item.resolvedFilePaths)
var seen = Set<String>()
var out: [String] = []
for raw in collected {
let p = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !p.isEmpty, FileManager.default.fileExists(atPath: p), !seen.contains(p) else { continue }
seen.insert(p)
out.append(p)
}
return out
}

private static func shouldPreferImageExport(for item: ClipItem) -> Bool {
guard item.imageBytesForExport() != nil else { return false }
guard resolvedExistingPaths(for: item).isEmpty else { return false }

switch item.contentType {
case .image:
return true
case .mixed:
let trimmed = item.content.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty || trimmed == "[Image]"
default:
return false
}
}

// MARK: - Panels

private static func presentSaveCopy(ofFileAt path: String, item: ClipItem) {
let src = URL(fileURLWithPath: path)
let panel = NSSavePanel()
panel.title = L10n.tr("cmd.saveAsFile")
panel.prompt = L10n.tr("saveAs.save")
panel.canCreateDirectories = true
let ext = src.pathExtension.isEmpty ? "txt" : src.pathExtension
panel.nameFieldStringValue = defaultFileName(for: item, ext: ext)
if !src.pathExtension.isEmpty, let ut = UTType(filenameExtension: src.pathExtension) {
panel.allowedContentTypes = [ut]
}
panel.directoryURL = src.deletingLastPathComponent()
guard panel.runModal() == .OK, let dest = panel.url else { return }
copyFileReplacing(from: src, to: dest)
}

private static func presentChooseFolderAndCopy(paths: [String]) {
let panel = NSOpenPanel()
panel.title = L10n.tr("cmd.saveAsFile")
panel.prompt = L10n.tr("saveAs.chooseFolderPrompt")
panel.message = L10n.tr("saveAs.chooseFolderMessage", paths.count)
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let destDir = panel.url else { return }

let fm = FileManager.default
var ok = 0
for path in paths {
let src = URL(fileURLWithPath: path)
let name = src.lastPathComponent
let dest = uniqueDestinationURL(in: destDir, preferredName: name, sourceURL: src)
do {
try fm.copyItem(at: src, to: dest)
ok += 1
} catch { continue }
}

if ok == paths.count {
ToastCenter.shared.show(ToastDescriptor(message: L10n.tr("saveAs.exportedCount", ok), icon: .success))
} else if ok > 0 {
ToastCenter.shared.show(ToastDescriptor(message: L10n.tr("saveAs.exportedPartial", ok, paths.count), icon: .info))
} else {
toastFailed()
}
}

private static func uniqueDestinationURL(in folder: URL, preferredName: String, sourceURL: URL) -> URL {
let fm = FileManager.default
var dest = folder.appendingPathComponent(preferredName)
guard fm.fileExists(atPath: dest.path) else { return dest }

let base = sourceURL.deletingPathExtension().lastPathComponent
let ext = sourceURL.pathExtension
var n = 1
while fm.fileExists(atPath: dest.path) {
let stem = ext.isEmpty ? "\(base) (\(n))" : "\(base) (\(n)).\(ext)"
dest = folder.appendingPathComponent(stem)
n += 1
}
return dest
}

private static func presentSaveImage(data: Data, item: ClipItem) {
let panel = NSSavePanel()
panel.title = L10n.tr("cmd.saveAsFile")
panel.prompt = L10n.tr("saveAs.save")
panel.canCreateDirectories = true
let ext = imageFileExtension(for: data)
if let ut = UTType(filenameExtension: ext) {
panel.allowedContentTypes = [ut]
}
panel.nameFieldStringValue = defaultFileName(for: item, ext: ext)
guard panel.runModal() == .OK, let dest = panel.url else { return }
do {
try data.write(to: dest, options: .atomic)
toastSaved(dest)
} catch {
toastFailed()
}
}

private static func presentSaveText(_ item: ClipItem) {
let panel = NSSavePanel()
panel.title = L10n.tr("cmd.saveAsFile")
panel.prompt = L10n.tr("saveAs.save")
panel.canCreateDirectories = true
let ext = item.contentType == .code ? item.resolvedFileExtension : "txt"
if let ut = UTType(filenameExtension: ext) {
panel.allowedContentTypes = [ut]
} else {
panel.allowedContentTypes = [.plainText]
}
panel.nameFieldStringValue = defaultFileName(for: item, ext: ext)
guard panel.runModal() == .OK, let dest = panel.url else { return }
guard let encoded = item.content.data(using: .utf8) else {
toastFailed()
return
}
do {
try encoded.write(to: dest, options: .atomic)
toastSaved(dest)
} catch {
toastFailed()
}
}

// MARK: - File ops & helpers

private static func copyFileReplacing(from src: URL, to dest: URL) {
let fm = FileManager.default
do {
if fm.fileExists(atPath: dest.path) {
try fm.removeItem(at: dest)
}
try fm.copyItem(at: src, to: dest)
toastSaved(dest)
} catch {
toastFailed()
}
}

private static func toastSaved(_ url: URL) {
ToastCenter.shared.show(ToastDescriptor(message: L10n.tr("saveAs.saved", url.path), icon: .success))
}

private static func toastFailed() {
ToastCenter.shared.show(ToastDescriptor(message: L10n.tr("saveAs.failed"), icon: .info))
}

private static func defaultFileName(for item: ClipItem, ext: String) -> String {
let shortID = item.itemID.split(separator: "-", maxSplits: 1).first.map(String.init) ?? item.itemID
return "pastememo_\(shortID).\(ext)"
}

private static func imageFileExtension(for data: Data) -> String {
guard data.count >= 8 else { return "png" }
if data.starts(with: Data([0xFF, 0xD8, 0xFF])) { return "jpg" }
if data.starts(with: Data([0x89, 0x50, 0x4E, 0x47])) { return "png" }
if data.starts(with: Data([0x47, 0x49, 0x46])) { return "gif" }
// RIFF....WEBP
let riff = Data([0x52, 0x49, 0x46, 0x46])
let webp = Data([0x57, 0x45, 0x42, 0x50])
if data.count >= 12,
data.subdata(in: 0..<4) == riff,
data.subdata(in: 8..<12) == webp {
return "webp"
}
return "png"
}
}
8 changes: 8 additions & 0 deletions Sources/Localization/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,14 @@ Verwenden Sie %@ zum Einfügen.";
/* Command Palette (additional) */
"cmd.copyAs" = "Kopieren als %@";
"cmd.showInFinder" = "Im Finder anzeigen";
"cmd.saveAsFile" = "Speichern unter…";
"saveAs.save" = "Sichern";
"saveAs.chooseFolderPrompt" = "Exportieren";
"saveAs.chooseFolderMessage" = "Ordner wählen, in den %d Objekte kopiert werden.";
"saveAs.saved" = "Gespeichert unter %@";
"saveAs.failed" = "Datei konnte nicht gespeichert werden.";
"saveAs.exportedCount" = "%d Dateien exportiert.";
"saveAs.exportedPartial" = "%d von %d Dateien exportiert.";

/* Onboarding - Accessibility */
"accessibility.lost.later" = "Später";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
"action.unfavorite" = "Unfavorite";
"action.pin" = "Pin";
"cmd.showInFinder" = "Show in Finder";
"cmd.saveAsFile" = "Save As…";
"saveAs.save" = "Save";
"saveAs.chooseFolderPrompt" = "Export";
"saveAs.chooseFolderMessage" = "Choose a folder to copy %d items into.";
"saveAs.saved" = "Saved to %@";
"saveAs.failed" = "Could not save the file.";
"saveAs.exportedCount" = "Exported %d files.";
"saveAs.exportedPartial" = "Exported %d of %d files.";
"cmd.copyAs" = "Copy as %@";
"relay.reverse" = "Reverse Order";
"relay.clearAll" = "Clear All";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,14 @@ Usa %@ para pegar uno por uno.";
/* Commands - Additional */
"cmd.copyAs" = "Copiar como %@";
"cmd.showInFinder" = "Mostrar en Finder";
"cmd.saveAsFile" = "Guardar como…";
"saveAs.save" = "Guardar";
"saveAs.chooseFolderPrompt" = "Exportar";
"saveAs.chooseFolderMessage" = "Elige una carpeta para copiar %d elementos.";
"saveAs.saved" = "Guardado en %@";
"saveAs.failed" = "No se pudo guardar el archivo.";
"saveAs.exportedCount" = "Se exportaron %d archivos.";
"saveAs.exportedPartial" = "Se exportaron %d de %d archivos.";

/* Accessibility - Permission Lost */
"accessibility.lost.later" = "Más tarde";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,14 @@ Utilisez %@ pour coller un par un.";
/* Command Palette */
"cmd.copyAs" = "Copier en tant que %@";
"cmd.showInFinder" = "Afficher dans le Finder";
"cmd.saveAsFile" = "Enregistrer sous…";
"saveAs.save" = "Enregistrer";
"saveAs.chooseFolderPrompt" = "Exporter";
"saveAs.chooseFolderMessage" = "Choisissez un dossier pour copier %d éléments.";
"saveAs.saved" = "Enregistré dans %@";
"saveAs.failed" = "Impossible d’enregistrer le fichier.";
"saveAs.exportedCount" = "%d fichiers exportés.";
"saveAs.exportedPartial" = "%d fichiers sur %d exportés.";

/* Accessibility */
"accessibility.lost.later" = "Plus tard";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/id.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,14 @@ Gunakan %@ untuk menempel satu per satu.";

/* Missing Keys - Actions */
"cmd.showInFinder" = "Tampilkan di Finder";
"cmd.saveAsFile" = "Simpan sebagai…";
"saveAs.save" = "Simpan";
"saveAs.chooseFolderPrompt" = "Ekspor";
"saveAs.chooseFolderMessage" = "Pilih folder untuk menyalin %d item.";
"saveAs.saved" = "Disimpan ke %@";
"saveAs.failed" = "Tidak dapat menyimpan berkas.";
"saveAs.exportedCount" = "%d berkas diekspor.";
"saveAs.exportedPartial" = "%d dari %d berkas diekspor.";
"cmd.copyAs" = "Salin sebagai %@";
"relay.reverse" = "Balik Urutan";
"relay.clearAll" = "Hapus Semua";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,14 @@ Usa %@ per incollare uno per uno.";

/* Missing Keys - Actions */
"cmd.showInFinder" = "Mostra nel Finder";
"cmd.saveAsFile" = "Salva con nome…";
"saveAs.save" = "Salva";
"saveAs.chooseFolderPrompt" = "Esporta";
"saveAs.chooseFolderMessage" = "Scegli una cartella in cui copiare %d elementi.";
"saveAs.saved" = "Salvato in %@";
"saveAs.failed" = "Impossibile salvare il file.";
"saveAs.exportedCount" = "Esportati %d file.";
"saveAs.exportedPartial" = "Esportati %d di %d file.";
"cmd.copyAs" = "Copia come %@";
"relay.reverse" = "Inverti ordine";
"relay.clearAll" = "Cancella tutto";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,14 @@
/* Command - Additional */
"cmd.copyAs" = "%@ としてコピー";
"cmd.showInFinder" = "Finderで表示";
"cmd.saveAsFile" = "名前を付けて保存…";
"saveAs.save" = "保存";
"saveAs.chooseFolderPrompt" = "書き出す";
"saveAs.chooseFolderMessage" = "%d 個の項目をコピーするフォルダを選択してください。";
"saveAs.saved" = "%@ に保存しました";
"saveAs.failed" = "ファイルを保存できませんでした。";
"saveAs.exportedCount" = "%d 個のファイルを書き出しました。";
"saveAs.exportedPartial" = "%d / %d 個のファイルを書き出しました。";

/* Accessibility - Lost Permission */
"accessibility.lost.later" = "後で";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,14 @@
/* Command - Additional */
"cmd.copyAs" = "%@(으)로 복사";
"cmd.showInFinder" = "Finder에서 보기";
"cmd.saveAsFile" = "다른 이름으로 저장…";
"saveAs.save" = "저장";
"saveAs.chooseFolderPrompt" = "내보내기";
"saveAs.chooseFolderMessage" = "%d개 항목을 복사할 폴더를 선택하세요.";
"saveAs.saved" = "%@에 저장했습니다";
"saveAs.failed" = "파일을 저장할 수 없습니다.";
"saveAs.exportedCount" = "%d개 파일을 내보냈습니다.";
"saveAs.exportedPartial" = "%d / %d개 파일을 내보냈습니다.";

/* Accessibility - Lost Permission */
"accessibility.lost.later" = "나중에";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,14 @@

/* Missing Keys - Actions */
"cmd.showInFinder" = "Показать в Finder";
"cmd.saveAsFile" = "Сохранить как…";
"saveAs.save" = "Сохранить";
"saveAs.chooseFolderPrompt" = "Экспорт";
"saveAs.chooseFolderMessage" = "Выберите папку для копирования %d элементов.";
"saveAs.saved" = "Сохранено в %@";
"saveAs.failed" = "Не удалось сохранить файл.";
"saveAs.exportedCount" = "Экспортировано файлов: %d.";
"saveAs.exportedPartial" = "Экспортировано %d из %d файлов.";
"cmd.copyAs" = "Копировать как %@";
"relay.reverse" = "Обратный порядок";
"relay.clearAll" = "Очистить всё";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
"action.unfavorite" = "取消收藏";
"action.pin" = "置顶";
"cmd.showInFinder" = "在 Finder 中显示";
"cmd.saveAsFile" = "另存为";
"saveAs.save" = "存储";
"saveAs.chooseFolderPrompt" = "导出";
"saveAs.chooseFolderMessage" = "选取要将 %d 个项目副本保存到的文件夹。";
"saveAs.saved" = "已保存到 %@";
"saveAs.failed" = "无法保存文件。";
"saveAs.exportedCount" = "已导出 %d 个文件。";
"saveAs.exportedPartial" = "已导出 %d / %d 个文件。";
"cmd.copyAs" = "复制为 %@";
"relay.reverse" = "反转顺序";
"relay.clearAll" = "清空所有";
Expand Down
8 changes: 8 additions & 0 deletions Sources/Localization/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,14 @@

/* 缺失的鍵 - 操作 */
"cmd.showInFinder" = "在 Finder 中顯示";
"cmd.saveAsFile" = "另存為";
"saveAs.save" = "儲存";
"saveAs.chooseFolderPrompt" = "輸出";
"saveAs.chooseFolderMessage" = "選擇要將 %d 個項目複製到哪個檔案夾。";
"saveAs.saved" = "已儲存至 %@";
"saveAs.failed" = "無法儲存檔案。";
"saveAs.exportedCount" = "已輸出 %d 個檔案。";
"saveAs.exportedPartial" = "已輸出 %d / %d 個檔案。";
"cmd.copyAs" = "複製為 %@";
"relay.reverse" = "反轉順序";
"relay.clearAll" = "清空所有";
Expand Down
Loading