From f08b7f4d6666dd9157585432edb90f97dc100e14 Mon Sep 17 00:00:00 2001 From: Samuel Meuli Date: Tue, 21 Apr 2020 16:24:20 +0200 Subject: [PATCH] Add tar/gzip support --- Glance.xcodeproj/project.pbxproj | 4 + Glance/Credits.rtf | 2 +- QLPlugin/Info.plist | 2 + QLPlugin/MainVC.swift | 22 +++-- QLPlugin/Views/FileTypes/TARPreviewVC.swift | 94 +++++++++++++++++++ QLPlugin/Views/PreviewVCFactory.swift | 9 +- .../Views/ViewTypes/OutlinePreviewVC.swift | 2 +- .../Views/ViewTypes/OutlinePreviewView.swift | 6 +- 8 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 QLPlugin/Views/FileTypes/TARPreviewVC.swift diff --git a/Glance.xcodeproj/project.pbxproj b/Glance.xcodeproj/project.pbxproj index 6ad5ea1..18e3215 100644 --- a/Glance.xcodeproj/project.pbxproj +++ b/Glance.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 7E6EF1FD240CC802009E4199 /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E6EF1FC240CC802009E4199 /* Quartz.framework */; }; 7E6EF200240CC802009E4199 /* MainVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6EF1FF240CC802009E4199 /* MainVC.swift */; }; 7E6EF208240CC802009E4199 /* QLPlugin.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7E6EF1FA240CC802009E4199 /* QLPlugin.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7E7A9DAD244F24B900276E51 /* TARPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7A9DAC244F24B900276E51 /* TARPreviewVC.swift */; }; 7E8492E0244B8BA60013E55A /* chroma-v0.7.0 in Copy Files */ = {isa = PBXBuildFile; fileRef = 7E9F0D592416286A007F1008 /* chroma-v0.7.0 */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 7E8492E1244B8BA80013E55A /* nbtohtml-v0.4.0 in Copy Files */ = {isa = PBXBuildFile; fileRef = 7E8DEDAF242BEF3700F2DABB /* nbtohtml-v0.4.0 */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 7E8DEDAB242BE1FA00F2DABB /* Down in Frameworks */ = {isa = PBXBuildFile; productRef = 7E8DEDAA242BE1FA00F2DABB /* Down */; }; @@ -179,6 +180,7 @@ 7E6EF1FF240CC802009E4199 /* MainVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainVC.swift; sourceTree = ""; }; 7E6EF204240CC802009E4199 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7E6EF205240CC802009E4199 /* QLPlugin.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLPlugin.entitlements; sourceTree = ""; }; + 7E7A9DAC244F24B900276E51 /* TARPreviewVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TARPreviewVC.swift; sourceTree = ""; }; 7E8DEDAF242BEF3700F2DABB /* nbtohtml-v0.4.0 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = "nbtohtml-v0.4.0"; sourceTree = ""; }; 7E8DEDBE242C20A000F2DABB /* OfflineWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineWebView.swift; sourceTree = ""; }; 7E9282102449AB7100DDCFBF /* TablePreviewView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TablePreviewView.xib; sourceTree = ""; }; @@ -336,6 +338,7 @@ 7E413F7F2418DD6200CFBB1D /* CSVPreviewVC.swift */, 7EB7491824228549007265A4 /* JupyterPreviewVC.swift */, 7E1DC520240E6D8000D0A061 /* MarkdownPreviewVC.swift */, + 7E7A9DAC244F24B900276E51 /* TARPreviewVC.swift */, 7EB36489244B665700D7F96F /* ZIPPreviewVC.swift */, ); path = FileTypes; @@ -805,6 +808,7 @@ 7E9F0D7F24168870007F1008 /* WebAsset.swift in Sources */, 7E6EF200240CC802009E4199 /* MainVC.swift in Sources */, 7E9282152449B96600DDCFBF /* LoadableNib.swift in Sources */, + 7E7A9DAD244F24B900276E51 /* TARPreviewVC.swift in Sources */, 7E1DC528240E6F4A00D0A061 /* PreviewVCFactory.swift in Sources */, 7EB36484244B0CCE00D7F96F /* TablePreviewVC.swift in Sources */, 7E1DC521240E6D8000D0A061 /* MarkdownPreviewVC.swift in Sources */, diff --git a/Glance/Credits.rtf b/Glance/Credits.rtf index 1635386..a7b12b9 100644 --- a/Glance/Credits.rtf +++ b/Glance/Credits.rtf @@ -30,7 +30,7 @@ \ls2\ilvl0 \f0\b Archives\ \ls2\ilvl0 -\f1\b0 .zip\ +\f1\b0 .tar, .tar.gz, .zip\ \ \pard\tx220\tx720\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\li720\fi-720\pardirnatural\qc\partightenfactor0 \ls3\ilvl0 diff --git a/QLPlugin/Info.plist b/QLPlugin/Info.plist index d59fd91..6160efd 100644 --- a/QLPlugin/Info.plist +++ b/QLPlugin/Info.plist @@ -41,6 +41,8 @@ public.data + org.gnu.gnu-zip-archive + public.tar-archive public.zip-archive diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift index fb9dba8..dddaf12 100644 --- a/QLPlugin/MainVC.swift +++ b/QLPlugin/MainVC.swift @@ -81,16 +81,18 @@ class MainVC: NSViewController, QLPreviewingController { /// Generates a preview of the selected file and adds the corresponding child view controller private func previewFile(file: File) throws { // Initialize `PreviewVC` for the file type - let previewVCType = PreviewVCFactory.getView(fileExtension: file.url.pathExtension) - let previewVC = previewVCType.init(file: file) + if let previewVCType = PreviewVCFactory.getView(fileURL: file.url) { + // Generate file preview + let previewVC = previewVCType.init(file: file) + try previewVC.loadPreview() - // Generate file preview - try previewVC.loadPreview() - - // Add `PreviewVC` as a child view controller - addChild(previewVC) - previewVC.view.autoresizingMask = [.height, .width] - previewVC.view.frame = view.frame - view.addSubview(previewVC.view) + // Add `PreviewVC` as a child view controller + addChild(previewVC) + previewVC.view.autoresizingMask = [.height, .width] + previewVC.view.frame = view.frame + view.addSubview(previewVC.view) + } else { + os_log("Skipping preview for file %s: File type not supported", type: .debug, file.path) + } } } diff --git a/QLPlugin/Views/FileTypes/TARPreviewVC.swift b/QLPlugin/Views/FileTypes/TARPreviewVC.swift new file mode 100644 index 0000000..214356f --- /dev/null +++ b/QLPlugin/Views/FileTypes/TARPreviewVC.swift @@ -0,0 +1,94 @@ +import Cocoa +import Foundation +import os.log +import SwiftExec + +/// View controller for previewing tarballs (may be gzipped). +class TARPreviewVC: OutlinePreviewVC, PreviewVC { + let linesRegex = #"([\w-]{10}) \d+ .+ .+ + (\d+) (\w{3} \d+ +[\d:]+) (.*)"# + + let byteCountFormatter = ByteCountFormatter() + let dateFormatter1 = DateFormatter() + let dateFormatter2 = DateFormatter() + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?, file: File) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil, file: file) + initDateFormatters() + } + + /// Sets up `dateFormatter1` and `dateFormatter2` to parse date strings from `tar` output. Date + /// strings may be in one of the following formats: + /// + /// - "MMM dd HH:mm", e.g. "Mar 28 15:36" (date is in current year) + /// - "MMM dd yyyy", e.g. "Dec 29 2018" + private func initDateFormatters() { + // Set default date to today to parse dates in current year + dateFormatter1.defaultDate = Date() + + // Specify date formats + dateFormatter1.dateFormat = "MMM dd HH:mm" + dateFormatter2.dateFormat = "MMM dd yyyy" + } + + /// Parses a date string from `tar` output to a `Date` object. + private func parseDate(dateString: String) -> Date? { + if dateString.contains(":") { + return dateFormatter1.date(from: dateString) + } else { + return dateFormatter2.date(from: dateString) + } + } + + /// Parses the output of the `tar` command. + private func parseTARFileTree(file: File, lines: String) -> FileTree { + let fileTree = FileTree() + + // Create node for root directory (not contained in `tar` output) + try! fileTree.addNode( + path: file.url.lastPathComponent.components(separatedBy: ".")[0], + isDirectory: true, + size: 0, + dateModified: file.attributes[FileAttributeKey.modificationDate] as? Date ?? Date() + ) + + // Content lines: "-rw-r--r-- 0 user staff 642 Dec 29 2018 my-tar/file.ext" + // - "-" as first character indicates a file, "d" a directory + // - Digits before date indicate number of bytes + let linesMatched = lines.matchRegex(regex: linesRegex) + for match in linesMatched { + do { + // Add file/directory node to tree + try fileTree.addNode( + path: match[4], + isDirectory: match[1].first == "d", + size: Int(match[2]) ?? -1, + dateModified: parseDate(dateString: match[3]) ?? Date() + ) + } catch { + os_log("%s", type: .error, error.localizedDescription) + } + } + + return fileTree + } + + func loadPreview() throws { + // Run `tar` command + let result = try exec( + program: "/usr/bin/tar", + arguments: [ + "--gzip", // Allows listing contents of `.tar.gz` files + "--list", + "--verbose", + "--file", + file.path, + ] + ) + + // Parse command output + let fileTree = parseTARFileTree(file: file, lines: result.stdout ?? "") + + // Load data into outline view + loadData(fileTree: fileTree, labelText: nil) + } +} diff --git a/QLPlugin/Views/PreviewVCFactory.swift b/QLPlugin/Views/PreviewVCFactory.swift index 492d310..378be63 100644 --- a/QLPlugin/Views/PreviewVCFactory.swift +++ b/QLPlugin/Views/PreviewVCFactory.swift @@ -3,14 +3,19 @@ import Foundation /// Returns an instance of the `PreviewVC` subclass that should be used for generating previews of /// files with the provided extension class PreviewVCFactory { - static func getView(fileExtension: String) -> PreviewVC.Type { - switch fileExtension { + static func getView(fileURL: URL) -> PreviewVC.Type? { + switch fileURL.pathExtension { case "csv", "tab", "tsv": return CSVPreviewVC.self + case "gz": + // `gzip` is only supported for tarballs + return fileURL.path.hasSuffix(".tar.gz") ? TARPreviewVC.self : nil case "md", "markdown", "mdown", "mkdn", "mkd": return MarkdownPreviewVC.self case "ipynb": return JupyterPreviewVC.self + case "tar": + return TARPreviewVC.self case "zip": return ZIPPreviewVC.self default: diff --git a/QLPlugin/Views/ViewTypes/OutlinePreviewVC.swift b/QLPlugin/Views/ViewTypes/OutlinePreviewVC.swift index 9630326..259977f 100644 --- a/QLPlugin/Views/ViewTypes/OutlinePreviewVC.swift +++ b/QLPlugin/Views/ViewTypes/OutlinePreviewVC.swift @@ -23,7 +23,7 @@ class OutlinePreviewVC: NSViewController { view = NSView() } - func loadData(fileTree: FileTree, labelText: String) { + func loadData(fileTree: FileTree, labelText: String?) { let outlineView = OutlinePreviewView( frame: view.frame, fileTree: fileTree, diff --git a/QLPlugin/Views/ViewTypes/OutlinePreviewView.swift b/QLPlugin/Views/ViewTypes/OutlinePreviewView.swift index f7608f7..1da4da4 100644 --- a/QLPlugin/Views/ViewTypes/OutlinePreviewView.swift +++ b/QLPlugin/Views/ViewTypes/OutlinePreviewView.swift @@ -8,11 +8,11 @@ class OutlinePreviewView: NSView, LoadableNib { @IBOutlet private var outlineView: NSOutlineView! @objc dynamic var fileTreeNodes: [FileTreeNode] - let labelText: String + let labelText: String? private let treeController = NSTreeController() - required init(frame: CGRect, fileTree: FileTree, labelText: String) { + required init(frame: CGRect, fileTree: FileTree, labelText: String?) { fileTreeNodes = Array(fileTree.root.children.values) self.labelText = labelText @@ -49,7 +49,7 @@ class OutlinePreviewView: NSView, LoadableNib { options: nil ) - label.stringValue = labelText + label.stringValue = labelText ?? "" } /// Expands all first-level tree nodes.