Archived
1
This commit is contained in:
Kim Wittenburg
2019-02-01 22:59:01 +01:00
parent ba472864df
commit 0a485ff42a
82 changed files with 3975 additions and 1822 deletions

View File

@@ -0,0 +1,176 @@
//
// ActivityViewController.swift
// TagTunes
//
// Created by Kim Wittenburg on 22.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
import AppKitPlus
// TODO: Order Fields, Documentation
let saveQueue = OperationQueue()
// TODO: Better Name
private var operationsContext = 0
class ActivityViewController: NSViewController, OperationQueueDelegate {
private struct Groups {
static let Lookup: AnyObject = "LookupGroup"
static let Save: AnyObject = "SaveGroup"
}
private static let LookupItem: AnyObject = "LookupActivityItem"
// MARK: Properties
@IBOutlet weak var outlineView: NSOutlineView!
private var saveOperations = [SaveOperation]() {
didSet {
noteOutlineContentDidChange()
}
}
// MARK: Initialization
override init?(nibName nibNameOrNil: String?, bundle nibBundleOrNil: NSBundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
registerObservers()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
registerObservers()
}
private func registerObservers() {
saveQueue.addObserver(self, forKeyPath: "operations", options: [], context: &operationsContext)
LookupQueue.globalQueue.delegate = self
}
// MARK: Obersers and Delegates
private func noteOutlineContentDidChange() {
// TODO: Optimize this to reload single rows
self.outlineView?.reloadData()
self.outlineView?.expandItem(nil, expandChildren: true)
}
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
if context == &operationsContext {
let operations = saveQueue.operations.flatMap { $0 as? SaveOperation }
dispatch_async(dispatch_get_main_queue()) {
self.saveOperations = operations
}
}
}
func operationQueue(operationQueue: OperationQueue, willAddOperation operation: NSOperation) {
if operationQueue === LookupQueue.globalQueue {
noteOutlineContentDidChange()
}
}
func operationQueue(operationQueue: OperationQueue, operationDidFinish operation: NSOperation, withErrors errors: [NSError]) {
// TODO: Why is this not on main thread and willAdd... is?
dispatch_sync(dispatch_get_main_queue()) {
if operationQueue === LookupQueue.globalQueue {
self.noteOutlineContentDidChange()
}
}
}
}
extension ActivityViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {
if item == nil {
return 2
}
if item === Groups.Lookup {
return 0 // TODO: Return sometimes 1
}
if item === Groups.Save {
return saveOperations.count
}
return 0
}
func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {
if item == nil {
return [Groups.Lookup, Groups.Save][index]
}
if item === Groups.Lookup {
return ActivityViewController.LookupItem
}
if item === Groups.Save {
return saveOperations[index]
}
return ""
}
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
return self.outlineView(outlineView, isGroupItem: item)
}
func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool {
return item === Groups.Lookup || item === Groups.Save
}
func outlineView(outlineView: NSOutlineView, shouldShowOutlineCellForItem item: AnyObject) -> Bool {
return false
}
func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat {
if self.outlineView(outlineView, isGroupItem: item) {
return 24
} else {
return 42
}
}
func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? {
if item === Groups.Lookup {
let view = outlineView.makeViewWithIdentifier("GroupCell", owner: nil) as? NSTableCellView
// TODO: Localize
view?.textField?.stringValue = "iTunes Match"
return view
}
if item === Groups.Save {
let view = outlineView.makeViewWithIdentifier("GroupCell", owner: nil) as? NSTableCellView
// TODO: Localize
view?.textField?.stringValue = "Saving"
return view
}
print(item)
if item === ActivityViewController.LookupItem {
var view = outlineView.makeViewWithIdentifier("LookupCell", owner: nil) as? AdvancedTableCellView
if view == nil {
view = AdvancedTableCellView()
view?.style = .Subtitle
let progressIndicator = NSProgressIndicator()
progressIndicator.controlSize = .RegularControlSize
view?.leftAccessoryView = progressIndicator
}
// TODO: Localize
view?.textField?.stringValue = "Looking up n tracks"
view?.secondaryTextField?.stringValue = "m tracks in queue"
return view
}
if let saveOperation = item as? SaveOperation {
var view = outlineView.makeViewWithIdentifier("SaveCell", owner: nil) as? SaveTableCellView
if view == nil {
view = SaveTableCellView()
}
view?.progress = saveOperation.progress
view?.leftAccessoryView = nil
return view
}
return nil
}
}

77
TagTunes/AlbumItem.swift Executable file
View File

@@ -0,0 +1,77 @@
// AlbumItem.swift
// TagTunes
//
// Created by Kim Wittenburg on 29.05.15.
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
//
import SearchAPI
/// Represents an `Album` from the Search API.
public class AlbumItem: TagTunesGroupItem {
/// Returns the `entity` as an `Album`.
public var album: Album {
return entity as! Album
}
public override var children: [TagTunesEntityItem] {
didSet {
super.children.sortInPlace { (item1: TagTunesEntityItem, item2: TagTunesEntityItem) in
let song1 = (item1 as! SongItem).song
let song2 = (item2 as! SongItem).song
return song1.discNumber < song2.discNumber || (song1.discNumber == song2.discNumber && song1.trackNumber < song2.trackNumber)
}
}
}
public override func addAssociatedTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
var unmatchedTracks = Set<TagTunesTrack>()
for track in tracks {
var inserted = false
for child in children where child is SongItem {
let songItem = child as! SongItem
if (track.discNumber == songItem.song.discNumber || track.discNumber == 0) && track.trackNumber == songItem.song.trackNumber {
songItem.addAssociatedTracks([track])
inserted = true
break
}
}
if !inserted {
unmatchedTracks.insert(track)
}
}
return unmatchedTracks
}
public override func addAssociatedTrack(track: TagTunesTrack, forChildEntity childEntity: SearchAPIEntity) {
for child in children where child.entity.id == childEntity.id {
child.addAssociatedTracks([track])
return
}
let child = SongItem(entity: childEntity, parentItem: self)
child.addAssociatedTracks([track])
children.append(child)
}
public override var hasCommonArtist: Bool {
for child in children where child is SongItem {
if album.artist != (child as! SongItem).song.artist {
return false
}
}
return true
}
override class var childEntityType: SearchAPIRequest.EntityType {
return .Song
}
override func processLookupResult(result: SearchAPIResult) {
for song in result.contentsOfCollectionWithID(album.id) where !children.contains({ $0.entity == song }) {
let item = SongItem(entity: song, parentItem: self)
children.append(item)
}
}
}

76
TagTunes/AlbumTableCellView.swift Normal file → Executable file
View File

@@ -8,9 +8,9 @@
import Cocoa
import AppKitPlus
import SearchAPI
/// A table cell view to represent an `Album`. This view can be initialized using
/// `initWithFrame`.
/// A table cell view to represent an `Album`.
public class AlbumTableCellView: AdvancedTableCellView {
// MARK: Types
@@ -55,19 +55,6 @@ public class AlbumTableCellView: AdvancedTableCellView {
return loadingIndicator
}()
/// Intended to be used as accessory view.
///
/// Displayed for search results.
@IBOutlet public lazy var button: NSButton! = {
let button = NSButton()
button.setButtonType(NSButtonType.MomentaryPushInButton)
button.bezelStyle = NSBezelStyle.RoundedBezelStyle
button.controlSize = .SmallControlSize
button.setContentHuggingPriority(NSLayoutPriorityDefaultHigh, forOrientation: .Horizontal)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
@IBOutlet public lazy var errorButton: NSButton! = {
let errorButton = NSButton()
errorButton.setButtonType(NSButtonType.MomentaryChangeButton)
@@ -114,22 +101,23 @@ public class AlbumTableCellView: AdvancedTableCellView {
/// Configures the receiver to display the specified `album`.
///
/// - parameters:
/// - album: The album to be displayed.
/// - loading: `true` if a loading indicator should be displayed at the
/// album view.
public func setupForAlbum(album: Album, loading: Bool, error: NSError?) {
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
secondaryTextField?.stringValue = album.artistName
asyncImageView.downloadImageFromURL(album.artwork.displayImageURL)
if loading {
/// - albumItem: The album to be displayed.
/// - height: The height the cell view is going to have. This is used to
/// apropriately scale the album's artwork.
public func setupForAlbumItem(albumItem: AlbumItem, height: CGFloat) {
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? albumItem.album.censoredName : albumItem.album.name
secondaryTextField?.stringValue = albumItem.album.artist.name
asyncImageView.downloadImageFromURL(albumItem.album.artwork.optimalArtworkURLForImageSize(height))
switch albumItem.loadingState {
case .Loading:
textField?.textColor = NSColor.disabledControlTextColor()
rightAccessoryView = loadingIndicator
} else if error != nil {
case .Error:
textField?.textColor = NSColor.redColor()
rightAccessoryView = errorButton
} else {
case .Normal:
textField?.textColor = NSColor.controlTextColor()
if album.saved {
if albumItem.saved {
let aspectRatioConstraint = NSLayoutConstraint(
item: secondaryImageView,
attribute: .Width,
@@ -145,33 +133,31 @@ public class AlbumTableCellView: AdvancedTableCellView {
toItem: nil,
attribute: .Width,
multiplier: 1,
constant: 17)
constant: Constants.secondaryImageViewWidth)
setRightAccessoryView(secondaryImageView, withConstraints: [aspectRatioConstraint, widthConstraint])
} else {
rightAccessoryView = nil
}
}
}
/// Configures the receiver to display the specified `searchResult`.
/// Configures the cell to display the specified `album`. This method should
/// be used to display search results.
///
/// - parameters:
/// - searchResult: The search result to be displayed.
/// - selectable: `true` if the search result can be selected, `false`
/// otherwise.
public func setupForSearchResult(searchResult: SearchResult, selectable: Bool) {
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? searchResult.censoredName : searchResult.name
/// - album: The `Album` to be displayed.
/// - enabled: Wether the cell should appear *enabled*. If not it draws its
/// text in a gray color.
/// - height: The height the cell view is going to have. This is used to
/// appropriately scale the album's artwork.
public func setupForAlbum(album: Album, enabled: Bool, height: CGFloat) {
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
textField?.textColor = NSColor.controlTextColor()
secondaryTextField?.stringValue = searchResult.artistName
asyncImageView.downloadImageFromURL(searchResult.artwork.displayImageURL)
if selectable {
button.title = NSLocalizedString("Select", comment: "Button title for 'selecting a search result'")
button.enabled = true
} else {
button.title = NSLocalizedString("Added", comment: "Button title for a search result that is already present")
button.enabled = false
}
rightAccessoryView = button
secondaryTextField?.stringValue = album.artist.name
textField?.textColor = enabled ? NSColor.textColor() : NSColor.disabledControlTextColor()
secondaryTextField?.textColor = enabled ? NSColor.secondaryLabelColor() : NSColor.disabledControlTextColor()
asyncImageView.downloadImageFromURL(album.artwork.optimalArtworkURLForImageSize(height))
asyncImageView.enabled = enabled
}
}

30
TagTunes/AppDelegate.swift Normal file → Executable file
View File

@@ -11,13 +11,43 @@ import Cocoa
@NSApplicationMain
internal class AppDelegate: NSObject, NSApplicationDelegate {
/// The app's unsorted tracks window. This should be initalized when the app
/// launches.
var unsortedTracksWindowController: NSWindowController!
/// The app's unsorted tracks controller.
weak var unsortedTracksController: UnsortedTracksController!
internal func applicationDidFinishLaunching(aNotification: NSNotification) {
Preferences.sharedPreferences.initializeDefaultValues()
let storyboard = NSStoryboard(name: "Main", bundle: nil)
unsortedTracksWindowController = storyboard.instantiateControllerWithIdentifier("unsortedTracksWindowController") as? NSWindowController
unsortedTracksController.visible = true
}
internal func applicationShouldTerminateAfterLastWindowClosed(sender: NSApplication) -> Bool {
return true
}
@IBAction func toggleUnsortedTracks(sender: AnyObject?) {
unsortedTracksController.visible = !unsortedTracksController.visible
}
}
extension NSApplication {
/// The app`s unsorted tracks controller. When a `UnsortedTracksController`
/// is initialized it should this property to itself.
///
/// The value of this property is not retained.
var unsortedTracksController: UnsortedTracksController! {
set {
(delegate as? AppDelegate)?.unsortedTracksController = newValue
}
get {
return (delegate as? AppDelegate)?.unsortedTracksController
}
}
}

View File

@@ -1,124 +0,0 @@
//
// Artwork.swift
// Tag for iTunes
//
// Created by Kim Wittenburg on 30.05.15.
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
//
import Cocoa
/// Represents an Artwork from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public class Artwork: iTunesType {
// MARK: Properties
/// The URL for the artwork, sized to 60x60 pixels.
public let url60: NSURL
/// The URL for the artwork, sized to 100x100 pixels.
public let url100: NSURL
/// The URL for the artwork with full resolution.
///
/// - note: This URL is aquired by using an unofficial hack. If Apple changes
/// the way the full resolution artworks are stored this URL may be
/// `nil`.
public let hiResURL: NSURL!
// MARK: Initializers
public required init(data: [iTunesAPI.Field: AnyObject]) {
url60 = NSURL(string: data[.ArtworkUrl60] as! String)!
url100 = NSURL(string: data[.ArtworkUrl100] as! String)!
var hiResURLString = (data[.ArtworkUrl100] as! String)
let hotSpotRange = hiResURLString.rangeOfString("100x100", options: .BackwardsSearch, range: nil, locale: nil)!
hiResURLString.replaceRange(hotSpotRange, with: "1500x1500")
hiResURL = NSURL(string: hiResURLString)
}
public static var requiredFields: [iTunesAPI.Field] {
return [.ArtworkUrl60, .ArtworkUrl100]
}
// MARK: Methods
/// Saves the high resolution artwork to the specified URL. If there is
/// alread a file at the specified URL it will be overwritten.
///
/// - parameters:
/// - url: The URL of the directory the artwork is going to be saved to.
/// This must be a valid file URL.
/// - filename: The filename to be used (without the file extension).
public func saveToURL(url: NSURL, filename: String) throws {
let directory = url.filePathURL!.path!
let filePath = directory.stringByAppendingString("/\(filename).tiff")
if !Preferences.sharedPreferences.overwriteExistingFiles && NSFileManager.defaultManager().fileExistsAtPath(filePath) {
return
}
try NSFileManager.defaultManager().createDirectoryAtPath(directory, withIntermediateDirectories: true, attributes: nil)
let _ = NSFileManager.defaultManager().createFileAtPath(filePath, contents: saveImage?.TIFFRepresentation, attributes: nil)
}
// MARK: Calculated Properties
private var cachedImage60: NSImage?
/// Returns an `NSImage` instance of the image located at `url60`.
///
/// - attention: The first time this method is called the current thread will
/// be blocked until the image is loaded.
public var image60: NSImage? {
if cachedImage60 == nil {
cachedImage60 = NSImage(byReferencingURL: url60)
}
return cachedImage60
}
private var cachedImage100: NSImage?
/// Returns an `NSImage` instance of the image located at `url100`.
///
/// - attention: The first time this method is called the current thread will
/// be blocked until the image is loaded.
public var image100: NSImage? {
if cachedImage100 == nil {
cachedImage100 = NSImage(byReferencingURL: url100)
}
return cachedImage100
}
private var cachedHiResImage: NSImage?
/// Returns an `NSImage` instance of the image located at `hiResURL`.
///
/// - attention: The first time this method is called the current thread will
/// be blocked until the image is loaded.
public var hiResImage: NSImage? {
if hiResURL == nil {
return nil
}
if cachedHiResImage == nil {
cachedHiResImage = NSImage(byReferencingURL: hiResURL)
}
return cachedHiResImage
}
/// Returns the url of an image that should be used to display this artwork
/// with respect to the user's preferences.
public var displayImageURL: NSURL {
if !Preferences.sharedPreferences.useLowResolutionArtwork && hiResURL != nil {
return hiResURL
}
return url100
}
/// Returns the image that should be used to save this artwork.
public var saveImage: NSImage? {
return hiResImage != nil ? hiResImage : image100
}
}

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

0
TagTunes/Assets.xcassets/AppIcon.appiconset/16x16.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

0
TagTunes/Assets.xcassets/AppIcon.appiconset/32x32.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

View File

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 694 KiB

View File

0
TagTunes/Assets.xcassets/Contents.json Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Cross.imageset/Contents.json vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Cross.imageset/Cross.pdf vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Note.imageset/Contents.json vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Note.imageset/Note.pdf vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/PreferenceStore.imageset/Contents.json vendored Normal file → Executable file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

0
TagTunes/Assets.xcassets/PreferenceTags.imageset/Contents.json vendored Normal file → Executable file
View File

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

0
TagTunes/Assets.xcassets/Save.imageset/Contents.json vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Save.imageset/Save.pdf vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/SaveArtwork.imageset/Contents.json vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/SaveArtwork.imageset/SaveArtwork.pdf vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Tick.imageset/Contents.json vendored Normal file → Executable file
View File

0
TagTunes/Assets.xcassets/Tick.imageset/Tick.pdf vendored Normal file → Executable file
View File

BIN
TagTunes/Base.lproj/Localizable.strings Normal file → Executable file

Binary file not shown.

72
TagTunes/Base.lproj/Localizable.stringsdict Normal file → Executable file
View File

@@ -1,22 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>%d artworks could not be saved.</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@artworks@ could not be saved</string>
<key>artworks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>One artwork</string>
<key>other</key>
<string>%d artworks</string>
</dict>
</dict>
</dict>
<dict>
<key>Preparing Lookup for %d Tracks…</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Preparing Lookup for %#@tracks@…</string>
<key>tracks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>One Track</string>
<key>other</key>
<string>%d Tracks</string>
</dict>
</dict>
<key>Looking up %d tracks…</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Looking up %#@tracks@…</string>
<key>tracks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>one track</string>
<key>other</key>
<string>%d tracks</string>
</dict>
</dict>
<key>%d Tracks Pending</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@tracks@ Pending</string>
<key>tracks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>No Tracks</string>
<key>one</key>
<string>One Track</string>
<key>other</key>
<string>%d Tracks</string>
</dict>
</dict>
</dict>
</plist>

728
TagTunes/Base.lproj/Main.storyboard Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
//
// ContentViewController.swift
// TagTunes
//
// Created by Kim Wittenburg on 21.01.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import Cocoa
import SearchAPI
/// A `ContentViewController` displays a set of `TagTunesItem`s. It is
/// responsible for allowing the user to associate `TagTunesTrack`s with
/// `TagTunesEntityItem`s.
///
/// The expected structure of the content view controller is a tree structure in
/// the following form:
///
/// 1. At the root level there are `TagTunesGroupItem`s (groups) and
/// `TagTunesEntityItem`s (entities).
/// 2. Groups can contain other entities (children).
/// 3. Entities can have tracks associated with them. A track should not be
/// associated with multiple entities.
public class ContentViewController: NSViewController {
override public func viewDidLoad() {
super.viewDidLoad()
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ContentViewController.itemDidChangeState(_:)), name: TagTunesGroupItem.StateChangedNotificationName, object: nil)
}
/// Called when the `TagTunesGroupItem.StateChangedNotification` is posted.
private dynamic func itemDidChangeState(notification: NSNotification) {
if let item = notification.object as? TagTunesItem {
updateItem(item)
}
}
deinit {
NSNotificationCenter.defaultCenter().removeObserver(self)
}
/// Returns all selected items. This property should remove duplicate items
/// from the selection following these three rules:
///
/// 1. If an entity is selected (and thus contained in the returned array)
/// none of its `associatedTracks` should be contained in the returned
/// array.
/// 2. If a group is selected none of its children should be contained in the
/// returned array.
/// 3. If a track is selected and (1) and (2) do not appy it should be
/// contained in the returned array.
///
/// This value is not cached. If you need to access this multiple times in a
/// row, you should consider caching it yourself in a local variable. The
/// order in which the selected objects occur in the returned array may be
/// different from the order in which they are displayed.
///
/// This method must be implemented by subclasses.
public var selectedItems: [AnyObject] {
fatalError("Must override property selectedItems")
}
/// Returns the item the user clicked on. This property should be used
/// instead of the `selectedItems` for any context menu related actions. This
/// property should behave the same way that `selectedItems` does.
///
/// This method must be implemented by subclasses.
public var clickedItems: [AnyObject] {
fatalError("Must override property clickedItem")
}
/// Returns the item that represents the specified `entity`. If no such item
/// exists in the content view controller `nil` is returned.
///
/// This method must be implemented by subclasses.
public func itemForEntity(entity: SearchAPIEntity) -> TagTunesItem? {
fatalError("Must override itemForEntity(_:)")
}
/// Adds the specified `item` to the view controller. This should insert the
/// `item` at the root level of the controller. If the controller already
/// contains an item that represents the same entity as the specified `item`
/// this method should do nothing.
///
/// This method must be implemented by subclasses.
public func addItem(item: TagTunesItem) {
fatalError("Must override addItem(_:)")
}
/// Updates the specified `item`. This method should be called if the
/// properties of the `item` changed in any way and the view should be
/// notified about it. If the `item` has not previously been added to the
/// view controller this method should do nothing.
///
/// This method must be implemented by subclasses.
public func updateItem(item: TagTunesItem) {
fatalError("Must override updateItem(_:)")
}
/// Updates all items. If many items in the view controller changed it may be
/// more efficient to use this method instead of `updateItem(_:)`.
///
/// This method must be implemented by subclasses.
public func updateAllItems() {
fatalError("Must override updateAllItems()")
}
/// Removes the selected items from the view controller. By default the
/// implementation just calls `removeItems(selectedItems` but subclasses may
/// override this method to optimize the behavior.
public func removeSelectedItems() {
removeItems(selectedItems)
}
/// Removes the specified items from the view controller and updates the view
/// accordingly.
///
/// This method must be implemented by subclasses.
public func removeItems(items: [AnyObject]) {
fatalError("Must override removeItems(_:)")
}
}

6
TagTunes/DescriptiveError.swift Normal file → Executable file
View File

@@ -9,7 +9,7 @@
import Foundation
/// A custom error class that wraps another error to display a more descriptive
/// error description than for example "The operation couldn't be completed."
/// error description than for example "The operation couldn't be completed".
public class DescriptiveError: NSError {
/// Initializes the receiver with the specified `underlyingError`.
@@ -34,6 +34,8 @@ public class DescriptiveError: NSError {
override public var localizedDescription: String {
if domain == NSURLErrorDomain {
switch code {
case NSURLErrorCannotConnectToHost:
fallthrough
case NSURLErrorNotConnectedToInternet:
return NSLocalizedString("You are not connected to the internet.", comment: "Error message informing the user that he is not connected to the internet.")
case NSURLErrorTimedOut:
@@ -47,6 +49,8 @@ public class DescriptiveError: NSError {
override public var localizedRecoverySuggestion: String? {
if domain == NSURLErrorDomain {
switch code {
case NSURLErrorCannotConnectToHost:
fallthrough
case NSURLErrorNotConnectedToInternet:
return NSLocalizedString("Please check your network connection and try again.", comment: "Error recovery suggestion for 'not connected to the internet' error.")
case NSURLErrorTimedOut:

91
TagTunes/ImportedTrack.swift Executable file
View File

@@ -0,0 +1,91 @@
//
// ImportedTrack.swift
// TagTunes
//
// Created by Kim Wittenburg on 17.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
public class ImportedTrack: TagTunesTrack {
/// The `iTunesTrack` represented by this instance.
private var track: iTunesTrack
public override func readTrackID() throws -> SearchAPIID {
guard let fileTrack = self.track as? iTunesFileTrack, fileURL = fileTrack.location where fileURL.checkResourceIsReachableAndReturnError(nil) else {
throw TagTunesTrackErrors.FileNotFound
}
let fileHandle = try? NSFileHandle(forReadingFromURL: fileTrack.location)
// Read the first 1024 bytes from the file. The file's ID seems to always
// be at around 600-700 bytes so we don't need to read more than that.
guard let data = fileHandle?.readDataOfLength(1024) else {
throw TagTunesTrackErrors.FileNotReadable
}
let dataToFind = "song".dataUsingEncoding(NSASCIIStringEncoding)!
let dataRange = data.rangeOfData(dataToFind, options: [], range: NSRange(location: 0, length: data.length))
guard dataRange.location != NSNotFound else {
throw TagTunesTrackErrors.NoIDFound
}
var rawID: UInt32 = 0
data.getBytes(&rawID, range: NSRange(location: NSMaxRange(dataRange), length: 4))
return SearchAPIID(UInt32(bigEndian: rawID))
}
/// Initializes the track with the specified `iTunesTrack`.
public init(track: iTunesTrack) {
self.track = track
super.init()
}
public required init?(coder aDecoder: NSCoder) {
if let track = aDecoder.decodeObjectOfClass(iTunesTrack.self, forKey: "track") {
self.track = track
super.init()
} else {
track = iTunesTrack()
super.init()
return nil
}
}
public override func encodeWithCoder(aCoder: NSCoder) {
aCoder.encodeObject(track, forKey: "track")
}
public override func valueForTag(tag: Tag) -> AnyObject! {
return track.valueForKey(tag.rawValue)
}
public override func reveal() {
track.reveal()
iTunes.activate()
}
public override var supportsBatchSaving: Bool {
return false
}
public override func saveValue(value: AnyObject?, forTag tag: Tag) throws {
if tag == .Artwork {
track.artworks().removeAllObjects()
if let artwork = value as? NSImage {
track.artworks().objectAtIndex(0).propertyWithCode(AEKeyword("pPCT")).setTo(artwork)
}
} else if let theValue = value {
track.propertyWithCode(tag.code).setTo(theValue)
} else {
track.propertyWithCode(tag.code).setTo("")
}
}
public override var hashValue: Int {
return track.id()
}
}
public func ==(lhs: ImportedTrack, rhs: ImportedTrack) -> Bool {
return lhs.track.id() == rhs.track.id()
}

4
TagTunes/Info.plist Normal file → Executable file
View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2.1</string>
<string>2.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>37</string>
<string>2674</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>

73
TagTunes/LookupOperation.swift Executable file
View File

@@ -0,0 +1,73 @@
//
// LookupOperation.swift
// TagTunes
//
// Created by Kim Wittenburg on 08.04.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
import AppKitPlus
class LookupOperation: Operation {
/// The `NSURLSession` used to perform lookup tasks.
private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
let tracks: [TagTunesTrack]
private var lookupTask: NSURLSessionTask!
private var lookupData: NSData?
private var lookupError: ErrorType?
init<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
self.tracks = Array(tracks)
var request = SearchAPIRequest(lookupRequestWithIDs: tracks.flatMap { $0.id })
request.entity = .Album
request.country = Preferences.sharedPreferences.iTunesStore
if Preferences.sharedPreferences.useEnglishTags {
request.language = .English
}
super.init()
self.lookupTask = urlSession.dataTaskWithURL(request.URL, completionHandler: downloadFinishedWithData)
addCondition(MutuallyExclusive<LookupOperation>())
}
private func downloadFinishedWithData(data: NSData?, response: NSURLResponse?, error: NSError?) {
lookupData = data
lookupError = error
}
override func willEnqueue() {
let operation = URLSessionTaskOperation(task: lookupTask)
addDependency(operation)
produceOperation(operation)
super.willEnqueue()
}
override func execute() {
// TODO: This must be executed after the url data task finished. Is that the case?
do {
if let data = lookupData {
let result = try SearchAPIResult(data: data)
for track in tracks where track.id != nil {
if let resultTrack = result.entityForID(track.id!) as? Track {
track.lookupState = .Found(resultTrack)
} else {
track.lookupState = .NotFound
}
}
finish()
} else {
throw lookupError!
}
} catch {
for track in tracks {
track.lookupState = .Error(error)
}
finishWithError(error as NSError?)
}
}
}

141
TagTunes/LookupOperationTmp.swift Executable file
View File

@@ -0,0 +1,141 @@
////
//// LookupOperation.swift
//// TagTunes
////
//// Created by Kim Wittenburg on 08.04.16.
//// Copyright © 2016 Kim Wittenburg. All rights reserved.
////
//
//import AppKitPlus
//
//class LookupOperation: Operation {
//
// /// The `NSURLSession` used to perform lookup tasks.
// private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
//
// /// The task of the current lookup. If there is currently no lookup being
// /// processed this is `nil`.
// ///
// /// If you need to know whether there is currently a active lookup you should
// /// use the `lookupActive` property instead.
// private var lookupTask: NSURLSessionTask?
//
// var lookupActive = false
//
// /// The tracks that are currently being looked up. These tracks do not
// /// contain the tracks enqueued for lookup.
// private var lookupTracks = [SearchAPIID: TagTunesTrack]()
//
// private var queue = [TagTunesTrack]() {
// didSet {
// lookupActivity.progress.localizedAdditionalDescription = String(format: NSLocalizedString("%d Tracks Pending", comment: "Additional description format fpr the iTunes Match lookup."), queue.count)
// }
// }
//
// func enqueueTracksForLookup<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
// queue.appendContentsOf(tracks)
// if !lookupActive {
// beginLookup()
// }
// }
//
// @IBAction func cancelLookup(sender: AnyObject) {
// cancelLookup()
// }
//
// func cancelLookup() {
// lookupTask?.cancel()
// lookupTask = nil
// var tracks = queue
// tracks.appendContentsOf(lookupTracks.values)
// for track in tracks {
// track.lookupState = .Error(NSCocoaError.UserCancelledError)
// }
// lookupDelegate?.lookupController(self, completedLookupForTracks: tracks)
// queue = []
// lookupTracks = [:]
// lookupDelegate?.lookupControllerDidFinishLookup(self)
//
// lookupActive = false
// }
//
// private func beginLookup() {
// lookupActive = true
// lookupDelegate?.lookupController(self, willBeginLookupForTracks: queue)
//
// let tracks = queue
// dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { self.performLookupForTracks(tracks) }
// queue = []
// }
//
// /// Actually starts the lookup for the specified tracks.
// ///
// /// This method does not execute the network request (but it starts it).
// /// Still it is recommended not to invoke this method on the main thread
// /// since it can block the current thread for quite a while (depending on the
// /// location of the tracks).
// private func performLookupForTracks(tracks: [TagTunesTrack]) {
// dispatch_sync(dispatch_get_main_queue()) {
// self.lookupActivity.progress.totalUnitCount = -1
// self.lookupActivity.progress.localizedDescription = String(format: NSLocalizedString("Preparing Lookup for %d Tracks", comment: "Description format for the iTunes Match lookup preparation."), tracks.count)
// }
// var invalidTracks = [TagTunesTrack]()
// lookupTracks = [:]
// for track in tracks {
// track.lookupState = .Preparing
// if let id = track.id {
// lookupTracks[id] = track
// track.lookupState = .Searching
// } else {
// invalidTracks.append(track)
// track.lookupState = .Unqualified
// }
// }
// dispatch_sync(dispatch_get_main_queue()) {
// if !invalidTracks.isEmpty {
// self.lookupDelegate?.lookupController(self, completedLookupForTracks: invalidTracks)
// }
// self.lookupActivity.progress.localizedDescription = String(format: NSLocalizedString("Looking up %d tracks", comment: "Description format for the iTunes Match lookup."), self.lookupTracks.count)
// }
// var request = SearchAPIRequest(lookupRequestWithIDs: lookupTracks.keys)
// request.entity = .Album
// request.country = Preferences.sharedPreferences.iTunesStore
// if Preferences.sharedPreferences.useEnglishTags {
// request.language = .English
// }
// NSThread.sleepForTimeInterval(2)
// lookupTask = urlSession.dataTaskWithURL(request.URL, completionHandler: finishedTrackLookupWithData)
// lookupTask?.resume()
// }
//
// /// Invoked after the lookup network request has completed. This method
// /// processes the raw lookup data and send delegate messages accordingly.
// /// This method may send multiple delegate messages depending on the
// /// characteristics of the `data`.
// ///
// /// This method must be called on the main thread.
// private func finishedTrackLookupWithData(data: NSData?, response: NSURLResponse?, error: NSError?) {
// assert(NSThread.isMainThread())
// defer {
// lookupTask = nil
// }
// let result = (try? data.map(SearchAPIResult.init)) ?? nil
// for (id, track) in lookupTracks {
// if let error = error {
// track.lookupState = .Error(error)
// } else if let resultTrack = result?.entityForID(id) as? Track {
// track.lookupState = .Found(resultTrack)
// } else {
// track.lookupState = .NotFound
// }
// }
// lookupDelegate?.lookupController(self, completedLookupForTracks: Array(lookupTracks.values))
// if queue.isEmpty {
// lookupDelegate?.lookupControllerDidFinishLookup(self)
// lookupActive = false
// } else {
// beginLookup()
// }
// }
//
//}

View File

@@ -0,0 +1,27 @@
//
// LookupPreparationOperation.swift
// TagTunes
//
// Created by Kim Wittenburg on 12.04.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import AppKitPlus
// TODO: Documentation
class LookupPreparationOperation: Operation {
let track: TagTunesTrack
init(track: TagTunesTrack) {
self.track = track
}
override func execute() {
// TODO: Is it ok to block the thread?
track.updateTrackID()
finish()
}
}

181
TagTunes/LookupQueue.swift Executable file
View File

@@ -0,0 +1,181 @@
//
// LookupController.swift
// TagTunes
//
// Created by Kim Wittenburg on 07.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
import AppKitPlus
/// The state of a lookup process.
public enum LookupState {
/// The track has not been processed for lookup. This is the initial state.
case Unprocessed
/// The track is being prepared for lookup. During preparation the track's id
/// is read.
case Preparing
/// The lookup is currently searching for the track's metadata.
case Searching
/// The track was found. The associated `Track` represents the found metadata
/// of the track.
case Found(Track)
/// The track has been looked up but was not found.
case NotFound
/// The track is not qualified for lookup because its id could not be read. The
/// associated value contains an error description identifying the reason why
/// the track's id could not be read.
case Unqualified(ErrorType)
/// During the lookup an error occured. This state is also used if the lookup
/// was cancelled by the user.
case Error(ErrorType?)
}
// TODO: Documentation
// DOCUMENTATION: All delegate methods are executed on main thread.
protocol LookupQueueDelegate: class {
/// Invoked before the `lookupController` will process the specified
/// `tracks`. This method may be invoked multiple times before
/// `lookupControllerDiDFinishLookup(_:)` is invoked, but it is guaranteed
/// that it will be invoked at least once. For more information see the
/// documentation for `LookupController`.
func lookupQueue(lookupQueue: LookupQueue, willBeginLookupForTracks tracks: [TagTunesTrack])
/// Invoked when the `lookupController` completes the lookup process for the
/// specified `tracks`. This method is called for tracks that could be
/// successfully looked up or that could not be looked up. There can also be
/// both in one invocation of this method. Use the `lookupState` of the
/// `TagTunesTrack` instances to get information about the lookup result for
/// individual tracks.
///
/// This method may be called multiple times in a row. Every track that has
/// been part of the `tracks` array of a
/// `lookupController(_:willBeginLookupForTracks:)` message will be part of
/// a invocation of this method exactly once.
///
/// - parameters:
/// - lookupController: The controller that did the lookup.
/// - tracks: The tracks that have been processed by the
/// `lookupController`. The order of the tracks has no meaning.
func lookupQueue(lookupQueue: LookupQueue, completedLookupForTracks tracks: [TagTunesTrack])
/// Invoked after the `lookupController` finished looking up all enqueued
/// tracks.
func lookupQueueDidFinishLookup(lookupQueue: LookupQueue)
}
/// The lookup controller looks up tracks' metadata online based on the iTunes
/// store ID embedded into the a file. To be notified about the lookup progress
/// set the controller's `delegate` property. There are some valid assumptions
/// you can make about the order in which the delgate methods are invoked:
///
/// 1. `lookupController(_:willBeginLookupForTracks:)` is the first of the
/// methods to be invoked.
/// 2. Every `TagTunesTrack` that is part of the `tracks` in
/// `lookupController(_:willBeginLookupForTracks:)` will be part of the
/// `tracks` of `lookupController(_:completedLookupForTracks:)` or.
/// 4. `lookupControllerDidFinishLookup(_:)` may only be called after (1) and (2)
/// are satisfied.
/// 5. After `lookupControllerDidFinishLookup(_:)` the next delegate message will
/// be `lookupController(_:willBeginLookupForTracks:)`.
///
/// The calls to `lookupController(_:willBeginLookupForTracks:)` and
/// `lookupControllerDidFinishLookup(_:)` are **not** balanced. There may be
/// multiple invokations of the former with only a single invokation of the
/// latter.
class LookupQueue: OperationQueue {
static let globalQueue = LookupQueue()
// TODO: Is there an alternative to two delegates
/// The lookup controller's delegate.
weak var lookupDelegate: LookupQueueDelegate?
var lookupOperation: LookupOperation?
private var aggregatedTracks = [TagTunesTrack]()
/// Enqueues the specified `tracks` for lookup. The point in time where the
/// `tracks` will actually be looked up is not defined. It may be when this
/// method returns or at some later point. To get notified when the lookup
/// actually starts implement the delgate method
/// `lookupController(_:willBeginLookupForTracks:)`.
///
/// - note: There is no correspondance between the specified `tracks` an the
/// tracks passed to the delegate methods. If there is currently a
/// lookup in progress the lookup controller might collect the
/// enqueued tracks and batch-process them.
func enqueueTracksForLookup<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
aggregatedTracks.appendContentsOf(tracks)
for track in tracks {
let preparationOperation = LookupPreparationOperation(track: track)
addOperation(preparationOperation)
}
beginLookupIfPossible()
}
// DOCUMENTATION: Must execute on main thread
// FIXME: ? Can there be two lookup operations?
private func beginLookupIfPossible() {
// TODO: "Finished Lookup" delegate call
if lookupOperation == nil && !aggregatedTracks.isEmpty {
lookupOperation = LookupOperation(tracks: aggregatedTracks)
lookupOperation?.addObserver(BlockObserver(startHandler: { operation in
dispatch_sync(dispatch_get_main_queue()) {
// TODO: Is dispatch_sync ok?
let lookupOperation = operation as! LookupOperation
self.lookupDelegate?.lookupQueue(self, willBeginLookupForTracks: lookupOperation.tracks)
}
}, produceHandler: nil, finishHandler: { (operation, errors) in
dispatch_sync(dispatch_get_main_queue()) {
// TODO: Is this a retain cycle?
// TODO: Is dispatch_sync ok?
self.lookupOperation(operation, finishedWithErrors: errors)
}
}))
aggregatedTracks = []
for operation in operations where operation is LookupPreparationOperation {
lookupOperation?.addDependency(operation)
}
addOperation(lookupOperation!)
}
}
// DOCUMENTATION: Must execute on main thread.
private func lookupOperation(operation: Operation, finishedWithErrors errors: [ErrorType]) {
let lookupOperation = operation as! LookupOperation
self.lookupDelegate?.lookupQueue(self, completedLookupForTracks: lookupOperation.tracks)
self.lookupOperation = nil
if aggregatedTracks.isEmpty {
lookupDelegate?.lookupQueueDidFinishLookup(self)
} else {
beginLookupIfPossible()
}
}
/// Cancels the lookup. This will do three things:
///
/// 1. Stop any running or pending network request. Set the `lookupState` of
/// all tracks to `NSCocoaError.UserCancelledError`.
/// 2. Send the delegate a `lookupController(_:completedLookupForTracks:)`
/// message.
/// 3. Send the delegate a `lookupControllerDidFinishLookup(_:)` message.
func cancelLookup() {
// TODO: Implementation
}
}

1103
TagTunes/MainViewController.swift Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
//
// MainWindowController.swift
// TagTunes
//
// Created by Kim Wittenburg on 09.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import AppKitPlus
class MainWindowController: NSWindowController, NSWindowDelegate {
dynamic let searchController = SearchController()
@IBOutlet weak var searchField: PopUpSearchField! {
set {
searchController.searchField = newValue
}
get {
return searchController.searchField
}
}
dynamic var activityViewController: ActivityViewController!
@IBOutlet weak var activityProgressButton: ProgressButton!
let activityPopover = NSPopover()
override var contentViewController: NSViewController? {
didSet {
searchController.delegate = mainViewController
}
}
var mainViewController: MainViewController! {
return contentViewController as? MainViewController
}
override func windowDidLoad() {
super.windowDidLoad()
activityViewController = storyboard?.instantiateControllerWithIdentifier("ActivityController") as! ActivityViewController
searchController.delegate = mainViewController
LookupQueue.globalQueue.lookupDelegate = mainViewController
window?.titleVisibility = NSWindowTitleVisibility.Hidden
activityProgressButton.target = self
activityProgressButton.action = #selector(MainWindowController.showActivityView(_:))
activityPopover.contentViewController = activityViewController
activityPopover.behavior = .ApplicationDefined
}
@IBAction internal func beginSearch(sender: AnyObject?) {
searchController.beginSearch()
}
@objc @IBAction internal func showActivityView(sender: AnyObject?) {
if let view = sender as? NSView {
activityPopover.showRelativeToRect(view.bounds, ofView: view, preferredEdge: .MaxY)
}
}
}

View File

@@ -0,0 +1,421 @@
//
// OutlineContentViewController.swift
// TagTunes
//
// Created by Kim Wittenburg on 21.01.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import Foundation
import AppKitPlus
import SearchAPI
class OutlineContentViewController: ContentViewController {
// MARK: Types
private struct OutlineViewConstants {
struct ViewIdentifiers {
/// The identifier used for the outline view rows representing
/// `TagTunesTrack`s.
static let SimpleTableCellViewIdentifier = "SimpleTableCellViewIdentifier"
/// The identifier used for the outline view rows representing
/// `AlbumItem`s.
static let AlbumTableCellViewIdentifier = "AlbumTableCellViewIdentifier"
/// The identifier used for the outline view rows representing
/// `SongItem`s.
static let TrackTableCellViewIdentifier = "TrackTableCellViewIdentifier"
}
}
// MARK: IBOutlets
@IBOutlet private weak var outlineView: NSOutlineView!
// MARK: Outline View Content
/// All items in the outline view.
internal private(set) var items = [TagTunesItem]()
override func viewDidLoad() {
super.viewDidLoad()
outlineView.registerForDraggedTypes([TrackPboardType])
}
override var selectedItems: [AnyObject] {
var selectedGroups = Set<TagTunesGroupItem>()
var selectedEntities = Set<TagTunesEntityItem>()
var selectedTracks = Set<TagTunesTrack>()
for row in outlineView.selectedRowIndexes {
let item = outlineView.itemAtRow(row)
if let album = item as? TagTunesGroupItem {
selectedGroups.insert(album)
} else if let track = item as? TagTunesEntityItem {
selectedEntities.insert(track)
} else if let track = item as? TagTunesTrack {
selectedTracks.insert(track)
}
}
for group in selectedGroups {
for entity in group.children {
for track in entity.associatedTracks {
selectedTracks.remove(track)
}
selectedEntities.remove(entity)
}
}
for entity in selectedEntities {
for track in entity.associatedTracks {
selectedTracks.remove(track)
}
}
var selectedItems = [AnyObject]()
selectedItems.appendContentsOf(Array(selectedGroups) as [AnyObject])
selectedItems.appendContentsOf(Array(selectedEntities) as [AnyObject])
selectedItems.appendContentsOf(Array(selectedTracks) as [AnyObject])
return selectedItems
}
override var clickedItems: [AnyObject] {
if outlineView.clickedRow < 0 {
return []
} else if outlineView.selectedRowIndexes.contains(outlineView.clickedRow) {
return selectedItems
} else {
return [outlineView.itemAtRow(outlineView.clickedRow)!]
}
}
override func addItem(item: TagTunesItem) {
if itemForEntity(item.entity) == nil {
items.append(item)
outlineView.insertItemsAtIndexes(NSIndexSet(index: items.count-1), inParent: nil, withAnimation: NSTableViewAnimationOptions.EffectNone)
}
}
override func itemForEntity(entity: SearchAPIEntity) -> TagTunesItem? {
for item in items {
if item.entity == entity {
return item
}
if let group = item as? TagTunesGroupItem {
for child in group.children where child.entity == entity {
return child
}
}
}
return nil
}
override func updateItem(item: TagTunesItem) {
outlineView.reloadDataForRowIndexes(NSIndexSet(index: outlineView.rowForItem(item)), columnIndexes: NSIndexSet(index: 0))
outlineView.reloadItem(item, reloadChildren: true)
}
override func updateAllItems() {
outlineView.reloadData()
}
override func removeItems(objects: [AnyObject]) {
outlineView.beginUpdates()
for object in objects {
if let item = object as? TagTunesItem {
item.clearAssociatedTracks()
if let index = items.indexOf({ $0 == item }) {
items.removeAtIndex(index)
outlineView.removeItemsAtIndexes(NSIndexSet(index: index), inParent: nil, withAnimation: .EffectNone)
}
} else if let track = object as? TagTunesTrack {
if let entity = track.entity {
entity.associatedTracks.remove(track)
let row = outlineView.rowForItem(entity)
outlineView.reloadDataForRowIndexes(NSIndexSet(index: row), columnIndexes: NSIndexSet(index: 0))
outlineView.reloadItem(entity, reloadChildren: true)
}
}
}
outlineView.endUpdates()
}
}
// MARK: - Outline View Data Source & Delegate
extension OutlineContentViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {
if item == nil {
return items.count
} else if let group = item as? TagTunesGroupItem {
if case .Normal = group.loadingState {
return group.children.count
} else {
return 0
}
} else if let entity = item as? TagTunesEntityItem {
return entity.associatedTracks.count
} else {
return 0
}
}
func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {
if item == nil {
return items[index]
} else if let group = item as? TagTunesGroupItem {
return group.children[index]
} else if let entity = item as? TagTunesEntityItem {
return Array(entity.associatedTracks)[index]
} else {
return ""
}
}
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0
}
func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat {
if item is AlbumItem {
return 39
} else if let entity = item as? TagTunesEntityItem {
return (entity.parentItem != nil && entity.parentItem!.hasCommonArtist) ? 24 : 31
} else {
return 17
}
}
func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? {
if let albumItem = item as? AlbumItem {
var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.AlbumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView
if view == nil {
view = AlbumTableCellView()
view?.identifier = OutlineViewConstants.ViewIdentifiers.AlbumTableCellViewIdentifier
}
let height = self.outlineView(outlineView, heightOfRowByItem: item)
view?.setupForAlbumItem(albumItem, height: height)
view?.errorButton?.target = self
view?.errorButton.action = #selector(OutlineContentViewController.showErrorDetails(_:))
return view
}
if let songItem = item as? SongItem {
var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.TrackTableCellViewIdentifier, owner: nil) as? SongTableCellView
if view == nil {
view = SongTableCellView()
view?.identifier = OutlineViewConstants.ViewIdentifiers.TrackTableCellViewIdentifier
}
view?.setupForSongItem(songItem)
return view
}
if let track = item as? TagTunesTrack {
var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.SimpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView
if view == nil {
view = AdvancedTableCellView()
view?.identifier = OutlineViewConstants.ViewIdentifiers.SimpleTableCellViewIdentifier
}
view?.style = .Simple
view?.textField?.font = NSFont.systemFontOfSize(0)
view?.textField?.textColor = NSColor.textColor()
let nameFont = NSFont.labelFontOfSize(13)
if let name = track.name {
view?.textField?.stringValue = name
view?.textField?.textColor = NSColor.textColor()
view?.textField?.font = nameFont
} else {
let italicFont = NSFontManager.sharedFontManager().convertFont(nameFont, toHaveTrait: .ItalicFontMask)
view?.textField?.stringValue = NSLocalizedString("Unnamed Track", comment: "Default name for a track that has no name associated with it.")
view?.textField?.textColor = NSColor.disabledControlTextColor()
view?.textField?.font = italicFont
}
return view
}
return nil
}
// MARK: Drag and Drop
func outlineView(outlineView: NSOutlineView, writeItems items: [AnyObject], toPasteboard pasteboard: NSPasteboard) -> Bool {
if items.count > 1 {
for item in items {
if item is TagTunesGroupItem {
return false
}
}
}
var draggedItems = [NSPasteboardItem]()
let addDraggedTrack: (TagTunesTrack, row: Int) -> Void = { track, row in
let item = NSPasteboardItem()
item.setData(NSKeyedArchiver.archivedDataWithRootObject(track), forType: TrackPboardType)
item.setData(NSKeyedArchiver.archivedDataWithRootObject(row), forType: IndexPboardType)
draggedItems.append(item)
}
for item in items {
if let draggedItem = item as? TagTunesItem {
let row = outlineView.rowForItem(draggedItem)
for track in draggedItem.associatedTracks {
addDraggedTrack(track, row: row)
}
} else if let track = item as? TagTunesTrack, entity = track.entity {
if !items.contains({ $0 === entity }) {
let row = outlineView.rowForItem(entity)
addDraggedTrack(track, row: row)
}
}
}
pasteboard.clearContents()
pasteboard.writeObjects(draggedItems)
return !draggedItems.isEmpty
}
func outlineView(outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAtPoint screenPoint: NSPoint, operation: NSDragOperation) {
if let pointInWindow = view.window?.convertRectFromScreen(NSRect(origin: screenPoint, size: NSSize.zero)).origin {
let pointInView = view.window!.contentView!.convertPoint(pointInWindow, fromView: nil)
let targetView = view.window!.contentView!.hitTest(pointInView)
if targetView?.isDescendantOf(outlineView) ?? false {
return
}
}
if let draggedItems = session.draggingPasteboard.pasteboardItems where operation != .None {
outlineView.beginUpdates()
let draggedTracks = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! Int, $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! TagTunesTrack) }
for (row, track) in draggedTracks {
let item = outlineView.itemAtRow(row) as! TagTunesItem
if item is TagTunesGroupItem {
item.clearAssociatedTracks()
} else if let entity = item as? TagTunesEntityItem {
entity.associatedTracks.remove(track)
}
outlineView.reloadDataForRowIndexes(NSIndexSet(index: row), columnIndexes: NSIndexSet(index: 0))
outlineView.reloadItem(item, reloadChildren: true)
}
outlineView.endUpdates()
}
}
func outlineView(outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: AnyObject?, proposedChildIndex index: Int) -> NSDragOperation {
// Validate pasteboard contents
guard info.draggingPasteboard().canReadItemWithDataConformingToTypes([TrackPboardType]) else {
return .None
}
// Drop onto empty outline view
if item == nil && index == NSOutlineViewDropOnItemIndex {
return .None
}
// Drop on TagTunesTrack item or between items
if index != NSOutlineViewDropOnItemIndex || item is TagTunesTrack {
return .None
}
// Drop on loading (or failed) album
if let group = item as? TagTunesGroupItem {
guard case .Normal = group.loadingState else {
return .None
}
}
return .Move
}
func outlineView(outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: AnyObject?, childIndex index: Int) -> Bool {
guard let draggedItems = info.draggingPasteboard().pasteboardItems, targetItem = item as? TagTunesItem else {
return false
}
outlineView.beginUpdates()
let draggedTracks = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? Int, $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack, $0) }
if info.draggingSource() === outlineView {
for (row, track, _) in draggedTracks {
let item = outlineView.itemAtRow(row!) as! TagTunesItem
if item is TagTunesGroupItem {
item.clearAssociatedTracks()
} else if let entity = item as? TagTunesEntityItem, theTrack = track {
entity.associatedTracks.remove(theTrack)
}
outlineView.reloadDataForRowIndexes(NSIndexSet(index: row!), columnIndexes: NSIndexSet(index: 0))
outlineView.reloadItem(item, reloadChildren: true)
}
}
var remainingItems = [NSPasteboardItem]()
for (_, track, item) in draggedTracks {
if let theTrack = track {
if !targetItem.addAssociatedTracks([theTrack]).isEmpty {
remainingItems.append(item)
}
}
}
NSApp.unsortedTracksController.returnDraggedItems(remainingItems)
outlineView.reloadDataForRowIndexes(NSIndexSet(index: outlineView.rowForItem(targetItem)), columnIndexes: NSIndexSet(index: 0))
outlineView.reloadItem(targetItem, reloadChildren: true)
outlineView.endUpdates()
return true
}
}
// MARK: - Error Handling
extension OutlineContentViewController {
/// Action that should be triggered from a view inside the outline view. If
/// `sender` is not an `NSError` instance the item at the row associated with
/// the `sender` (as determined by `NSOutlineView.rowForView`) should be a
/// `NSError` or `Album` instance for this method to work correctly.
@objc @IBAction private func showErrorDetails(sender: AnyObject?) {
var error: NSError
if let theError = sender as? NSError {
error = theError
} else if let view = sender as? NSView {
let row = outlineView.rowForView(view)
let item = outlineView.itemAtRow(row)
if let group = item as? TagTunesGroupItem {
if case let .Error(theError) = group.loadingState {
error = theError as NSError
} else {
return
}
} else {
return
}
} else {
return
}
presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil)
}
override func willPresentError(error: NSError) -> NSError {
let recoveryOptions = [
NSLocalizedString("OK", comment: "Button title"),
NSLocalizedString("Try Again", comment: "Button title for error alerts offering the user to try again.")
]
return DescriptiveError(underlyingError: error, userInfo: [NSRecoveryAttempterErrorKey: self, NSLocalizedRecoveryOptionsErrorKey: recoveryOptions])
}
override func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int, delegate: AnyObject?, didRecoverSelector: Selector, contextInfo: UnsafeMutablePointer<Void>) {
let didRecover = attemptRecoveryFromError(error, optionIndex: recoveryOptionIndex)
delegate?.performSelector(didRecoverSelector, withObject: didRecover, withObject: contextInfo as! AnyObject)
}
override func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int) -> Bool {
if recoveryOptionIndex == 0 {
return true
}
for item in items where item is TagTunesGroupItem {
let group = item as! TagTunesGroupItem
if case let .Error(loadingError as NSError) = group.loadingState where error == loadingError || error.userInfo[NSUnderlyingErrorKey] === loadingError {
group.beginLoadingChildren()
return true
}
}
return false
}
}

80
TagTunes/Preference Controllers.swift Normal file → Executable file
View File

@@ -8,43 +8,10 @@
import Cocoa
internal class GeneralPreferencesViewController: NSViewController {
// MARK: IBOutlets
@IBOutlet internal weak var artworkPathControl: NSPathControl!
@IBOutlet internal weak var chooseArtworkButton: NSButton!
override internal func viewDidLoad() {
super.viewDidLoad()
artworkPathControl.URL = Preferences.sharedPreferences.artworkTarget
}
@IBAction internal func saveArtworkStateChanged(sender: AnyObject) {
if Preferences.sharedPreferences.saveArtwork && Preferences.sharedPreferences.artworkTarget == nil {
chooseArtworkPath(sender)
}
}
@IBAction internal func chooseArtworkPath(sender: AnyObject) {
let openPanel = NSOpenPanel()
openPanel.canChooseDirectories = true
openPanel.canChooseFiles = false
openPanel.canCreateDirectories = true
openPanel.prompt = NSLocalizedString("Choose…", comment: "Button title in an open dialog prompting the user to choose a directory")
openPanel.beginSheetModalForWindow(view.window!) {
result in
if result == NSModalResponseOK {
Preferences.sharedPreferences.artworkTarget = openPanel.URL!.filePathURL!
} else if Preferences.sharedPreferences.artworkTarget == nil {
Preferences.sharedPreferences.saveArtwork = false
}
}
}
class GeneralPreferencesViewController: NSViewController {
}
internal class StorePreferencesViewController: NSViewController {
class StorePreferencesViewController: NSViewController {
dynamic var iTunesStores: [String] {
return NSLocale.ISOCountryCodes().map { NSLocale.currentLocale().displayNameForKey(NSLocaleCountryCode, value: $0)! }
@@ -62,7 +29,7 @@ internal class StorePreferencesViewController: NSViewController {
}
internal class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
// MARK: Types
@@ -84,12 +51,12 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
// MARK: Table View
internal func numberOfRowsInTableView(tableView: NSTableView) -> Int {
return Track.Tag.allTags.count
func numberOfRowsInTableView(tableView: NSTableView) -> Int {
return Tag.allTags.count
}
func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
let tag = Track.Tag.allTags[row]
let tag = Tag.allTags[row]
if tableColumn?.identifier == TableViewConstants.tagTableColumnIdentifier {
let view = tableView.makeViewWithIdentifier(TableViewConstants.textTableCellViewIdentifier, owner: nil) as? NSTableCellView
view?.textField?.stringValue = tag.localizedName
@@ -100,9 +67,7 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
if tag.isReturnedBySearchAPI {
popupButton?.addItemWithTitle(NSLocalizedString("Save", comment: "Menu item title for a tag that is going to be saved"))
}
if tag.clearable {
popupButton?.addItemWithTitle(NSLocalizedString("Clear", comment: "Menu item title for a tag that is going to be cleared"))
}
popupButton?.addItemWithTitle(NSLocalizedString("Clear", comment: "Menu item title for a tag that is going to be cleared"))
popupButton?.addItemWithTitle(NSLocalizedString("Ignore", comment: "Menu item title for a tag that is not going to be saved"))
var selectedIndex: Int
@@ -111,17 +76,11 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
selectedIndex = 0
case .Clear:
selectedIndex = 1
if !tag.isReturnedBySearchAPI {
--selectedIndex
}
case .Ignore:
selectedIndex = 2
if !tag.isReturnedBySearchAPI {
--selectedIndex
}
if !tag.clearable {
--selectedIndex
}
}
if !tag.isReturnedBySearchAPI {
selectedIndex -= 1
}
popupButton?.selectItemAtIndex(selectedIndex)
return popupButton
@@ -130,27 +89,14 @@ internal class TagsPreferencesViewController: NSViewController, NSTableViewDataS
}
@IBAction private func savingBehaviorChanged(sender: NSPopUpButton) {
let tag = Track.Tag.allTags[tableView.rowForView(sender)]
let tag = Tag.allTags[tableView.rowForView(sender)]
let selectedIndex = sender.indexOfItem(sender.selectedItem!)
var savingBehavior = Preferences.sharedPreferences.tagSavingBehaviors[tag]!
switch selectedIndex {
case 0:
if tag.isReturnedBySearchAPI {
savingBehavior = .Save
} else if tag.clearable {
savingBehavior = .Clear
} else {
savingBehavior = .Ignore
}
savingBehavior = tag.isReturnedBySearchAPI ? .Save : .Clear
case 1:
if tag.isReturnedBySearchAPI {
if tag.clearable {
savingBehavior = .Clear
} else {
savingBehavior = .Ignore
}
}
savingBehavior = .Ignore
savingBehavior = tag.isReturnedBySearchAPI ? .Clear : .Ignore
case 2:
savingBehavior = .Ignore
default:

113
TagTunes/Preferences.swift Normal file → Executable file
View File

@@ -27,14 +27,6 @@ import Cocoa
// MARK: Types
internal struct UserDefaultsConstants {
static let saveArtworkKey = "Save Artwork"
static let artworkTargetKey = "Artwork Target"
static let overwriteExistingFilesKey = "Overwrite Existing Files"
static let keepSearchResultsKey = "Keep Search Results"
static let numberOfSearchResultsKey = "Number of Search Results"
@@ -42,18 +34,12 @@ import Cocoa
static let useEnglishTagsKey = "Use English Tags"
static let useLowResolutionArtworkKey = "Use Low Resolution Artwork"
static let removeSavedItemsKey = "Remove Saved Items"
static let keepSavedAlbumsKey = "Keep Saved Albums"
static let useCensoredNamesKey = "Use Censored Names"
static let caseSensitiveKey = "Case Sensitive"
static let clearArtworksKey = "Clear Artworks"
static let tagSavingBehaviorsKey = "Tag Saving Behaviors"
}
@@ -81,74 +67,27 @@ import Cocoa
/// overridden.
public func initializeDefaultValues() {
NSUserDefaults.standardUserDefaults().registerDefaults([
UserDefaultsConstants.saveArtworkKey: false,
UserDefaultsConstants.overwriteExistingFilesKey: false,
UserDefaultsConstants.keepSearchResultsKey: false,
UserDefaultsConstants.numberOfSearchResultsKey: 10,
UserDefaultsConstants.iTunesStoreKey: NSLocale.currentLocale().objectForKey(NSLocaleCountryCode)!,
UserDefaultsConstants.useEnglishTagsKey: false,
UserDefaultsConstants.useLowResolutionArtworkKey: false,
UserDefaultsConstants.removeSavedItemsKey: false,
UserDefaultsConstants.keepSavedAlbumsKey: false,
UserDefaultsConstants.useCensoredNamesKey: false,
UserDefaultsConstants.caseSensitiveKey: true,
UserDefaultsConstants.clearArtworksKey: false
UserDefaultsConstants.caseSensitiveKey: true
])
if NSUserDefaults.standardUserDefaults().dictionaryForKey(UserDefaultsConstants.tagSavingBehaviorsKey) == nil {
var savingBehaviors: [Track.Tag: TagSavingBehavior] = [:]
for tag in Track.Tag.allTags {
tagSavingBehaviors = [:]
}
var savingBehaviors = tagSavingBehaviors
for tag in Tag.allTags {
if savingBehaviors[tag] == nil {
savingBehaviors[tag] = tag.isReturnedBySearchAPI ? .Save : .Clear
}
tagSavingBehaviors = savingBehaviors
}
tagSavingBehaviors = savingBehaviors
}
// MARK: General Preferences
/// If `true` the album artwork should be saved to the `artworkTarget` URL
/// when an item is saved.
public dynamic var saveArtwork: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.saveArtworkKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.saveArtworkKey)
}
}
/// The URL of the folder album artwork is saved to.
///
/// The URL must be a valid file URL pointing to a directory.
public dynamic var artworkTarget: NSURL? {
set {
NSUserDefaults.standardUserDefaults().setURL(newValue, forKey: UserDefaultsConstants.artworkTargetKey)
}
get {
return NSUserDefaults.standardUserDefaults().URLForKey(UserDefaultsConstants.artworkTargetKey)
}
}
/// If `true` any existing files will be overwritten when artworks are saved.
public dynamic var overwriteExistingFiles: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.overwriteExistingFilesKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.overwriteExistingFilesKey)
}
}
/// If `true` the search results are not removed from the main outline view
/// when the user selects a result.
public dynamic var keepSearchResults: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.keepSearchResultsKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.keepSearchResultsKey)
}
}
/// The number of search results that should be displayed.
public dynamic var numberOfSearchResults: Int {
set {
@@ -181,17 +120,6 @@ import Cocoa
}
}
/// If `true` the main table view will use 100x100 artworks instead of full
/// sized images. This option does not affect saving.
public dynamic var useLowResolutionArtwork: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.useLowResolutionArtworkKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.useLowResolutionArtworkKey)
}
}
/// If `true` all saved items are removed from the list after saving.
public dynamic var removeSavedItems: Bool {
set {
@@ -201,17 +129,6 @@ import Cocoa
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.removeSavedItemsKey)
}
}
/// If `true` and `removeSavedItems` is also `true` albums are not removed on
/// saving.
public dynamic var keepSavedAlbums: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.keepSavedAlbumsKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.keepSavedAlbumsKey)
}
}
// MARK: Tag Preferences
@@ -235,26 +152,16 @@ import Cocoa
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.caseSensitiveKey)
}
}
/// If `true` TagTunes clears the artworsk of saved tracks.
public dynamic var clearArtworks: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.clearArtworksKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.clearArtworksKey)
}
}
/// The ways different tags are saved (or not saved).
public var tagSavingBehaviors: [Track.Tag: TagSavingBehavior] {
public var tagSavingBehaviors: [Tag: TagSavingBehavior] {
set {
let savableData = newValue.map { ($0.rawValue, $1.rawValue) }
NSUserDefaults.standardUserDefaults().setObject(savableData, forKey: UserDefaultsConstants.tagSavingBehaviorsKey)
}
get {
let savableData = NSUserDefaults.standardUserDefaults().dictionaryForKey(UserDefaultsConstants.tagSavingBehaviorsKey)!
return savableData.map { (Track.Tag(rawValue: $0)!, TagSavingBehavior(rawValue: $1 as! String)!) }
return savableData.map { (Tag(rawValue: $0)!, TagSavingBehavior(rawValue: $1 as! String)!) }
}
}

99
TagTunes/SaveOperation.swift Executable file
View File

@@ -0,0 +1,99 @@
//
// iTunesSaveOperation.swift
// TagTunes
//
// Created by Kim Wittenburg on 08.04.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import AppKitPlus
// TODO: Documentation
class SaveOperation<TrackType: TagTunesTrack>: Operation, NSProgressReporting {
// TODO: Support Cancellation
let progress = NSProgress(parent: nil, userInfo: nil)
let track: TrackType
let entity: TagTunesEntityItem
let parentEntity: TagTunesGroupItem?
init(track: TrackType, entity: TagTunesEntityItem) {
self.track = track
self.entity = entity
self.parentEntity = entity.parentItem
super.init()
progress.cancellable = false
progress.localizedDescription = entity.entity.name
progress.localizedAdditionalDescription = "Pending…" // TODO: Localize
addCondition(MutuallyExclusive<TrackType>())
}
override func execute() {
dispatch_sync(dispatch_get_main_queue()) {
self.progress.totalUnitCount = Int64(Tag.allTags.count)
self.progress.completedUnitCount = 0
}
if track.supportsBatchSaving {
executeBatchSave()
} else {
executeNormalSave()
}
}
private func executeBatchSave() {
dispatch_sync(dispatch_get_main_queue()) {
self.progress.localizedAdditionalDescription = "Saving…" // TODO: Localize
}
var tags = [Tag: AnyObject?]()
for tag in Tag.allTags {
do {
switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
case .Save:
tags[tag] = try entity.valueForTag(tag)
case .Clear:
tags[tag] = nil
case .Ignore:
break
}
} catch {
logError(error as NSError)
}
}
progress.becomeCurrentWithPendingUnitCount(progress.totalUnitCount)
do {
try track.saveTags(tags)
finish()
} catch {
finishWithError(error as NSError)
}
progress.resignCurrent()
}
private func executeNormalSave() {
for tag in Tag.allTags {
dispatch_sync(dispatch_get_main_queue()) {
self.progress.localizedAdditionalDescription = String(format: "Saving %@…", tag.localizedName) // TODO: Localize
}
do {
switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
case .Save:
try track.saveValue(entity.valueForTag(tag), forTag: tag)
case .Clear:
try track.saveValue(nil, forTag: tag)
case .Ignore:
break
}
} catch {
logError(error as NSError)
}
dispatch_sync(dispatch_get_main_queue()) {
self.progress.completedUnitCount += 1
}
}
finish()
}
}

View File

@@ -0,0 +1,63 @@
//
// SaveTableCellView.swift
// TagTunes
//
// Created by Kim Wittenburg on 08.04.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import AppKitPlus
// TODO: Generalize for AppKitPlus
class SaveTableCellView: AdvancedTableCellView {
let progressIndicator: NSProgressIndicator = {
let indicator = NSProgressIndicator()
indicator.style = .SpinningStyle
indicator.controlSize = .SmallControlSize
indicator.minValue = 0
indicator.maxValue = 1
indicator.translatesAutoresizingMaskIntoConstraints = false
return indicator
}()
let cancelButton: NSButton = {
let button = NSButton()
button.setButtonType(.MomentaryChangeButton)
button.bezelStyle = .ShadowlessSquareBezelStyle
button.bordered = false
button.imagePosition = .ImageOnly
button.image = NSImage(named: NSImageNameStopProgressFreestandingTemplate)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
dynamic var progress: NSProgress?
override func setupView() {
style = .Subtitle
cancelButton.target = self
cancelButton.action = #selector(SaveTableCellView.cancel(_:))
let stackView = NSStackView(views: [progressIndicator, cancelButton])
stackView.orientation = .Horizontal
rightAccessoryView = stackView
// Setup bindings
primaryTextField?.bind(NSValueBinding, toObject: self, withKeyPath: "progress.localizedDescription", options: nil)
secondaryTextField?.bind(NSValueBinding, toObject: self, withKeyPath: "progress.localizedAdditionalDescription", options: nil)
cancelButton.bind(NSEnabledBinding, toObject: self, withKeyPath: "progress.cancellable", options: nil)
progressIndicator.bind(NSValueBinding, toObject: self, withKeyPath: "progress.fractionCompleted", options: nil)
progressIndicator.bind(NSIsIndeterminateBinding, toObject: self, withKeyPath: "progress.indeterminate", options: nil)
progressIndicator.bind(NSAnimateBinding, toObject: self, withKeyPath: "progress.indeterminate", options: nil)
super.setupView()
}
func cancel(sender: AnyObject?) {
progress?.cancel()
}
}

182
TagTunes/SearchController.swift Executable file
View File

@@ -0,0 +1,182 @@
//
// SearchController.swift
// TagTunes
//
// Created by Kim Wittenburg on 06.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
import AppKitPlus
/// A search delegate is notified when a `SearchController` selects an item.
protocol SearchDelegate: class {
/// The view controller displaying the content.
var contentViewController: ContentViewController! { get }
/// Invoked when the `searchController` selected a item. In the
/// implementation the selected `searchResult` should be added to the
/// `contentViewController`.
func searchController(searchController: SearchController, didSelectSearchResult searchResult: TagTunesItem)
}
/// Manages the user interface for search results.
public class SearchController: NSObject, PopUpSearchFieldDelegate {
/// This struct contains *magic numbers* like references to storyboard
/// identifiers.
private struct Constants {
/// The identifier used for the rows in the search results pop up.
static let AlbumTableCellViewIdentifier = "AlbumTableCellViewIdentifier"
/// The identifier used for the `No Results` row in the search results
/// pop up.
static let NoResultsTableCellViewIdentifier = "NoResultsTableCellViewIdentifier"
}
weak var delegate: SearchDelegate?
/// The search field. This should be set by the view controller containing
/// the user interface for searching.
public weak var searchField: PopUpSearchField! {
willSet {
searchField?.popUpDelegate = nil
}
didSet {
searchField?.popUpDelegate = self
}
}
/// Used for searching and loading search results
private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
/// The URL task currently loading the search results.
private var searchTask: NSURLSessionTask? {
didSet {
searching = searchTask != nil
}
}
public private(set) dynamic var searching: Bool = false
/// The search results for the current search term.
private var searchResults = [Album]() {
didSet {
searchField.updatePopUp()
}
}
/// The error that occured during searching, if any.
private var searchError: NSError?
/// Begins searching for the `searchField`'s `stringValue`.
public func beginSearch() {
let searchString = searchField.stringValue
cancelSearch()
if searchString == "" {
searchResults = []
} else {
var request = SearchAPIRequest(searchRequestWithTerm: searchString)
request.mediaType = .Music
request.entity = .Album
request.maximumNumberOfResults = UInt(Preferences.sharedPreferences.numberOfSearchResults)
if Preferences.sharedPreferences.useEnglishTags {
request.language = .English
}
request.country = Preferences.sharedPreferences.iTunesStore
searchTask = urlSession.dataTaskWithURL(request.URL, completionHandler: processSearchResults)
searchTask!.resume()
}
}
/// Cancels the current search (if there is one) and removes the search
/// results.
private func cancelSearch() {
searchTask?.cancel()
}
/// Processes the data returned from a network request into the
/// `searchResults` array.
private func processSearchResults(data: NSData?, response: NSURLResponse?, error: NSError?) {
searchTask = nil
var newResults = [Album]()
if let theData = data where error == nil {
do {
let result = try SearchAPIResult(data: theData)
newResults = result.resultEntities.flatMap{ $0 as? Album }
} catch let error as NSError {
searchError = error
}
} else if let theError = error {
searchError = theError
}
searchResults = newResults
}
public func popUpSearchFieldWillHidePopUp(searchField: PopUpSearchField) {
cancelSearch()
}
public func popUpSearchFieldShouldShowPopUp(searchField: PopUpSearchField) -> Bool {
return !searchField.stringValue.isEmpty
}
public func popUpSearchField(searchField: PopUpSearchField, didSelectPopUpEntryAtRow row: Int) {
let searchResult = searchResults[row]
let albumItem = AlbumItem(entity: searchResult)
albumItem.beginLoadingChildren()
delegate?.searchController(self, didSelectSearchResult: albumItem)
searchField.reloadPopUpRow(row)
}
private var shouldDisplayNoResults: Bool {
return searchResults.isEmpty && !searchField.stringValue.isEmpty && searchTask == nil
}
public func numberOfRowsInTableView(tableView: NSTableView) -> Int {
if shouldDisplayNoResults {
return 1
}
return searchResults.count
}
public func tableView(tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
return 39
}
public func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
if shouldDisplayNoResults {
var view = tableView.makeViewWithIdentifier(Constants.NoResultsTableCellViewIdentifier, owner: nil) as? CenteredTableCellView
if view == nil {
view = CenteredTableCellView()
view?.identifier = Constants.NoResultsTableCellViewIdentifier
}
view?.setupForNoResults()
return view
} else {
var view = tableView.makeViewWithIdentifier(Constants.AlbumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView
if view == nil {
view = AlbumTableCellView()
view?.identifier = Constants.AlbumTableCellViewIdentifier
}
let searchResult = searchResults[row]
let height = self.tableView(tableView, heightOfRow: row)
let albumEnabled = delegate?.contentViewController.itemForEntity(searchResult) == nil
view?.setupForAlbum(searchResult, enabled: albumEnabled, height: height)
return view
}
}
public func tableView(tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
if shouldDisplayNoResults {
return false
} else {
return delegate?.contentViewController?.itemForEntity(searchResults[row]) == nil
}
}
}

71
TagTunes/SongItem.swift Executable file
View File

@@ -0,0 +1,71 @@
//
// SongItem.swift
// TagTunes
//
// Created by Kim Wittenburg on 30.05.15.
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
//
import SearchAPI
/// Represents a `Song` from the Search API.
public class SongItem: TagTunesEntityItem {
/// Returns the `entity` as a `Song`.
public var song: Song {
return entity as! Song
}
public var album: Album {
return parentItem?.entity as! Album
}
override public var saved: Bool {
let components = NSCalendar.currentCalendar().components(.Year, fromDate: song.releaseDate)
for track in associatedTracks {
let trackName = Preferences.sharedPreferences.useCensoredNames ? song.censoredName : song.name
let albumName = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name
let options = Preferences.sharedPreferences.caseSensitive ? [] : NSStringCompareOptions.CaseInsensitiveSearch
if let name = track.name where name.compare(trackName, options: options, range: nil, locale: nil) != .OrderedSame {
return false
}
if let album = track.album where album.compare(albumName, options: options, range: nil, locale: nil) != .OrderedSame {
return false
}
guard track.artist == song.artist.name && track.year == components.year && track.trackNumber == song.trackNumber && track.trackCount == song.trackCountOnDisc && track.discNumber == song.discNumber && track.discCount == song.discCount && track.genre == song.primaryGenre && track.albumArtist == album.artist.name && track.composer == "" else {
return false
}
}
return true
}
public override func valueForTag(tag: Tag) throws -> AnyObject! {
let censoredNames = Preferences.sharedPreferences.useCensoredNames
switch tag {
case .Name: return censoredNames ? song.censoredName : song.name
case .Artist: return censoredNames ? song.artist.censoredName : song.artist.name
case .Year:
let components = NSCalendar.currentCalendar().components(.Year, fromDate: song.releaseDate)
return components.year
case .TrackNumber: return song.trackNumber
case .TrackCount: return song.trackCountOnDisc
case .DiscNumber: return song.discNumber
case .DiscCount: return song.discCount
case .Genre: return song.primaryGenre
case .AlbumName: return censoredNames ? album.censoredName : album.name
case .AlbumArtist: return album.artist.name
case .Compilation: return album.compilation
case .ReleaseDate: return song.releaseDate
// TODO: Artwork download error!
case .Artwork: return album.artwork.optimalArtworkImageForSize(CGFloat.max)
case .SortName,
.SortArtist,
.SortAlbumName,
.SortAlbumArtist,
.Composer,
.SortComposer,
.Comment: return nil
}
}
}

140
TagTunes/SongTableCellView.swift Executable file
View File

@@ -0,0 +1,140 @@
//
// TrackTableCellView.swift
// Harmony
//
// Created by Kim Wittenburg on 21.01.15.
// Copyright (c) 2015 Das Code Kollektiv. All rights reserved.
//
import Cocoa
import AppKitPlus
/// A table cell view to display information for a `SongItem`.
public class SongTableCellView: AdvancedTableCellView {
// MARK: Types
private struct Images {
/// Caches the tick image for track cells so that it does not need to be
/// reloaded every time a cell is configured.
static let tickImage = NSImage(named: "Tick")?.imageByMaskingWithColor(NSColor.clearColor())
/// Caches the gray tick image for track cells so that it does not need to be
/// reloaded every time a cell is configured.
static let grayTickImage = NSImage(named: "Tick")?.imageByMaskingWithColor(NSColor.lightGrayColor())
}
// MARK: Properties
/// An outlet do display the track number. This acts as a secondary label.
/// The text color is automatically adjusted based on the `backgroundStyle`
/// of the receiver.
///
/// Intended to be used as accessory view
@IBOutlet public lazy var trackNumberTextField: NSTextField? = {
let trackNumberTextField = NSTextField()
trackNumberTextField.bordered = false
trackNumberTextField.drawsBackground = false
trackNumberTextField.selectable = false
trackNumberTextField.lineBreakMode = .ByTruncatingTail
trackNumberTextField.font = NSFont.systemFontOfSize(0)
trackNumberTextField.textColor = NSColor.secondaryLabelColor()
trackNumberTextField.translatesAutoresizingMaskIntoConstraints = false
return trackNumberTextField
}()
/// Intended to be used as accessory view.
///
/// This property replaces `imageView`.
@IBOutlet public lazy var savedImageView: NSImageView! = {
let secondaryImageView = NSImageView()
secondaryImageView.imageScaling = .ScaleProportionallyDown
secondaryImageView.translatesAutoresizingMaskIntoConstraints = false
return secondaryImageView
}()
// MARK: Intitializers
override public func setupView() {
leftAccessoryView = trackNumberTextField
super.setupView()
}
// MARK: Overrides
override public var backgroundStyle: NSBackgroundStyle {
get {
return super.backgroundStyle
}
set {
super.backgroundStyle = newValue
let trackNumberCell = self.trackNumberTextField?.cell as? NSTextFieldCell
trackNumberCell?.backgroundStyle = newValue
switch newValue {
case .Light:
trackNumberTextField?.textColor = NSColor.secondaryLabelColor()
case .Dark:
trackNumberTextField?.textColor = NSColor.secondarySelectedControlColor()
default:
break
}
}
}
override public var imageView: NSImageView? {
set {
savedImageView = newValue
}
get {
return savedImageView
}
}
// MARK: Methods
/// Sets up the receiver to display the specified `songItem`.
public func setupForSongItem(songItem: SongItem) {
style = (songItem.parentItem != nil && songItem.parentItem!.hasCommonArtist) ? .Simple : .CompactSubtitle
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? songItem.song.censoredName : songItem.song.name
if songItem.associatedTracks.isEmpty {
textField?.textColor = NSColor.disabledControlTextColor()
} else if songItem.associatedTracks.count > 1 || songItem.associatedTracks.first!.name?.compare(songItem.song.name, options: Preferences.sharedPreferences.caseSensitive ? [] : .CaseInsensitiveSearch, range: nil, locale: nil) != .OrderedSame {
textField?.textColor = NSColor.redColor()
} else {
textField?.textColor = NSColor.controlTextColor()
}
secondaryTextField?.stringValue = songItem.song.artist.name
trackNumberTextField?.stringValue = "\(songItem.song.trackNumber)"
if songItem.associatedTracks.isEmpty {
imageView?.image = SongTableCellView.Images.grayTickImage
} else {
imageView?.image = SongTableCellView.Images.tickImage
}
if songItem.saved {
let aspectRatioConstraint = NSLayoutConstraint(
item: savedImageView,
attribute: .Width,
relatedBy: .Equal,
toItem: savedImageView,
attribute: .Height,
multiplier: 1,
constant: 0)
let widthConstraint = NSLayoutConstraint(
item: savedImageView,
attribute: .Width,
relatedBy: .Equal,
toItem: nil,
attribute: .Width,
multiplier: 1,
constant: 17)
setRightAccessoryView(savedImageView, withConstraints: [aspectRatioConstraint, widthConstraint])
} else {
rightAccessoryView = nil
}
}
}

26
TagTunes/String+AEKeyword.swift Executable file
View File

@@ -0,0 +1,26 @@
//
// String+AEKeyword.swift
// TagTunes
//
// Created by Kim Wittenburg on 14.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import Foundation
public extension AEKeyword {
/// Initializes the keyword with the specified string. This is intended to
/// replace the C-style literal keywords.
public init(_ string: String) {
var result : UInt = 0
if let data = string.dataUsingEncoding(NSMacOSRomanStringEncoding) {
let bytes = UnsafePointer<UInt8>(data.bytes)
for i in 0..<data.length {
result = result << 8 + UInt(bytes[i])
}
}
self.init(result)
}
}

78
TagTunes/Tag.swift Executable file
View File

@@ -0,0 +1,78 @@
//
// Tag.swift
// TagTunes
//
// Created by Kim Wittenburg on 21.01.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import Foundation
/// This enum contains cases for the different tags supported by TagTunes. Tags
/// may or may not be returned by the iTunes Store. Use the
/// `isReturnedBySearchAPI` property to query this information.
///
/// The `Tag` enum supports localization for displaying the name of a tag in the
/// user interface. Use the `localizedName` property for this purpose.
public enum Tag: String {
case Name = "name", Artist = "artist", Year = "year", TrackNumber = "trackNumber", TrackCount = "trackCount", DiscNumber = "discNumber", DiscCount = "discCount", Genre = "genre", AlbumName = "album", AlbumArtist = "albumArtist", ReleaseDate = "releaseDate", Compilation = "compilation"
case Artwork = "artwork"
case SortName = "sortName", SortArtist = "sortArtist", SortAlbumName = "sortAlbum", SortAlbumArtist = "sortAlbumArtist", Composer = "composer", SortComposer = "sortComposer", Comment = "comment"
/// Returns `true` for tags that are returned from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public var isReturnedBySearchAPI: Bool {
switch self {
case Name, Artist, Year, TrackNumber, TrackCount, DiscNumber, DiscCount, Genre, AlbumName, AlbumArtist, Compilation, ReleaseDate:
return true
case Artwork:
return true
case SortName, SortArtist, SortAlbumName, SortAlbumArtist, Composer, SortComposer, Comment:
return false
}
}
/// Returns a `AEKeyword` associated with the tag. This code can be used to
/// work with Apple-Event properties of the `iTunesTrack` class.
internal var code: AEKeyword {
let keyword: String
switch self {
case Name: keyword = "pnam"
case Artist: keyword = "pArt"
case Year: keyword = "pYr "
case TrackNumber: keyword = "pTrN"
case TrackCount: keyword = "pTrC"
case DiscNumber: keyword = "pDsN"
case DiscCount: keyword = "pDsC"
case Genre: keyword = "pGen"
case AlbumName: keyword = "pAlb"
case AlbumArtist: keyword = "pAlA"
case Compilation: keyword = "pAnt"
case ReleaseDate: keyword = "pRlD"
case Artwork: keyword = "cArt"
case SortName: keyword = "pSNm"
case SortArtist: keyword = "pSAr"
case SortAlbumName: keyword = "pSAl"
case SortAlbumArtist: keyword = "pSAA"
case Composer: keyword = "pCmp"
case SortComposer: keyword = "pSCm"
case Comment: keyword = "pCmt"
}
return AEKeyword(keyword)
}
/// Returns the localized name of the tag which can be displayed in the user
/// interface.
public var localizedName: String {
return NSLocalizedString("Tag: \(self.rawValue)", comment: "")
}
// TODO: Order of tags should not be determined here.
/// Returns an array of all tags. The tags are sorted by whether they are
/// returned by the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public static var allTags: [Tag] {
return [.Name, .Artist, .Year, .TrackNumber, .TrackCount, .DiscNumber, .DiscCount, .Genre, .AlbumName, .AlbumArtist, .Compilation, .ReleaseDate, .Artwork, .SortName, .SortArtist, .SortAlbumName, .SortAlbumArtist, .Composer, .SortComposer, .Comment]
}
}

0
TagTunes/TagTunes-Bridging-Header.h Normal file → Executable file
View File

345
TagTunes/TagTunesItem.swift Executable file
View File

@@ -0,0 +1,345 @@
//
// TagTunesItem.swift
// TagTunes
//
// Created by Kim Wittenburg on 21.01.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
/// This enum holds errors that may occur during saving.
public enum SaveError: ErrorType {
/// This error indicates that the artwork could not be downloaded.
case ArtworkDownloadFailed
/// This constant indicates that there was probably a error downloading the
/// artwork. This is judged by the artwork's resolution.
case LowResArtwork
}
/// A `TagTunesItem` (*item* for short) represents a `SearchAPIEntity` in the
/// TagTunes application. There are two classes that conform to this protocol:
/// `TagTunesEntityItem` (*entity*) and `TagTunesGroupItem` (*group*). The
/// general structure is as follows:
///
/// - A group contains zero or more entities.
/// - An entity can be contained in a group (but doesn't have to).
/// - An entity contains zero or more associated tracks.
public protocol TagTunesItem: class {
/// The `SearchAPIEntity` represented by the item. The `entity` should not
/// change after initialization.
var entity: SearchAPIEntity { get }
/// The `TagTunesTrack`s associated with the item. For groups this is the
/// union of the `associatedTracks` of its children.
var associatedTracks: Set<TagTunesTrack> { get }
/// Adds the specified `tracks` to the item's `associatedTracks`. For goups
/// this sorts the `tracks` into its children. How the tracks are sorted is
/// determined by the concrete group.
///
/// - parameters:
/// - tracks: The `TagTunesTrack`s to be associated with the item.
///
/// - returns: For groups: The tracks that couldn't be sorted.
/// For entities: An empty `Set`.
func addAssociatedTracks<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack>
/// Removes all `associatedTracks` from the item.
func clearAssociatedTracks()
/// Returns whether the item's `associatedTracks` already contain up-to-date
/// tags. This value should respect the user's `Preferences`.
var saved: Bool { get }
}
public func ==(lhs: TagTunesItem, rhs: TagTunesItem) -> Bool {
return lhs.entity == rhs.entity
}
/// Represents an entity item. An entity item directly corresponds to a file (for
/// example a song, a movie, or a podcast episode). An entity may have a parent.
/// An entity's parent does not usually correspond to a file but rather is an
/// abstract grouping criterion (for example a album or a podcast).
///
/// `TagTunesEntityItem` is an abstract class that can not be initialized
/// directly. Instead one of the concrete subclasses (like `SongItem`) should be
/// used.
public class TagTunesEntityItem: TagTunesItem {
public let entity: SearchAPIEntity
/// The entity's parent. The parent may change if the entity is added to a
/// different group. The parent is nil for entities that do not belong to a
/// group (such as movies or apps).
public weak var parentItem: TagTunesGroupItem?
public var associatedTracks = Set<TagTunesTrack>() {
willSet {
for track in associatedTracks {
track.entity = nil
}
}
didSet {
for track in associatedTracks {
track.entity = self
}
}
}
/// Initializes a new `TagTunesEntityItem` with the specified `entity`.
///
/// The class `TagTunesEntityItem` can not be initialized directly. Instead
/// use one of the concrete subclasses like `SongItem`.
public init(entity: SearchAPIEntity) {
self.entity = entity
assert(self.dynamicType != TagTunesEntityItem.self, "TagTunesEntityItem must not be initialized directly.")
}
/// Initializes a new `TagTunesEntityItem` with the specified `entity` and
/// `parentItem`. The newly initialized entity is **not** added to the
/// `parentItem` automatically.
///
/// The class `TagTunesEntityItem` can not be initialized directly. Instead
/// use one of the concrete subclasses like `SongItem`.
public convenience init(entity: SearchAPIEntity, parentItem: TagTunesGroupItem?) {
self.init(entity: entity)
self.parentItem = parentItem
}
public func addAssociatedTracks<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
associatedTracks.unionInPlace(tracks)
return []
}
public func clearAssociatedTracks() {
associatedTracks.removeAll()
}
public var saved: Bool {
fatalError("Must override property saved.")
}
/// Returns the value for the specified `tag`. This method should **not**
/// act depending on the user's preferences.
///
/// - returns: The value for the specified `tag` or `nil` if such a value
/// does not exist.
///
/// - throws: Errors that occur when reading the tag. This may for example
/// happen if an artwork can not be downloaded.
public func valueForTag(tag: Tag) throws -> AnyObject! {
fatalError("Must override valueForTag(_:)")
}
}
/// Represents a group item. A group item is the parent of zero or more entities.
///
/// A group usually does not directly correspond to a specific file but rather is
/// an abstract grouping concept. A group would for example be an album (see
/// class `AlbumItem`) with its children being the songs stored on disk.
///
/// Since groups can have children associated with them it may be neccessary to
/// load those children sometime after the group has been initialized. The
/// `TagTunesGroupItem` class offers the method `beginLoadingChildren()` to deal
/// with those cases. See the documentation on that method for details.
///
/// `TagTunesGroupItem` is an abstract class that can not be initialized
/// directly. Instead one of the concrete subclasses (like `AlbumItem`) should be
/// used.
public class TagTunesGroupItem: TagTunesItem {
/// This notification is posted by `TagTunesGroupItem`s when their state
/// changes. Listening for this notification is the recommended way to react
/// to groups starting or finishing loading their children.
///
/// The `object` associated with the respective notification is the group
/// item whose state changed.
public static let StateChangedNotificationName = "TagTunesGroupItemStateChangedNotificationName"
/// The state of a group item.
public enum LoadingState {
/// The group is currently not active. This state is set before and after
/// a group loads its children.
case Normal
/// The group is currently loading its children.
case Loading(NSURLSessionTask)
/// This state is basically equivalent to the `Normal` state except that
/// indicates that the previous attempt to load the item's children
/// failed.
case Error(ErrorType)
}
/// The URL session used to load children for groups.
private static let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
public let entity: SearchAPIEntity
/// The group's children. It is recommended that subclasses install a
/// property observer on this property and sort the children appropriately.
public internal(set) var children = [TagTunesEntityItem]()
/// The state of the group. This state indicates whether the group is
/// currently loading its children or not. See `LoadingState` for details.
public private(set) var loadingState: LoadingState = .Normal {
didSet {
NSNotificationCenter.defaultCenter().postNotificationName(TagTunesGroupItem.StateChangedNotificationName, object: self)
}
}
/// Creates a new group representing the specified `entity`.
///
/// The class `TagTunesGroupItem` can not be initialized directly. Instead
/// use one of the concrete subclasses like `AlbumItem`.
public init(entity: SearchAPIEntity) {
self.entity = entity
assert(self.dynamicType != TagTunesGroupItem.self, "TagTunesGroupItem must not be initialized directly.")
}
public var associatedTracks: Set<TagTunesTrack> {
return children.reduce(Set()) { $0.union($1.associatedTracks) }
}
public func addAssociatedTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
fatalError("Must override addAssociatedTracks(_:)")
}
/// Sorts the specified `track` into the children of the group. If there is
/// no child representing the specified `childEntity` a new one is created.
///
/// In contrast to the `addAssociatedTracks(_:)` method this one can be
/// called before the group has loaded its children. Tracks associated by
/// this method are preserved when a group loads its children.
///
/// This method is especially useful if you want to associate the specified
/// `track` with the `childEntity` but it is not clear whether the group did
/// already load its children.
///
/// This method must be implemented by subclasses.
public func addAssociatedTrack(track: TagTunesTrack, forChildEntity childEntity: SearchAPIEntity) {
fatalError("Must override addAssociatedTrack:forChildEntity:")
}
public func clearAssociatedTracks() {
for child in children {
child.clearAssociatedTracks()
}
}
/// Determines whether all children have the same artist as the group itself.
///
/// This must be implemented by subclasses.
public var hasCommonArtist: Bool {
fatalError("Must override property hasCommonArtist")
}
/// Starts a network request to load the group's children. This method
/// returns immediately.
///
/// It is possible to monitor the loading of children through the group's
/// `loadingState`. Whenever that state changes a
/// `TagTunesGroupItem.StateChangedNotificationName` notification is posted
/// with the respective group as the notification's `object`.
///
/// Normally this method should only be invoked once per group. A second
/// invocation will still send the network request but the group will not
/// change because its children are already loaded. The only exception to
/// this rule is if the first request failed (as determined by the
/// `LoadingState.Error` case). In that case you can *retry* by invoking this
/// method again.
public func beginLoadingChildren() {
if case .Loading = loadingState {
return
}
var request = SearchAPIRequest(lookupRequestWithID: entity.id)
request.entity = self.dynamicType.childEntityType
request.country = Preferences.sharedPreferences.iTunesStore
if Preferences.sharedPreferences.useEnglishTags {
request.language = .English
}
request.maximumNumberOfResults = 200
let task = TagTunesGroupItem.urlSession.dataTaskWithURL(request.URL) { (data, response, error) -> Void in
if let theError = error {
self.loadingState = .Error(theError)
return
}
do {
let result = try SearchAPIResult(data: data!)
self.processLookupResult(result)
self.loadingState = .Normal
} catch let error as NSError {
self.loadingState = .Error(error)
}
}
loadingState = .Loading(task)
task.resume()
}
/// Returns the `EntityType` of the group's children. This is used to
/// construct an appropriate lookup request.
///
/// This property must be overridden by subclasses.
internal class var childEntityType: SearchAPIRequest.EntityType {
fatalError("Must override property childEntityType")
}
/// Called when the group did finish loading its children. Subclasses must
/// implement this method and should modify the `children` property to
/// represent the `result` of the lookup.
internal func processLookupResult(result: SearchAPIResult) {
fatalError("Must override processLookupResult(_:)")
}
/// Immediately stops loading the group's children and sets the
/// `loadingState` to `Error(NSUserCancelledError)`.
public func cancelLoadingChildren() {
if case let .Loading(task) = loadingState {
task.cancel()
}
let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
loadingState = .Error(error)
}
public var saved: Bool {
for child in children {
if !child.saved {
return false
}
}
return true
}
}
extension TagTunesEntityItem: Hashable {
public var hashValue: Int {
return Int(entity.id)
}
}
extension TagTunesGroupItem: Hashable {
public var hashValue: Int {
return Int(entity.id)
}
}
public func ==(lhs: TagTunesEntityItem, rhs: TagTunesEntityItem) -> Bool {
return lhs.entity.id == rhs.entity.id
}
public func ==(lhs: TagTunesGroupItem, rhs: TagTunesGroupItem) -> Bool {
return lhs.entity.id == rhs.entity.id
}

286
TagTunes/TagTunesTrack.swift Executable file
View File

@@ -0,0 +1,286 @@
//
// Track.swift
// TagTunes
//
// Created by Kim Wittenburg on 17.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import SearchAPI
// TODO: Documentation
public enum TagTunesTrackErrors: ErrorType {
case FileNotFound
case FileNotReadable
case NoIDFound
}
/// Represents a track in TagTunes. A track is directly related to a file. There
/// may be different subclasses of this class representing different kinds of
/// tracks (for example mp3, aac, ...).
///
/// `TagTunesTrack` should be regarded as a protocol. Due to Swift's limitation
/// with associated types this is currently not possible. Nevertheless every the
/// class `TagTunesTrack` can not be used by itself.
public class TagTunesTrack: NSObject, NSSecureCoding {
/// The entity containing the track.
public internal(set) weak var entity: TagTunesEntityItem?
/// The track's lookup state. By default the state is `Unprocessed`. The
/// state is changed by the lookup controller and may be changed on any
/// thread.
public internal(set) var lookupState = LookupState.Unprocessed
// TODO: Change DOcumentation
/// Returns the `id` of the track. The id can be used to query the track on
/// the iTunes Store. If no such id exists, `nil` is returned.
///
/// This property may block the main thread until the id could be fetched.
/// Subclasses may choose to use the `NSProgress` mechanism to report the
/// progress of the fetching of the id. If possible the id should be cached
/// to minimize waiting time for the user.
///
/// This property must be overridden by subclasses.
public private(set) var id: SearchAPIID?
// TODO: Documentation
public func updateTrackID() {
do {
try id = readTrackID()
} catch {
lookupState = LookupState.Unqualified(error)
}
}
// TODO: Documentation
public func readTrackID() throws -> SearchAPIID {
fatalError("Must override readTrackID()")
}
/// Initializes the track. This is the designated initializer of
/// `TagTunesTrack`. Also it is the only initializer that does not crash the
/// programm.
public override init() {
super.init()
}
/// Decodes the track. This method must be implemented by subclasses.
/// Subclasses must not call this method on `super` in their implementation.
/// Instead subclasses should use `super.init()`.
@objc public required init?(coder aDecoder: NSCoder) {
fatalError("Must override init(coder:)")
}
/// Encoded the track. This method must be implemented by subclasses.
@objc public func encodeWithCoder(aCoder: NSCoder) {
fatalError("Must override encodeWithCoder(_:)")
}
@objc public static func supportsSecureCoding() -> Bool {
return true
}
/// Per-tag querying. This method must be implemented by subclasses. It is
/// generally not a good idea to return `nil` from this method.
public func valueForTag(tag: Tag) -> AnyObject! {
fatalError("Must override valueForTag(_:)")
}
/// Reveals the track. Normally this launches another application in which to
/// reveal the track. Which application is launched depends on the actual
/// track.
///
/// This method must be implemented by subclasses.
public func reveal() {
fatalError("Must override reveal()")
}
// TODO: Documentation
public var supportsBatchSaving: Bool {
fatalError("Must override property supportsBatchSaving")
}
// TODO: Remove
/// Saves the track using the data from the specified `entity`. Subclasses
/// may use the `NSProgress` mechanism to report the saving process.
///
/// This method may be overridden by subclasses. It should respect the user's
/// preferences. The default implementation invokes `saveTag(_:value:)` for
/// each tag to be saved.
///
/// - returns: `true` if the `tags` could be saved successfully, `false`
/// otherwise. If `false` is returned the `saveErrors` property
/// should contain information about the errors that occured. If
/// `true` is returned `saveErrors` should be empty.
// public func save(entity: TagTunesEntityItem) -> Bool {
// var success = true
// saveErrors = []
// let progress = NSProgress(totalUnitCount: Int64(Tag.allTags.count))
// for tag in Tag.allTags {
// do {
// switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
// case .Save: try success = success && saveTag(tag, value: entity.valueForTag(tag))
// case .Clear: success = success && saveTag(tag, value: nil)
// case .Ignore: break
// }
// } catch let error {
// saveErrors.append(error)
// }
// dispatch_sync(dispatch_get_main_queue()) {
// progress.completedUnitCount += 1
// }
// }
// return success
// }
// TODO: Documentation
public func saveTags(tags: [Tag: AnyObject?]) throws {
fatalError("Must override saveTags(_:)")
}
// TODO: throws documentation
/// Saves the specified `value` for the specified `tag`. This method should
/// not act depending on the user's `Preferences`.
///
/// This method must be overridden by subclasses.
///
/// - parameters:
/// - value: The value to be saved. If this value is `nil` the value for
/// the specified `tag` should be removed from the track.
/// - tag: The `Tag` to be saved.
public func saveValue(value: AnyObject?, forTag tag: Tag) throws {
fatalError("Must override saveTag(_:value:)")
}
public override var hashValue: Int {
fatalError("Must override property hashValue.")
}
public override func isEqual(object: AnyObject?) -> Bool {
if let other = object as? TagTunesTrack {
return self == other
}
return super.isEqual(object)
}
public override var hash: Int {
return hashValue
}
}
public func ==(lhs: TagTunesTrack, rhs: TagTunesTrack) -> Bool {
if let leftTrack = lhs as? ImportedTrack, rightTrack = rhs as? ImportedTrack {
return leftTrack == rightTrack
}
return lhs === rhs
}
public extension TagTunesTrack {
/// The track's name.
public var name: String? {
return valueForTag(.Name) as? String
}
/// The track's artist.
public var artist: String? {
return valueForTag(.Artist) as? String
}
/// The track's year.
public var year: Int? {
return valueForTag(.Year) as? Int
}
/// The track's track number.
public var trackNumber: Int? {
return valueForTag(.TrackNumber) as? Int
}
/// The track's track count. The track count is respective to the track's
/// `discNumber`.
public var trackCount: Int? {
return valueForTag(.TrackCount) as? Int
}
/// The track's disc number.
public var discNumber: Int? {
return valueForTag(.DiscNumber) as? Int
}
/// The track's disc count.
public var discCount: Int? {
return valueForTag(.DiscCount) as? Int
}
/// The track's genre.
public var genre: String? {
return valueForTag(.Genre) as? String
}
/// The track's album.
public var album: String? {
return valueForTag(.AlbumName) as? String
}
/// The track's album artist.
public var albumArtist: String? {
return valueForTag(.AlbumArtist) as? String
}
/// The track's release date.
public var releaseDate: NSDate? {
return valueForTag(.ReleaseDate) as? NSDate
}
/// A boolean value indicating whether the track belongs to a compilation
/// album.
public var compilation: Bool? {
return valueForTag(.Compilation) as? Bool
}
/// The track's artwork.
public var artwork: NSImage? {
return valueForTag(.Artwork) as? NSImage
}
/// The track's sort name.
public var sortName: String? {
return valueForTag(.SortName) as? String
}
/// The track's sort artist.
public var sortArtist: String? {
return valueForTag(.SortArtist) as? String
}
/// The track's sort album.
public var sortAlbum: String? {
return valueForTag(.SortAlbumName) as? String
}
/// The track's sort album artist.
public var sortAlbumArtist: String? {
return valueForTag(.SortAlbumArtist) as? String
}
/// The track's composer.
public var composer: String? {
return valueForTag(.Composer) as? String
}
/// The track's sort composer.
public var sortComposer: String? {
return valueForTag(.SortComposer) as? String
}
/// The track's comment.
public var comment: String? {
return valueForTag(.Comment) as? String
}
}

View File

@@ -0,0 +1,31 @@
//
// UnsortedTracksController.swift
// TagTunes
//
// Created by Kim Wittenburg on 07.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import Cocoa
/// A unsorted tracks controller manages the unsorted tracks. It is recommended
/// that the controller offers a interface for adding unsorted tracks and for
/// looking up tracks using the app's `LookupController`.
public protocol UnsortedTracksController: class {
/// The visibility of the unsorted tracks controller. Setting this property
/// should show/hide the controller but there's no guarantee that that will
/// actually happen.
var visible: Bool { get set }
/// Adds the specified `tracks` to the controller. This method should filter
/// out tracks that were already added to TagTunes.
func addTracks<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S)
/// Returns the specified `items` from a drag and drop operation to the
/// controller. If this method is called outside a drag and drop operation
/// or the drag did not originate from the unsorted tracks controller this
/// method should just invoke `addTracks(_:)`.
func returnDraggedItems<S: SequenceType where S.Generator.Element == NSPasteboardItem>(items: S)
}

View File

@@ -0,0 +1,265 @@
//
// UnsortedTracksViewController.swift
// TagTunes
//
// Created by Kim Wittenburg on 04.03.16.
// Copyright © 2016 Kim Wittenburg. All rights reserved.
//
import Cocoa
class UnsortedTracksViewController: NSViewController, UnsortedTracksController, NSTableViewDataSource, NSTableViewDelegate {
var unsortedTracks = [TagTunesTrack]()
/// The `NSPasteboardItem`s returned via the ´returnDraggedItems(_:)` method.
/// This property is `nil` unless there is currently a dragging session with
/// `tableView` as its source.
private var returnedDraggedItems: Set<NSPasteboardItem>?
@IBOutlet private weak var tableView: NSTableView!
// MARK: View Controller Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
NSApp.unsortedTracksController = self
tableView.registerForDraggedTypes([TrackPboardType])
}
var visible: Bool {
set {
if newValue {
view.window?.windowController?.showWindow(self)
} else {
view.window?.performClose(self)
}
}
get {
return view.window?.visible ?? false
}
}
// MARK: Actions
/// Adds the current iTunes selection to the unsorted tracks. If iTunes is
/// currently not running a alert message is displayed.
@IBAction func addITunesSelection(sender: AnyObject?) {
if !iTunes.running {
let alert = NSAlert()
alert.messageText = NSLocalizedString("iTunes is not running.", comment: "Error message informing the user that iTunes is not currently running.")
alert.informativeText = NSLocalizedString("Please launch iTunes and try again.", comment: "Informative text for the 'iTunes is not running.' error")
alert.addButtonWithTitle(NSLocalizedString("OK", comment: "Button title"))
alert.beginSheetModalForWindow(view.window!, completionHandler: nil)
} else if let selection = iTunes.selection.get() as? [iTunesTrack] {
addTracks(selection.map(ImportedTrack.init))
}
}
func addTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) {
let newTracks = Array(tracks)
unsortedTracks.appendContentsOf(newTracks)
let newIndexes = NSIndexSet(indexesInRange: NSRange(location: unsortedTracks.count, length: newTracks.count))
tableView.insertRowsAtIndexes(newIndexes, withAnimation: .SlideDown)
}
func returnDraggedItems<S : SequenceType where S.Generator.Element == NSPasteboardItem>(items: S) {
if returnedDraggedItems != nil {
returnedDraggedItems!.unionInPlace(items)
} else {
addTracks(items.flatMap { $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack })
}
}
/// Removes the selected items from the unsorted tracks list.
@IBAction func delete(sender: AnyObject?) {
tableView.beginUpdates()
let indexes = tableView.selectedRowIndexes
var index = indexes.lastIndex
repeat {
unsortedTracks.removeAtIndex(index)
tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .SlideDown)
index = indexes.indexLessThanIndex(index)
} while index != NSNotFound
tableView.endUpdates()
}
/// Reveals the clicked track in iTunes.
@IBAction func revealInITunes(sender: AnyObject?) {
guard tableView.clickedRow >= 0 else {
return
}
let item = unsortedTracks[tableView.clickedRow]
item.reveal()
iTunes.activate()
}
/// Enqueues the clicked tracks for lookup using the app's
/// `LookupController`.
@IBAction func lookupOnITunesMatch(sender: AnyObject?) {
if tableView.selectedRowIndexes.containsIndex(tableView.clickedRow) {
let indexes = Array(tableView.selectedRowIndexes).sort(>)
let tracks = tableView.selectedRowIndexes.map { unsortedTracks[$0] }
LookupQueue.globalQueue.enqueueTracksForLookup(tracks)
for index in indexes {
unsortedTracks.removeAtIndex(index)
}
tableView.removeRowsAtIndexes(tableView.selectedRowIndexes, withAnimation: .SlideLeft)
} else if tableView.clickedRow >= 0 {
LookupQueue.globalQueue.enqueueTracksForLookup([unsortedTracks[tableView.clickedRow]])
unsortedTracks.removeAtIndex(tableView.clickedRow)
tableView.removeRowsAtIndexes(NSIndexSet(index: tableView.clickedRow), withAnimation: .SlideLeft)
}
}
// MARK: Table View
func numberOfRowsInTableView(tableView: NSTableView) -> Int {
return unsortedTracks.count
}
func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
let view = tableView.makeViewWithIdentifier("Cell", owner: nil) as? NSTableCellView
let nameFont = NSFont.labelFontOfSize(13)
if let name = unsortedTracks[row].name {
view?.textField?.stringValue = name
view?.textField?.textColor = NSColor.textColor()
view?.textField?.font = nameFont
} else {
let italicFont = NSFontManager.sharedFontManager().convertFont(nameFont, toHaveTrait: .ItalicFontMask)
view?.textField?.stringValue = NSLocalizedString("Unnamed Track", comment: "Default name for a track that has no name associated with it.")
view?.textField?.textColor = NSColor.disabledControlTextColor()
view?.textField?.font = italicFont
}
return view
}
func tableView(tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableRowActionEdge) -> [NSTableViewRowAction] {
if edge == .Trailing {
return [NSTableViewRowAction(style: .Destructive, title: NSLocalizedString("Remove", comment: "Action title for row in 'Unsorted Tracks' table view.")) { action, index in
self.unsortedTracks.removeAtIndex(index)
self.tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .SlideUp)
}
]
} else {
return [NSTableViewRowAction(style: .Regular, title: NSLocalizedString("Lookup on iTunes Match", comment: "Action title for row in 'Unsorted Tracks' table view.")) {action, index in
LookupQueue.globalQueue.enqueueTracksForLookup([self.unsortedTracks[index]])
self.unsortedTracks.removeAtIndex(index)
self.tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .SlideRight)
}
]
}
}
// MARK: Drag and Drop
func tableView(tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setData(NSKeyedArchiver.archivedDataWithRootObject(unsortedTracks[row]), forType: TrackPboardType)
pasteboardItem.setData(NSKeyedArchiver.archivedDataWithRootObject(row), forType: IndexPboardType)
return pasteboardItem
}
func tableView(tableView: NSTableView, draggingSession session: NSDraggingSession, willBeginAtPoint screenPoint: NSPoint, forRowIndexes rowIndexes: NSIndexSet) {
returnedDraggedItems = []
tableView.hideRowsAtIndexes(rowIndexes, withAnimation: .EffectFade)
}
func tableView(tableView: NSTableView, draggingSession session: NSDraggingSession, endedAtPoint screenPoint: NSPoint, operation: NSDragOperation) {
if let pointInWindow = view.window?.convertRectFromScreen(NSRect(origin: screenPoint, size: NSSize.zero)).origin {
let pointInView = view.window!.contentView!.convertPoint(pointInWindow, fromView: nil)
let targetView = view.window!.contentView!.hitTest(pointInView)
if targetView?.isDescendantOf(tableView) ?? false {
return
}
}
if operation == .None {
tableView.unhideRowsAtIndexes(tableView.hiddenRowIndexes, withAnimation: .EffectFade)
} else if let draggedItems = session.draggingPasteboard.pasteboardItems {
let draggedIndexes = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! Int, $0) }.sort { $0.0 > $1.0 }
tableView.beginUpdates()
for (index, item) in draggedIndexes {
if returnedDraggedItems!.contains(item) {
tableView.unhideRowsAtIndexes(NSIndexSet(index: index), withAnimation: .EffectGap)
} else {
unsortedTracks.removeAtIndex(index)
tableView.removeRowsAtIndexes(NSIndexSet(index: index), withAnimation: .EffectNone)
}
}
tableView.endUpdates()
}
returnedDraggedItems = nil
}
func tableView(tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
guard info.draggingPasteboard().canReadItemWithDataConformingToTypes([TrackPboardType]) else {
return .None
}
tableView.setDropRow(row, dropOperation: .Above)
return .Move
}
func tableView(tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
guard let draggedItems = info.draggingPasteboard().pasteboardItems else {
return false
}
let draggedTracks = draggedItems.map { (index: $0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? Int, track: $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack) }
tableView.beginUpdates()
var insertionIndex = row
if info.draggingSource() === tableView {
let sortedTracks = draggedTracks.sort { $0.index > $1.index }
for (index, _) in sortedTracks {
unsortedTracks.removeAtIndex(index!)
tableView.removeRowsAtIndexes(NSIndexSet(index: index!), withAnimation: .EffectNone)
if insertionIndex > index! {
insertionIndex -= 1
}
}
}
var validDrop = false
for (_, track) in draggedTracks where track != nil {
unsortedTracks.insert(track!, atIndex: insertionIndex)
tableView.insertRowsAtIndexes(NSIndexSet(index: insertionIndex), withAnimation: .EffectGap)
insertionIndex += 1
validDrop = true
}
tableView.endUpdates()
return validDrop
}
}
extension UnsortedTracksViewController: NSUserInterfaceValidations {
func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool {
if anItem.action() == #selector(UnsortedTracksViewController.addITunesSelection(_:)) {
return canAddITunesSelection()
} else if anItem.action() == #selector(UnsortedTracksViewController.delete(_:)) {
return canDelete()
} else if anItem.action() == #selector(UnsortedTracksViewController.lookupOnITunesMatch(_:)) {
return canLookupOnITunesMatch()
} else if anItem.action() == #selector(UnsortedTracksViewController.revealInITunes(_:)) {
return canRevealInITunes()
}
return false
}
private func canAddITunesSelection() -> Bool {
return iTunes.running && !(iTunes.selection.get() as! [AnyObject]).isEmpty
}
private func canDelete() -> Bool {
return tableView.selectedRowIndexes.count > 0
}
private func canLookupOnITunesMatch() -> Bool {
return tableView.clickedRow >= 0
}
private func canRevealInITunes() -> Bool {
return tableView.clickedRow >= 0 && (tableView.selectedRowIndexes.count == 1 || !tableView.selectedRowIndexes.containsIndex(tableView.clickedRow))
}
}

BIN
TagTunes/de.lproj/Localizable.strings Normal file → Executable file

Binary file not shown.

72
TagTunes/de.lproj/Localizable.stringsdict Normal file → Executable file
View File

@@ -1,22 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>%d artworks could not be saved.</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@cover@ konnten nicht gespeichert werden.</string>
<key>artworks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>Ein Cover</string>
<key>other</key>
<string>%d Cover</string>
</dict>
</dict>
</dict>
<dict>
<key>Preparing Lookup for %d Tracks…</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Suche nach %#@tracks@ vorbereiten</string>
<key>tracks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>einem Song</string>
<key>other</key>
<string>%d Songs</string>
</dict>
</dict>
<key>Looking up %d tracks…</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Suchen nach %#@tracks@…</string>
<key>tracks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>einem Song</string>
<key>other</key>
<string>%d Songs</string>
</dict>
</dict>
<key>%d Tracks Pending</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@tracks@ in der Warteschlange.</string>
<key>tracks</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Keine weiteren Songs</string>
<key>one</key>
<string>Ein weiterer Song</string>
<key>other</key>
<string>%d weitere Songs</string>
</dict>
</dict>
</dict>
</plist>

0
TagTunes/iTunes.h Normal file → Executable file
View File

0
TagTunes/iTunes.m Normal file → Executable file
View File

172
TagTunes/iTunes.swift Normal file → Executable file
View File

@@ -7,171 +7,17 @@
//
import Foundation
import AppKitPlus
import SearchAPI
/// The Cocoa Scripting Bridge interface of iTunes.
public let iTunes = iTunesApplication(bundleIdentifier: "com.apple.iTunes")!
/// An ID as returned from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
public typealias iTunesId = UInt
/// A pasteboard type for `TagTunesTrack` objects. This type is used for dragging
/// tracks in TagTunes. It's data should be a single encoded `TagTunesTrack`
/// object.
public let TrackPboardType = "public.item.tagtunestrack"
/// This struct contains static helper objects and methods to work with the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public struct iTunesAPI {
// MARK: Types
/// Error types indicating error responses from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public enum Error: ErrorType {
case InvalidCountryCode
case InvalidLanguageCode
case UnknownError
}
/// Contains constants identifying the fields in a response from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public enum Field: String {
case WrapperType = "wrapperType"
case Kind = "kind"
case TrackId = "trackId"
case TrackName = "trackName"
case TrackCensoredName = "trackCensoredName"
case ArtistName = "artistName"
case ReleaseDate = "releaseDate"
case TrackNumber = "trackNumber"
case TrackCount = "trackCount"
case DiscNumber = "discNumber"
case DiscCount = "discCount"
case PrimaryGenreName = "primaryGenreName"
case CollectionId = "collectionId"
case CollectionName = "collectionName"
case CollectionCensoredName = "collectionCensoredName"
case CollectionViewUrl = "collectionViewUrl"
case CollectionArtistName = "collectionArtistName"
case ArtworkUrl60 = "artworkUrl60"
case ArtworkUrl100 = "artworkUrl100"
}
// MARK: Static Properties and Functions
/// This formatter is configured to be used to parse dates returned from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
internal static let sharedDateFormatter: NSDateFormatter = {
let dateFormatter = NSDateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return dateFormatter
}()
/// Processes the data returned from a request to the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
/// The `data` has to be in a valid JSON format. See `NSJSONSerialization`
/// for details.
///
/// Currently only tracks and albums are supported. If there are any other
/// entries in the specified data this function will raise an exception.
///
/// - throws: Parsing errors and `iTUnesAPI.Error` constants.
/// - returns: An array of albums populated with all associated tracks in the
/// specified data.
public static func parseAPIData(data: NSData) throws -> [Album] {
guard let parsedData = try NSJSONSerialization.JSONObjectWithData(data, options: []) as? [String: AnyObject] else {
throw Error.UnknownError
}
// Handle API Errors
if let errorMessage = parsedData["errorMessage"] as? String {
switch errorMessage {
case "Invalid value(s) for key(s): [country]":
throw Error.InvalidCountryCode
case "Invalid value(s) for key(s): [language]":
throw Error.InvalidLanguageCode
default:
throw Error.UnknownError
}
}
// Parse API Results
var albums = [iTunesId: Album]()
if let results = parsedData["results"] as? [[String: AnyObject]] {
for result in results {
let convertedResult = result.filter({ (key, value) -> Bool in Field(rawValue: key) != nil }).map({ (Field(rawValue: $0)!, $1)
})
let albumId = convertedResult[.CollectionId] as! iTunesId
if albums[albumId] == nil {
albums[albumId] = Album(data: convertedResult)
}
if isTrack(convertedResult) {
albums[albumId]?.addTrack(Track(data: convertedResult))
}
}
}
return Array(albums.values)
}
private static func isTrack(data: [Field: AnyObject]) -> Bool {
return data[.WrapperType] as! String == "track" && data[.Kind] as! String == "song"
}
/// Creates an URL that searches the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
/// for albums matching a specific `term`.
///
/// This function respects the user's preferences (See `Preferences` class).
///
/// - returns: The query URL or `nil` if `term` is invalid.
public static func createAlbumSearchURLForTerm(term: String) -> NSURL? {
var searchTerm = term.stringByReplacingOccurrencesOfString(" ", withString: "+")
searchTerm = searchTerm.stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet())!
if searchTerm.isEmpty {
return nil
}
return NSURL(string: "https://itunes.apple.com/search?term=\(searchTerm)&media=music&entity=album&limit=\(Preferences.sharedPreferences.numberOfSearchResults)&country=\(Preferences.sharedPreferences.iTunesStore)" + (Preferences.sharedPreferences.useEnglishTags ? "&lang=en" : ""))
}
/// Creates an URL that looks up all tracks that belong to the album with the
/// specified `id` in the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
///
/// This function respects the user's preferences (See `Preferences` class).
public static func createAlbumLookupURLForId(id: iTunesId) -> NSURL {
return NSURL(string: "http://itunes.apple.com/lookup?id=\(id)&entity=song&country=\(Preferences.sharedPreferences.iTunesStore)&limit=200" + (Preferences.sharedPreferences.useEnglishTags ? "&lang=en" : ""))!
}
}
/// Defines a type that can be parsed from a result of the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public protocol iTunesType {
/// Initializes the receiver with the specified data. The receiver may use
/// all or only some of the values.
///
/// This method requires `data` to contain the expected formats. If data
/// contains no data or data in an invalid format for an expected key, this
/// method raises an exception. To check wether a specified dictionary is
/// valid use `canInitializeFromData`.
init(data: [iTunesAPI.Field: AnyObject])
/// Returns all fields that are required to initialize an instance of the
/// receiving type.
static var requiredFields: [iTunesAPI.Field] { get }
}
extension iTunesType {
/// Returns wether the specified `data` can be used to initialize a instance
/// of the receiving type.
public static func canInitializeFromData(data: [iTunesAPI.Field: AnyObject]) -> Bool {
for field in requiredFields {
if data[field] == nil {
return false
}
}
return true
}
}
/// A pasteboard type for `Int`s. The specific use of this type is not specified.
/// This pasteboard type should only be used for private purposes (for example to
/// *remember* the original indexes of dragged items in a table view).
public let IndexPboardType = "public.item.index"