diff --git a/Base.lproj/Credits.rtf b/Base.lproj/Credits.rtf old mode 100644 new mode 100755 diff --git a/Changelog.txt b/Changelog.txt old mode 100644 new mode 100755 index 41e14f7..d43b6e9 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,32 +1,51 @@ Version 1.0 -- First Release + - First Release Version 1.1 -Added: -+ 'Try again' option for errors -+ Options for tags not to be saved -+ Option to use censored names -+ Option for case (in)sensitivity -+ Toolbar button to just save the artwork of the selected items + Added: + + 'Try again' option for errors + + Options for tags not to be saved + + Option to use censored names + + Option for case (in)sensitivity + + Toolbar button to just save the artwork of the selected items -Fixed: -* Added support for OS X 10.11 El Capitan -* Error descriptions are now more understandable -* Cancelling the saving process of tracks does not crash the application on slow network connections anymore + Fixed: + * Added support for OS X 10.11 El Capitan + * Error descriptions are now more understandable + * Cancelling the saving process of tracks does not crash the application on slow network connections anymore Version 1.2 -Added: -+ Option for displayed number of search results -+ Option to change the iTunes Store used to get tags -+ Option to remove saved items + Added: + + Option for displayed number of search results + + Option to change the iTunes Store used to get tags + + Option to remove saved items -1.2.1 +Version 1.2.1 -Added: -+ Option to use English tags when using a different iTunes Store + Added: + + Option to use English tags when using a different iTunes Store -Fixed: -* Fixed an issue where TagTunes would sometimes crash when the user selected an album or track in the list \ No newline at end of file + Fixed: + * Fixed an issue where TagTunes would sometimes crash when the user selected an album or track in the list + +Version 2.0 + + Added: + + Updated layout to match the Yosemite/El Capitan Look and Feel + + Search Results in a pop up + + Artworks are saved directly to iTunes + + Unsorted songs are displayed in a designated window + + It is possible to lookup bought and matched songs in the iTunes Store + + Release Date Tag + + Compilation Tag (not always returned by the Search API) + + Removed: + - Some Preferences that are now unneccessary (e.g. the destination for artworks) + + Fixed: + * All tags can now be cleared + * A lot of internal changes + * The saving process not shows correctly \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/TagTunes.xcodeproj/project.pbxproj b/TagTunes.xcodeproj/project.pbxproj old mode 100644 new mode 100755 index edc4463..d0bdf24 --- a/TagTunes.xcodeproj/project.pbxproj +++ b/TagTunes.xcodeproj/project.pbxproj @@ -7,29 +7,46 @@ objects = { /* Begin PBXBuildFile section */ + 3B0DA0091C9B48CA004953C8 /* ImportedTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */; }; + 3B0DA00A1C9B48CA004953C8 /* TagTunesTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */; }; + 3B1A99411C970496008CD6CA /* String+AEKeyword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1A99401C970496008CD6CA /* String+AEKeyword.swift */; }; + 3B2482921CBCECCD003DBC51 /* LookupPreparationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */; }; 3B285DB81B9128C100F0A2F1 /* Preference Controllers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */; }; 3B285DBF1B912AB700F0A2F1 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B285DBE1B912AB700F0A2F1 /* Preferences.swift */; }; - 3B489DBF1B90B055002B7EB3 /* TrackTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */; }; - 3B489DC31B90B116002B7EB3 /* Artwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC01B90B116002B7EB3 /* Artwork.swift */; }; - 3B489DC41B90B116002B7EB3 /* Album.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC11B90B116002B7EB3 /* Album.swift */; }; - 3B489DC51B90B116002B7EB3 /* Track.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC21B90B116002B7EB3 /* Track.swift */; }; + 3B4057CD1CA1AA33004F210E /* ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */; }; + 3B489DBF1B90B055002B7EB3 /* SongTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */; }; + 3B489DC41B90B116002B7EB3 /* AlbumItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC11B90B116002B7EB3 /* AlbumItem.swift */; }; + 3B489DC51B90B116002B7EB3 /* SongItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC21B90B116002B7EB3 /* SongItem.swift */; }; 3B489DC71B90B38C002B7EB3 /* iTunes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DC61B90B38C002B7EB3 /* iTunes.swift */; }; 3B489DCB1B90B3E3002B7EB3 /* iTunes.m in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DCA1B90B3E3002B7EB3 /* iTunes.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; 3B489DD61B90E0D8002B7EB3 /* AlbumTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */; }; + 3B49F84D1CB79B93004E2857 /* LookupOperationTmp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */; }; + 3B49F8501CB7A56A004E2857 /* SaveTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */; }; + 3B49F8521CB7AC43004E2857 /* SaveOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F8511CB7AC43004E2857 /* SaveOperation.swift */; }; + 3B49F85F1CB7FA8B004E2857 /* LookupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */; }; + 3B51AAEB1C89E9A700759F00 /* UnsortedTracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */; }; 3B76C7731B909B280025D550 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C7721B909B280025D550 /* AppDelegate.swift */; }; 3B76C7751B909B280025D550 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C7741B909B280025D550 /* MainViewController.swift */; }; 3B76C77A1B909B280025D550 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3B76C7781B909B280025D550 /* Main.storyboard */; }; 3B76C7851B909B280025D550 /* TagTunesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C7841B909B280025D550 /* TagTunesTests.swift */; }; 3B76C7901B909B280025D550 /* TagTunesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76C78F1B909B280025D550 /* TagTunesUITests.swift */; }; + 3B8546DC1C513BCD00931755 /* SearchAPI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B8546DB1C513BCD00931755 /* SearchAPI.framework */; }; + 3B8546DD1C513BCD00931755 /* SearchAPI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B8546DB1C513BCD00931755 /* SearchAPI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3B8546DF1C5142B600931755 /* TagTunesItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546DE1C5142B600931755 /* TagTunesItem.swift */; }; + 3B8546E11C51685F00931755 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546E01C51685F00931755 /* Tag.swift */; }; + 3B8546E31C51767300931755 /* ContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546E21C51767300931755 /* ContentViewController.swift */; }; + 3B8546E51C517BDE00931755 /* OutlineContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */; }; 3B86A4021BA9E94F00B150AE /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 3B86A4041BA9E94F00B150AE /* Credits.rtf */; }; 3B96BD661B9CA24100CC4101 /* DescriptiveError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */; }; 3B9752721BA85C2F00E26515 /* AppKitPlus.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */; }; 3B9752731BA85C2F00E26515 /* AppKitPlus.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 3BAD17CD1B9F0F6800FEF908 /* AlbumCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD17CC1B9F0F6800FEF908 /* AlbumCollection.swift */; }; - 3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3BB1C97C1BA76F5F0083301F /* Assets.xcassets */; settings = {ASSET_TAGS = (); }; }; - 3BBF6FA01B946B7000BB1EDB /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */; }; - 3BFDED621BA84AD1007E7F36 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3BFDED601BA84AD1007E7F36 /* Localizable.strings */; settings = {ASSET_TAGS = (); }; }; - 3BFDED741BA855B8007E7F36 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3BFDED761BA855B8007E7F36 /* Localizable.stringsdict */; }; + 3BA23C5F1C8F7C4F0027691C /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */; }; + 3BA23C641C8F931F0027691C /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA23C631C8F931F0027691C /* MainWindowController.swift */; }; + 3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3BB1C97C1BA76F5F0083301F /* Assets.xcassets */; }; + 3BE5268F1C8CE75300DDA4E0 /* SearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */; }; + 3BE526941C8D9C7600DDA4E0 /* LookupQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */; }; + 3BE526971C8D9EC700DDA4E0 /* UnsortedTracksController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */; }; + 3BFDED621BA84AD1007E7F36 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3BFDED601BA84AD1007E7F36 /* Localizable.strings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,6 +74,7 @@ dstSubfolderSpec = 10; files = ( 3B9752731BA85C2F00E26515 /* AppKitPlus.framework in Embed Frameworks */, + 3B8546DD1C513BCD00931755 /* SearchAPI.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -64,18 +82,26 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportedTrack.swift; sourceTree = ""; }; + 3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagTunesTrack.swift; sourceTree = ""; }; + 3B1A99401C970496008CD6CA /* String+AEKeyword.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+AEKeyword.swift"; sourceTree = ""; }; + 3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupPreparationOperation.swift; sourceTree = ""; }; 3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Preference Controllers.swift"; sourceTree = ""; }; 3B285DBE1B912AB700F0A2F1 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; - 3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = TrackTableCellView.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 3B489DC01B90B116002B7EB3 /* Artwork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Artwork.swift; sourceTree = ""; }; - 3B489DC11B90B116002B7EB3 /* Album.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = ""; }; - 3B489DC21B90B116002B7EB3 /* Track.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Track.swift; sourceTree = ""; }; + 3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityViewController.swift; sourceTree = ""; }; + 3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SongTableCellView.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 3B489DC11B90B116002B7EB3 /* AlbumItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumItem.swift; sourceTree = ""; }; + 3B489DC21B90B116002B7EB3 /* SongItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SongItem.swift; sourceTree = ""; }; 3B489DC61B90B38C002B7EB3 /* iTunes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iTunes.swift; sourceTree = ""; }; 3B489DC81B90B3E2002B7EB3 /* TagTunes-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "TagTunes-Bridging-Header.h"; sourceTree = ""; }; 3B489DC91B90B3E3002B7EB3 /* iTunes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = iTunes.h; sourceTree = ""; }; 3B489DCA1B90B3E3002B7EB3 /* iTunes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = iTunes.m; sourceTree = ""; }; 3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AlbumTableCellView.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 3B4A0A931BD790CE00EF1BA0 /* de */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = de; path = de.lproj/Main.storyboard; sourceTree = ""; }; + 3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupOperationTmp.swift; sourceTree = ""; }; + 3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveTableCellView.swift; sourceTree = ""; }; + 3B49F8511CB7AC43004E2857 /* SaveOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveOperation.swift; sourceTree = ""; }; + 3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupOperation.swift; sourceTree = ""; }; + 3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsortedTracksViewController.swift; sourceTree = ""; }; 3B76C76F1B909B280025D550 /* TagTunes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TagTunes.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3B76C7721B909B280025D550 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3B76C7741B909B280025D550 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; @@ -87,20 +113,26 @@ 3B76C78B1B909B280025D550 /* TagTunesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TagTunesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B76C78F1B909B280025D550 /* TagTunesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTunesUITests.swift; sourceTree = ""; }; 3B76C7911B909B280025D550 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3B8546DB1C513BCD00931755 /* SearchAPI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = SearchAPI.framework; path = "/Users/Kim/Library/Developer/Xcode/DerivedData/TagTunes-ahlftzbggvvcneeglkkowfbohpzh/Build/Products/Debug/SearchAPI.framework"; sourceTree = ""; }; + 3B8546DE1C5142B600931755 /* TagTunesItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagTunesItem.swift; sourceTree = ""; }; + 3B8546E01C51685F00931755 /* Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; + 3B8546E21C51767300931755 /* ContentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentViewController.swift; sourceTree = ""; }; + 3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutlineContentViewController.swift; sourceTree = ""; }; 3B86A4001BA9E92F00B150AE /* de */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - 3B86A4011BA9E92F00B150AE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = ""; }; 3B86A4031BA9E94F00B150AE /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = Base; path = Base.lproj/Credits.rtf; sourceTree = ""; }; 3B86A4051BA9E95100B150AE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = de; path = de.lproj/Credits.rtf; sourceTree = ""; }; 3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DescriptiveError.swift; sourceTree = ""; }; - 3B97526A1BA85B5A00E26515 /* AppKitPlus.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKitPlus.framework; path = ../AppKitPlus/build/Release/AppKitPlus.framework; sourceTree = ""; }; - 3BAD17CC1B9F0F6800FEF908 /* AlbumCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumCollection.swift; sourceTree = ""; }; + 3BA23C601C8F7C4F0027691C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = Base; path = Base.lproj/Localizable.stringsdict; sourceTree = ""; }; + 3BA23C621C8F7C590027691C /* de */ = {isa = PBXFileReference; explicitFileType = text.plist.xml; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = ""; }; + 3BA23C631C8F931F0027691C /* MainWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; 3BB1C97C1BA76F5F0083301F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3BB8C5421BA2EEE800031021 /* Changelog.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Changelog.txt; sourceTree = ""; }; - 3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; }; 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKitPlus.framework; path = "../../../../Library/Developer/Xcode/DerivedData/TagTunes-ahlftzbggvvcneeglkkowfbohpzh/Build/Products/Debug/AppKitPlus.framework"; sourceTree = ""; }; 3BBF71161B98FB4200BB1EDB /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchController.swift; sourceTree = ""; }; + 3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupQueue.swift; sourceTree = ""; }; + 3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsortedTracksController.swift; sourceTree = ""; }; 3BFDED611BA84AD1007E7F36 /* Base */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; - 3BFDED751BA855B8007E7F36 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = Base; path = Base.lproj/Localizable.stringsdict; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -109,6 +141,7 @@ buildActionMask = 2147483647; files = ( 3B9752721BA85C2F00E26515 /* AppKitPlus.framework in Frameworks */, + 3B8546DC1C513BCD00931755 /* SearchAPI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -134,10 +167,23 @@ children = ( 3B489DC91B90B3E3002B7EB3 /* iTunes.h */, 3B489DCA1B90B3E3002B7EB3 /* iTunes.m */, + 3B1A99401C970496008CD6CA /* String+AEKeyword.swift */, ); name = Ultil; sourceTree = ""; }; + 3B49F84B1CB79B82004E2857 /* App Operations */ = { + isa = PBXGroup; + children = ( + 3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */, + 3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */, + 3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */, + 3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */, + 3B49F8511CB7AC43004E2857 /* SaveOperation.swift */, + ); + name = "App Operations"; + sourceTree = ""; + }; 3B76C7661B909B280025D550 = { isa = PBXGroup; children = ( @@ -168,6 +214,7 @@ 3B76C79D1B909B8C0025D550 /* Model */, 3B76C79F1B909B960025D550 /* View */, 3B76C79E1B909B910025D550 /* Controller */, + 3B49F84B1CB79B82004E2857 /* App Operations */, 3BFDED631BA84ADB007E7F36 /* Resources */, 3B489DC81B90B3E2002B7EB3 /* TagTunes-Bridging-Header.h */, ); @@ -195,14 +242,15 @@ 3B76C79D1B909B8C0025D550 /* Model */ = { isa = PBXGroup; children = ( - 3B489DC11B90B116002B7EB3 /* Album.swift */, - 3BAD17CC1B9F0F6800FEF908 /* AlbumCollection.swift */, - 3B489DC01B90B116002B7EB3 /* Artwork.swift */, + 3B8546E01C51685F00931755 /* Tag.swift */, + 3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */, + 3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */, + 3B8546DE1C5142B600931755 /* TagTunesItem.swift */, + 3B489DC11B90B116002B7EB3 /* AlbumItem.swift */, + 3B489DC21B90B116002B7EB3 /* SongItem.swift */, 3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */, 3B489DC61B90B38C002B7EB3 /* iTunes.swift */, 3B285DBE1B912AB700F0A2F1 /* Preferences.swift */, - 3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */, - 3B489DC21B90B116002B7EB3 /* Track.swift */, ); name = Model; sourceTree = ""; @@ -211,8 +259,15 @@ isa = PBXGroup; children = ( 3B76C7721B909B280025D550 /* AppDelegate.swift */, - 3B76C7741B909B280025D550 /* MainViewController.swift */, 3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */, + 3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */, + 3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */, + 3BA23C631C8F931F0027691C /* MainWindowController.swift */, + 3B76C7741B909B280025D550 /* MainViewController.swift */, + 3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */, + 3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */, + 3B8546E21C51767300931755 /* ContentViewController.swift */, + 3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */, ); name = Controller; sourceTree = ""; @@ -221,7 +276,8 @@ isa = PBXGroup; children = ( 3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */, - 3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */, + 3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */, + 3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */, ); name = View; sourceTree = ""; @@ -229,7 +285,7 @@ 3BBF710C1B95E02E00BB1EDB /* Frameworks */ = { isa = PBXGroup; children = ( - 3B97526A1BA85B5A00E26515 /* AppKitPlus.framework */, + 3B8546DB1C513BCD00931755 /* SearchAPI.framework */, 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */, ); name = Frameworks; @@ -241,9 +297,9 @@ 3B76C7781B909B280025D550 /* Main.storyboard */, 3BB1C97C1BA76F5F0083301F /* Assets.xcassets */, 3BFDED601BA84AD1007E7F36 /* Localizable.strings */, + 3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */, 3B76C77B1B909B280025D550 /* Info.plist */, 3B86A4041BA9E94F00B150AE /* Credits.rtf */, - 3BFDED761BA855B8007E7F36 /* Localizable.stringsdict */, ); name = Resources; sourceTree = ""; @@ -358,7 +414,7 @@ 3B76C77A1B909B280025D550 /* Main.storyboard in Resources */, 3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */, 3B86A4021BA9E94F00B150AE /* Credits.rtf in Resources */, - 3BFDED741BA855B8007E7F36 /* Localizable.stringsdict in Resources */, + 3BA23C5F1C8F7C4F0027691C /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -383,20 +439,35 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3BBF6FA01B946B7000BB1EDB /* SearchResult.swift in Sources */, - 3B489DC31B90B116002B7EB3 /* Artwork.swift in Sources */, + 3B8546E11C51685F00931755 /* Tag.swift in Sources */, + 3B8546E31C51767300931755 /* ContentViewController.swift in Sources */, + 3B49F8501CB7A56A004E2857 /* SaveTableCellView.swift in Sources */, 3B76C7751B909B280025D550 /* MainViewController.swift in Sources */, + 3BE526941C8D9C7600DDA4E0 /* LookupQueue.swift in Sources */, + 3BE526971C8D9EC700DDA4E0 /* UnsortedTracksController.swift in Sources */, 3B76C7731B909B280025D550 /* AppDelegate.swift in Sources */, - 3B489DBF1B90B055002B7EB3 /* TrackTableCellView.swift in Sources */, - 3BAD17CD1B9F0F6800FEF908 /* AlbumCollection.swift in Sources */, + 3B489DBF1B90B055002B7EB3 /* SongTableCellView.swift in Sources */, + 3B4057CD1CA1AA33004F210E /* ActivityViewController.swift in Sources */, + 3BA23C641C8F931F0027691C /* MainWindowController.swift in Sources */, + 3B0DA0091C9B48CA004953C8 /* ImportedTrack.swift in Sources */, 3B285DBF1B912AB700F0A2F1 /* Preferences.swift in Sources */, - 3B489DC41B90B116002B7EB3 /* Album.swift in Sources */, - 3B489DC51B90B116002B7EB3 /* Track.swift in Sources */, + 3B489DC41B90B116002B7EB3 /* AlbumItem.swift in Sources */, + 3B489DC51B90B116002B7EB3 /* SongItem.swift in Sources */, + 3B51AAEB1C89E9A700759F00 /* UnsortedTracksViewController.swift in Sources */, + 3B8546E51C517BDE00931755 /* OutlineContentViewController.swift in Sources */, + 3BE5268F1C8CE75300DDA4E0 /* SearchController.swift in Sources */, 3B489DC71B90B38C002B7EB3 /* iTunes.swift in Sources */, + 3B8546DF1C5142B600931755 /* TagTunesItem.swift in Sources */, 3B285DB81B9128C100F0A2F1 /* Preference Controllers.swift in Sources */, + 3B1A99411C970496008CD6CA /* String+AEKeyword.swift in Sources */, 3B96BD661B9CA24100CC4101 /* DescriptiveError.swift in Sources */, + 3B49F8521CB7AC43004E2857 /* SaveOperation.swift in Sources */, + 3B49F85F1CB7FA8B004E2857 /* LookupOperation.swift in Sources */, 3B489DCB1B90B3E3002B7EB3 /* iTunes.m in Sources */, + 3B0DA00A1C9B48CA004953C8 /* TagTunesTrack.swift in Sources */, 3B489DD61B90E0D8002B7EB3 /* AlbumTableCellView.swift in Sources */, + 3B49F84D1CB79B93004E2857 /* LookupOperationTmp.swift in Sources */, + 3B2482921CBCECCD003DBC51 /* LookupPreparationOperation.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -436,7 +507,6 @@ isa = PBXVariantGroup; children = ( 3B76C7791B909B280025D550 /* Base */, - 3B4A0A931BD790CE00EF1BA0 /* de */, ); name = Main.storyboard; sourceTree = ""; @@ -451,6 +521,15 @@ path = ..; sourceTree = ""; }; + 3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */ = { + isa = PBXVariantGroup; + children = ( + 3BA23C601C8F7C4F0027691C /* Base */, + 3BA23C621C8F7C590027691C /* de */, + ); + name = Localizable.stringsdict; + sourceTree = ""; + }; 3BFDED601BA84AD1007E7F36 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( @@ -460,15 +539,6 @@ name = Localizable.strings; sourceTree = ""; }; - 3BFDED761BA855B8007E7F36 /* Localizable.stringsdict */ = { - isa = PBXVariantGroup; - children = ( - 3BFDED751BA855B8007E7F36 /* Base */, - 3B86A4011BA9E92F00B150AE /* de */, - ); - name = Localizable.stringsdict; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/TagTunes.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/TagTunes.xcodeproj/project.xcworkspace/contents.xcworkspacedata old mode 100644 new mode 100755 diff --git a/TagTunes.xcodeproj/project.xcworkspace/xcshareddata/TagTunes.xcscmblueprint b/TagTunes.xcodeproj/project.xcworkspace/xcshareddata/TagTunes.xcscmblueprint new file mode 100755 index 0000000..47c0f12 --- /dev/null +++ b/TagTunes.xcodeproj/project.xcworkspace/xcshareddata/TagTunes.xcscmblueprint @@ -0,0 +1,30 @@ +{ + "DVTSourceControlWorkspaceBlueprintPrimaryRemoteRepositoryKey" : "F774C9974FDC4186CD9EAC17E9D53637171260A1", + "DVTSourceControlWorkspaceBlueprintWorkingCopyRepositoryLocationsKey" : { + + }, + "DVTSourceControlWorkspaceBlueprintWorkingCopyStatesKey" : { + "1212072DD9F3632F85477B6F74F796972EF39599" : 0, + "F774C9974FDC4186CD9EAC17E9D53637171260A1" : 0 + }, + "DVTSourceControlWorkspaceBlueprintIdentifierKey" : "BC04FE54-1FBA-4CE6-A0A4-AAED36E7734A", + "DVTSourceControlWorkspaceBlueprintWorkingCopyPathsKey" : { + "1212072DD9F3632F85477B6F74F796972EF39599" : "AppKitPlus\/", + "F774C9974FDC4186CD9EAC17E9D53637171260A1" : "TagTunes\/" + }, + "DVTSourceControlWorkspaceBlueprintNameKey" : "TagTunes", + "DVTSourceControlWorkspaceBlueprintVersion" : 204, + "DVTSourceControlWorkspaceBlueprintRelativePathToProjectKey" : "TagTunes.xcodeproj", + "DVTSourceControlWorkspaceBlueprintRemoteRepositoriesKey" : [ + { + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/bitbucket.org\/Codello\/appkitplus.git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "1212072DD9F3632F85477B6F74F796972EF39599" + }, + { + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/bitbucket.org\/Codello\/tagtunes.git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", + "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "F774C9974FDC4186CD9EAC17E9D53637171260A1" + } + ] +} \ No newline at end of file diff --git a/TagTunes.xcodeproj/project.xcworkspace/xcuserdata/Kim.xcuserdatad/UserInterfaceState.xcuserstate b/TagTunes.xcodeproj/project.xcworkspace/xcuserdata/Kim.xcuserdatad/UserInterfaceState.xcuserstate old mode 100644 new mode 100755 diff --git a/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100755 index 0000000..fe2b454 --- /dev/null +++ b/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,5 @@ + + + diff --git a/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcschemes/TagTunes.xcscheme b/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcschemes/TagTunes.xcscheme old mode 100644 new mode 100755 diff --git a/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcschemes/xcschememanagement.plist b/TagTunes.xcodeproj/xcuserdata/Kim.xcuserdatad/xcschemes/xcschememanagement.plist old mode 100644 new mode 100755 diff --git a/TagTunes/ActivityViewController.swift b/TagTunes/ActivityViewController.swift new file mode 100755 index 0000000..25bd1c9 --- /dev/null +++ b/TagTunes/ActivityViewController.swift @@ -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) { + 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 + } + +} \ No newline at end of file diff --git a/TagTunes/AlbumItem.swift b/TagTunes/AlbumItem.swift new file mode 100755 index 0000000..54905d9 --- /dev/null +++ b/TagTunes/AlbumItem.swift @@ -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(tracks: S) -> Set { + var unmatchedTracks = Set() + 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) + } + } + +} \ No newline at end of file diff --git a/TagTunes/AlbumTableCellView.swift b/TagTunes/AlbumTableCellView.swift old mode 100644 new mode 100755 index 49d9df4..16ecee9 --- a/TagTunes/AlbumTableCellView.swift +++ b/TagTunes/AlbumTableCellView.swift @@ -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 } - + } diff --git a/TagTunes/AppDelegate.swift b/TagTunes/AppDelegate.swift old mode 100644 new mode 100755 index 3cdfa83..266c69e --- a/TagTunes/AppDelegate.swift +++ b/TagTunes/AppDelegate.swift @@ -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 + } + } } diff --git a/TagTunes/Artwork.swift b/TagTunes/Artwork.swift deleted file mode 100644 index 3997c4d..0000000 --- a/TagTunes/Artwork.swift +++ /dev/null @@ -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 - } - -} diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/128x128.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/128x128.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/128x128@2x.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/128x128@2x.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/16x16.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/16x16.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/16x16@2x.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/16x16@2x.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/256x256.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/256x256.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/256x256@2x.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/256x256@2x.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/32x32.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/32x32.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/32x32@2x.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/32x32@2x.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/512x512.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/512x512.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/512x512@2x.png b/TagTunes/Assets.xcassets/AppIcon.appiconset/512x512@2x.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/AppIcon.appiconset/Contents.json b/TagTunes/Assets.xcassets/AppIcon.appiconset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Contents.json b/TagTunes/Assets.xcassets/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Cross.imageset/Contents.json b/TagTunes/Assets.xcassets/Cross.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Cross.imageset/Cross.pdf b/TagTunes/Assets.xcassets/Cross.imageset/Cross.pdf old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Note.imageset/Contents.json b/TagTunes/Assets.xcassets/Note.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Note.imageset/Note.pdf b/TagTunes/Assets.xcassets/Note.imageset/Note.pdf old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/PreferenceStore.imageset/Contents.json b/TagTunes/Assets.xcassets/PreferenceStore.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore.png b/TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore.png new file mode 100755 index 0000000..be80440 Binary files /dev/null and b/TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore.png differ diff --git a/TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore@2x.png b/TagTunes/Assets.xcassets/PreferenceStore.imageset/PreferenceStore@2x.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/PreferenceTags.imageset/Contents.json b/TagTunes/Assets.xcassets/PreferenceTags.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags-1.png b/TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags-1.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags-2.png b/TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags-2.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags.png b/TagTunes/Assets.xcassets/PreferenceTags.imageset/PreferencesTags.png old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Save.imageset/Contents.json b/TagTunes/Assets.xcassets/Save.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Save.imageset/Save.pdf b/TagTunes/Assets.xcassets/Save.imageset/Save.pdf old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/SaveArtwork.imageset/Contents.json b/TagTunes/Assets.xcassets/SaveArtwork.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/SaveArtwork.imageset/SaveArtwork.pdf b/TagTunes/Assets.xcassets/SaveArtwork.imageset/SaveArtwork.pdf old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Tick.imageset/Contents.json b/TagTunes/Assets.xcassets/Tick.imageset/Contents.json old mode 100644 new mode 100755 diff --git a/TagTunes/Assets.xcassets/Tick.imageset/Tick.pdf b/TagTunes/Assets.xcassets/Tick.imageset/Tick.pdf old mode 100644 new mode 100755 diff --git a/TagTunes/Base.lproj/Localizable.strings b/TagTunes/Base.lproj/Localizable.strings old mode 100644 new mode 100755 index 76e75eb..fe5109c Binary files a/TagTunes/Base.lproj/Localizable.strings and b/TagTunes/Base.lproj/Localizable.strings differ diff --git a/TagTunes/Base.lproj/Localizable.stringsdict b/TagTunes/Base.lproj/Localizable.stringsdict old mode 100644 new mode 100755 index ffffe14..5d5ea3e --- a/TagTunes/Base.lproj/Localizable.stringsdict +++ b/TagTunes/Base.lproj/Localizable.stringsdict @@ -1,22 +1,56 @@ - + - - %d artworks could not be saved. - - NSStringLocalizedFormatKey - %#@artworks@ could not be saved - artworks - - NSStringFormatSpecTypeKey - NSStringPluralRuleType - NSStringFormatValueTypeKey - d - one - One artwork - other - %d artworks - - - + + Preparing Lookup for %d Tracks… + + NSStringLocalizedFormatKey + Preparing Lookup for %#@tracks@… + tracks + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + One Track + other + %d Tracks + + + Looking up %d tracks… + + NSStringLocalizedFormatKey + Looking up %#@tracks@… + tracks + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + one track + other + %d tracks + + + %d Tracks Pending + + NSStringLocalizedFormatKey + %#@tracks@ Pending + tracks + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + No Tracks + one + One Track + other + %d Tracks + + + diff --git a/TagTunes/Base.lproj/Main.storyboard b/TagTunes/Base.lproj/Main.storyboard old mode 100644 new mode 100755 index 8fd3dc7..54f68e7 --- a/TagTunes/Base.lproj/Main.storyboard +++ b/TagTunes/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + - + + + @@ -68,13 +70,18 @@ - - - + + - + + + + + + + @@ -119,7 +126,7 @@ CA - + @@ -164,6 +171,12 @@ CA + + + + + + @@ -200,54 +213,109 @@ CA - + - - + - + - + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - + + + + + + + + - - - - - - - + + - - - - - - + + + + + + + + + + + + - + @@ -260,6 +328,7 @@ CA + @@ -268,7 +337,7 @@ CA - + @@ -283,7 +352,6 @@ CA - @@ -291,6 +359,7 @@ CA + @@ -298,88 +367,18 @@ CA - + - + - - - - - - - - - - - - - Choose an artwork directory… - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + - - - - - + - + - - - - - - - - - - - - + - - + @@ -485,11 +431,10 @@ CA - + - @@ -504,8 +449,7 @@ CA - - + @@ -513,11 +457,10 @@ CA - + - @@ -529,8 +472,7 @@ CA - - + @@ -538,8 +480,7 @@ CA - - + @@ -555,8 +496,7 @@ CA - - + @@ -576,25 +516,19 @@ CA - - - - - - - + @@ -602,19 +536,18 @@ CA - + - + - - + @@ -648,15 +579,14 @@ CA - + - + - + - @@ -680,7 +610,6 @@ CA - @@ -693,7 +622,6 @@ CA - @@ -720,7 +648,6 @@ CA - @@ -739,67 +666,36 @@ CA - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - - + @@ -810,43 +706,208 @@ CA - + - + - - - - - - - - + + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -861,45 +922,138 @@ CA - - + + + - - - - - - - - - - + + + + - - + - + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -908,7 +1062,5 @@ CA - - diff --git a/TagTunes/ContentViewController.swift b/TagTunes/ContentViewController.swift new file mode 100755 index 0000000..1c78cd0 --- /dev/null +++ b/TagTunes/ContentViewController.swift @@ -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(_:)") + } + +} diff --git a/TagTunes/DescriptiveError.swift b/TagTunes/DescriptiveError.swift old mode 100644 new mode 100755 index 11a315a..f321164 --- a/TagTunes/DescriptiveError.swift +++ b/TagTunes/DescriptiveError.swift @@ -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: diff --git a/TagTunes/ImportedTrack.swift b/TagTunes/ImportedTrack.swift new file mode 100755 index 0000000..0e30b6e --- /dev/null +++ b/TagTunes/ImportedTrack.swift @@ -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() +} \ No newline at end of file diff --git a/TagTunes/Info.plist b/TagTunes/Info.plist old mode 100644 new mode 100755 index b8095dc..f7eae51 --- a/TagTunes/Info.plist +++ b/TagTunes/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.1 + 2.0 CFBundleSignature ???? CFBundleVersion - 37 + 2674 LSApplicationCategoryType public.app-category.music LSMinimumSystemVersion diff --git a/TagTunes/LookupOperation.swift b/TagTunes/LookupOperation.swift new file mode 100755 index 0000000..46f3fd9 --- /dev/null +++ b/TagTunes/LookupOperation.swift @@ -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(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()) + } + + 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?) + } + } + +} \ No newline at end of file diff --git a/TagTunes/LookupOperationTmp.swift b/TagTunes/LookupOperationTmp.swift new file mode 100755 index 0000000..72f7eb0 --- /dev/null +++ b/TagTunes/LookupOperationTmp.swift @@ -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(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() +// } +// } +// +//} \ No newline at end of file diff --git a/TagTunes/LookupPreparationOperation.swift b/TagTunes/LookupPreparationOperation.swift new file mode 100755 index 0000000..1b8b297 --- /dev/null +++ b/TagTunes/LookupPreparationOperation.swift @@ -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() + } + +} diff --git a/TagTunes/LookupQueue.swift b/TagTunes/LookupQueue.swift new file mode 100755 index 0000000..b6b45de --- /dev/null +++ b/TagTunes/LookupQueue.swift @@ -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(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 + } + +} diff --git a/TagTunes/MainViewController.swift b/TagTunes/MainViewController.swift old mode 100644 new mode 100755 index 3bcf30a..c1c8bd9 --- a/TagTunes/MainViewController.swift +++ b/TagTunes/MainViewController.swift @@ -6,988 +6,239 @@ // Copyright © 2015 Kim Wittenburg. All rights reserved. // -import Cocoa +import SearchAPI import AppKitPlus -internal class MainViewController: NSViewController { +class MainViewController: NSViewController, SearchDelegate, LookupQueueDelegate { // MARK: Types - private struct OutlineViewConstants { - struct ViewIdentifiers { - static let simpleTableCellViewIdentifier = "SimpleTableCellViewIdentifier" - static let centeredTableCellViewIdentifier = "CenteredTableCellViewIdentifier" - static let albumTableCellViewIdentifier = "AlbumTableCellViewIdentifier" - static let trackTableCellViewIdentifier = "TrackTableCellViewIdentifier" - } + /// This struct contains *magic numbers* like references to storyboard + /// identifiers. + private struct Constants { + + /// The identifier of the segue that embeds the + /// `OutlineContentViewController` in its parent view. + static let OutlineContentViewControllerEmbedSegueIdentifier = "embedOutlineContentViewController" + + /// The identifier of the segue that embeds the `LookupViewController` in + /// its parent view. + static let LookupViewControllerEmbedSegueIdentifier = "embedLookupViewController" + + /// The space between the lookup panel and its superview when it's + /// visible. + static let LookupPanelExpandedBottomSpace: CGFloat = -4 + + /// The space between the superview and the lookup panel, if it's not + /// visible. + static let LookupPanelCollapsedBottomSpace: CGFloat = -82 + + /// The delay between the lookup controller completing and it being + /// hidden. + static let LookupPanelHideDelay: NSTimeInterval = 2 - struct Items { - static let loadingItem: AnyObject = "LoadingItem" - static let noResultsItem: AnyObject = "NoResultsItem" - - static let searchResultsHeaderItem: AnyObject = MainViewController.Section.SearchResults.rawValue - static let albumsHeaderItem: AnyObject = MainViewController.Section.Albums.rawValue - static let unsortedTracksHeaderItem: AnyObject = MainViewController.Section.UnsortedTracks.rawValue - } - - static let pasteboardType = "public.item.tagtunes" - } - - private struct KVOContexts { - static var preferencesContext = "KVOPreferencesContext" - } - - internal enum Section: String { - case SearchResults = "SearchResults" - case Albums = "Albums" - case UnsortedTracks = "UnsortedTracks" - - static func isHeaderItem(item: AnyObject) -> Bool { - if let itemAsString = item as? String { - return Section(rawValue: itemAsString) != nil - } else { - return false - } - } + /// This constant is used for KVO observations on a `Preferences` + /// instance. + static var PreferencesKVOContext = "PreferencesKVOContext" } - // MARK: IBOutlets - - @IBOutlet private weak var outlineView: NSOutlineView! - // MARK: Properties - /// 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? - - private var searchTerm: String? - - /// If `true` the search section is displayed at the top of the - /// `outlineView`. - internal var showsSearch: Bool = false - - /// `true` if there is currently a search in progress. - internal var searching: Bool { - return searchTask != nil - } - - /// The error that occured during searching, if any. - internal private(set) var searchError: NSError? - + /// The view controller that manages the display of the content. + internal var contentViewController: ContentViewController! + // MARK: View Life Cycle - // Proxy objects that act as `NSNotificationCenter` observers. - private var observerProxies = [NSObjectProtocol]() - - override internal func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() - - let startedLoadingTracksObserver = NSNotificationCenter.defaultCenter().addObserverForName(AlbumCollection.Notifications.albumStartedLoading, object: albumCollection, queue: NSOperationQueue.mainQueue(), usingBlock: albumCollectionDidBeginLoadingTracks) - let finishedLoadingTracksObserver = NSNotificationCenter.defaultCenter().addObserverForName(AlbumCollection.Notifications.albumFinishedLoading, object: albumCollection, queue: NSOperationQueue.mainQueue(), usingBlock: albumCollectionDidFinishLoadingTracks) - observerProxies.append(startedLoadingTracksObserver) - observerProxies.append(finishedLoadingTracksObserver) - - Preferences.sharedPreferences.addObserver(self, forKeyPath: "useCensoredNames", options: [], context: &KVOContexts.preferencesContext) - Preferences.sharedPreferences.addObserver(self, forKeyPath: "caseSensitive", options: [], context: &KVOContexts.preferencesContext) - - outlineView.setDraggingSourceOperationMask(.Move, forLocal: true) - outlineView.registerForDraggedTypes([OutlineViewConstants.pasteboardType]) + Preferences.sharedPreferences.addObserver(self, forKeyPath: "useCensoredNames", options: [], context: &Constants.PreferencesKVOContext) + Preferences.sharedPreferences.addObserver(self, forKeyPath: "caseSensitive", options: [], context: &Constants.PreferencesKVOContext) + } + + override func prepareForSegue(segue: NSStoryboardSegue, sender: AnyObject?) { + if segue.identifier == Constants.OutlineContentViewControllerEmbedSegueIdentifier { + contentViewController = segue.destinationController as? ContentViewController + } } deinit { - for observer in observerProxies { - NSNotificationCenter.defaultCenter().removeObserver(observer) - } + Preferences.sharedPreferences.removeObserver(self, forKeyPath: "useCensoredNames") + Preferences.sharedPreferences.removeObserver(self, forKeyPath: "caseSensitiv") } - // MARK: Outline View Content - - internal private(set) var searchResults = [SearchResult]() - - internal let albumCollection = AlbumCollection() - - internal private(set) var unsortedTracks = [iTunesTrack]() - - /// Returns all `iTunesTrack` objects that are somewhere down the outline - /// view. - private var allITunesTracks: Set { - return Set(unsortedTracks).union(albumCollection.flatMap({ $0.tracks.flatMap { $0.associatedTracks } })) - } - - /// Returns all contents of the outline view. - /// - /// This property is regenerated every time it is queried. If you need to - /// access it a lot of times it is recommended to chache it into a local - /// variable. - private var outlineViewContents: [AnyObject] { - var contents = [AnyObject]() - if showsSearch { - contents.append(OutlineViewConstants.Items.searchResultsHeaderItem) - if !searchResults.isEmpty { - contents.appendContentsOf(searchResults as [AnyObject]) - } else if searching { - contents.append(OutlineViewConstants.Items.loadingItem) - } else if let error = searchError { - contents.append(error) - } else { - contents.append(OutlineViewConstants.Items.noResultsItem) - } - if !albumCollection.isEmpty { - contents.append(OutlineViewConstants.Items.albumsHeaderItem) - } - } - contents.appendContentsOf(albumCollection.albums as [AnyObject]) - if !unsortedTracks.isEmpty { - contents.append(OutlineViewConstants.Items.unsortedTracksHeaderItem) - contents.appendContentsOf(unsortedTracks as [AnyObject]) - } - return contents - } - - /// Returns all selected items. This property removes duplicate items from - /// the returned array (for example a track is not included if the whole - /// album the track belongs to is included itself). - /// - /// This value is not cached. If you need to access this value often, you - /// should consider caching it yourself in a local variable. The order in - /// which the selected objects occur in the returned array is random. - /// - /// - returns: An array of `SearchResult`s, `Album`s, `Track`s and - /// `iTunesTrack`s. - private var selectedItems: [AnyObject] { - var selectedSearchResults = Set() - var selectedAlbums = Set() - var selectedTracks = Set() - var selectedITunesTracks = Set() - - for row in outlineView.selectedRowIndexes { - let item = outlineView.itemAtRow(row) - if let searchResult = item as? SearchResult { - selectedSearchResults.insert(searchResult) - } else if let album = item as? Album { - selectedAlbums.insert(album) - } else if let track = item as? Track { - selectedTracks.insert(track) - } else if let track = item as? iTunesTrack { - selectedITunesTracks.insert(track) - } - } - - for album in selectedAlbums { - for track in album.tracks { - for iTunesTrack in track.associatedTracks { - selectedITunesTracks.remove(iTunesTrack) - } - selectedTracks.remove(track) - } - } - - for track in selectedTracks { - for iTunesTrack in track.associatedTracks { - selectedITunesTracks.remove(iTunesTrack) - } - } - - var selectedItems = [AnyObject]() - selectedItems.appendContentsOf(Array(selectedSearchResults) as [AnyObject]) - selectedItems.appendContentsOf(Array(selectedAlbums) as [AnyObject]) - selectedItems.appendContentsOf(Array(selectedTracks) as [AnyObject]) - selectedItems.appendContentsOf(Array(selectedITunesTracks) as [AnyObject]) - return selectedItems - } - - internal func parentForTrack(track: iTunesTrack) -> Track? { - return outlineView.parentForItem(track) as? Track - } - - internal func containsAlbumForSearchResult(searchResult: SearchResult) -> Bool { - for album in albumCollection { - if album.id == searchResult.id { - return true - } - } - return false - } - - /// Returns the section of the specified row or `nil` if the row is not a - /// valid row. - internal func sectionOfRow(row: Int) -> Section? { - if row < 0 { - return nil - } - var relativeRow = row - if showsSearch { - let searchRelatedItemCount = 1 + (searchResults.isEmpty ? 1 : searchResults.count) - if relativeRow < searchRelatedItemCount { - return .SearchResults - } else { - relativeRow -= searchRelatedItemCount - } - } - var maxRow = outlineView.numberOfRows - if !unsortedTracks.isEmpty { - maxRow -= unsortedTracks.count + 1 - } - if relativeRow < maxRow { - return .Albums - } else { - relativeRow -= maxRow - } - if relativeRow < unsortedTracks.count + 1 { - return .UnsortedTracks - } - return nil - } - - // MARK: Searching - - /// Starts a search for the specified search term. Calling this method - internal func beginSearchForTerm(term: String) { - cancelSearch() - if let url = iTunesAPI.createAlbumSearchURLForTerm(term) { - showsSearch = true - searchTerm = term - searchTask = urlSession.dataTaskWithURL(url, completionHandler: processSearchResults) - searchTask?.resume() - } else { - showsSearch = false - } - outlineView.reloadData() - } - - /// Cancels the current search (if there is one). This also hides the search - /// results. - internal func cancelSearch() { - searchTask?.cancel() - searchResults.removeAll() - showsSearch = false - outlineView.reloadData() - } - - /// Processes the data returned from a network request into the - /// `searchResults`. - private func processSearchResults(data: NSData?, response: NSURLResponse?, var error: NSError?) { - searchTask = nil - if let theData = data where error == nil { - do { - let searchResults = try iTunesAPI.parseAPIData(theData).map { SearchResult(representedAlbum: $0) } - searchTerm = nil - self.searchResults = searchResults - } catch let theError as NSError { - error = theError - } - } - if let theError = error { - searchErrorOccured(theError) - } - showsSearch = true - outlineView.reloadData() - } - - /// Called when an error occurs during searching. - private func searchErrorOccured(error: NSError) { - searchError = error - } - - /// Adds the search result at the specified `row` to the albums section and - /// begins loading its tracks. - internal func selectSearchResultAtRow(row: Int) { - guard sectionOfRow(row) == .SearchResults else { - return - } - let searchResult = outlineView.itemAtRow(row) as! SearchResult - if !Preferences.sharedPreferences.keepSearchResults { - searchResults.removeAll() - showsSearch = false - } - if !containsAlbumForSearchResult(searchResult) { - let album = Album(searchResult: searchResult) - albumCollection.addAlbum(album, beginLoading: true) - } - outlineView.reloadData() - } - - // MARK: Saving - - private func saveItems(items: [AnyObject]) { - let numberOfTracks = numberOfTracksInItems(items) - let progress = NSProgress(totalUnitCount: Int64(numberOfTracks)) - NSProgress.currentProgress()?.localizedDescription = NSLocalizedString("Saving tracks…", comment: "Alert message indicating that the selected tracks are currently being saved") - NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription - - let save: (parentTrack: Track, tracks: [iTunesTrack]) -> Bool = { parentTrack, tracks in - for track in tracks { - if progress.cancelled { - return false - } - parentTrack.saveToTrack(track) - ++progress.completedUnitCount - NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription - } - return !progress.cancelled - } - - for item in items { - if let album = item as? Album { - for parentTrack in album.tracks { - if !save(parentTrack: parentTrack, tracks: parentTrack.associatedTracks) { - return - } - } - } else if let track = item as? Track { - if !save(parentTrack: track, tracks: track.associatedTracks) { - return - } - } else if let track = item as? iTunesTrack { - if let parentTrack = parentForTrack(track) { - if !save(parentTrack: parentTrack, tracks: [track]) { - return - } - } - } - } - - dispatch_sync(dispatch_get_main_queue()) { - if Preferences.sharedPreferences.removeSavedItems { - for item in items { - if let album = item as? Album { - for track in album.tracks { - track.associatedTracks.removeAll() - } - if !Preferences.sharedPreferences.keepSavedAlbums { - self.albumCollection.removeAlbum(album) - } - } else if let track = item as? Track { - track.associatedTracks.removeAll() - } else if let track = item as? iTunesTrack { - if let parentTrack = self.parentForTrack(track) { - parentTrack.associatedTracks.removeElement(track) - } - } - } - self.outlineView.reloadData() - } - } - } - - private func numberOfTracksInItems(items: [AnyObject]) -> Int { - return items.reduce(0) { (count: Int, item: AnyObject) -> Int in - if let album = item as? Album { - return count + album.tracks.reduce(0) { $0 + $1.associatedTracks.count } - } else if let track = item as? Track { - return count + track.associatedTracks.count - } else if let track = item as? iTunesTrack { - return parentForTrack(track) == nil ? count : count + 1 - } else { - return count - } - } - } - - private func saveArtworksForItems(items: [AnyObject]) { - var albums = Set() - for item in items { - if let searchResult = item as? SearchResult { - albums.insert(Album(searchResult: searchResult)) - } else if let album = item as? Album { - albums.insert(album) - } else if let track = item as? Track { - albums.insert(track.album) - } else if let track = item as? iTunesTrack { - if let parentTrack = parentForTrack(track) { - albums.insert(parentTrack.album) - } - } - } - - let progress = NSProgress(totalUnitCount: Int64(albums.count)) - var errorCount = 0 - NSProgress.currentProgress()?.localizedDescription = NSLocalizedString("Saving artworks…", comment: "Alert message indicating that the artworks for the selected tracks are currently being saved") - NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription - NSThread.sleepForTimeInterval(2) - for album in albums { - if progress.cancelled { - return - } - do { - try album.saveArtwork() - } catch _ { - ++errorCount - } - ++progress.completedUnitCount - NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription - } - if errorCount > 0 { - dispatch_sync(dispatch_get_main_queue()) { - let alert = NSAlert() - alert.messageText = String(format: NSLocalizedString("%d artworks could not be saved.", comment: "Error message indicating that n artworks could not be saved."), errorCount) - alert.informativeText = NSLocalizedString("Please check your privileges for the folder you set in the preferences and try again.", comment: "Informative text for 'artwork(s) could not be saved' errors") - alert.alertStyle = .WarningAlertStyle - alert.addButtonWithTitle("OK") - alert.beginSheetModalForWindow(self.view.window!, completionHandler: nil) - } - } - } // MARK: Notifications - private func albumCollectionDidBeginLoadingTracks(notification: NSNotification) { - outlineView.reloadData() + override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer) { + if context == &Constants.PreferencesKVOContext { + contentViewController.updateAllItems() + } } - private func albumCollectionDidFinishLoadingTracks(notification: NSNotification) { - outlineView.reloadData() + + // MARK: Searching + + func searchController(searchController: SearchController, didSelectSearchResult searchResult: TagTunesItem) { + contentViewController.addItem(searchResult) } - override internal func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer) { - if context == &KVOContexts.preferencesContext { - outlineView.reloadData() - } - } - - // MARK: Actions - - /// Adds the current iTunes selection - @IBAction internal 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] { - let newTracks = Set(selection).subtract(allITunesTracks) - unsortedTracks.appendContentsOf(newTracks) - outlineView.reloadData() - } + // MARK: Lookup Panel + + func lookupQueue(lookupQueue: LookupQueue, willBeginLookupForTracks tracks: [TagTunesTrack]) { } - /// Begins to search for the `sender`'s `stringValue`. - @IBAction internal func performSearch(sender: AnyObject?) { - if let searchTerm = sender?.stringValue { - beginSearchForTerm(searchTerm) - } - } - - /// Selects the search result associated with the `sender`'s row (as - /// determined by `NSOutlineView.rowForView`) and adds it to the list of - /// albums. - @IBAction private func selectSearchResult(sender: AnyObject?) { - if let view = sender as? NSView { - let row = outlineView.rowForView(view) - selectSearchResultAtRow(row) - } - } - - /// Saves the selected items to iTunes. The saving process will be reported - /// to the user in a progress sheet. - @IBAction internal func performSave(sender: AnyObject?) { - let selectedItems = self.selectedItems.filter { !($0 is SearchResult) } - let progress = NSProgress(totalUnitCount: 100) - let progressAlert = ProgressAlert(progress: progress) - progressAlert.dismissesWhenCancelled = false - progressAlert.beginSheetModalForWindow(self.view.window!) { - response in - self.outlineView.reloadData() - } - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { - if Preferences.sharedPreferences.saveArtwork { - progress.becomeCurrentWithPendingUnitCount(90) - } else { - progress.becomeCurrentWithPendingUnitCount(100) - } - self.saveItems(selectedItems) - progress.resignCurrent() - if progress.cancelled { - progressAlert.dismissWithResponse(NSModalResponseAbort) - return - } - if Preferences.sharedPreferences.saveArtwork { - progress.becomeCurrentWithPendingUnitCount(10) - self.saveArtworksForItems(selectedItems) - progress.resignCurrent() - if progress.cancelled { - progressAlert.dismissWithResponse(NSModalResponseAbort) - } - } - } - } - - /// Saves the artworks of the selected items to the folder specified in the - /// preferences. If there is no folder specified this method prompts the user - /// to select one. - @IBAction internal func saveArtworks(sender: AnyObject?) { - if Preferences.sharedPreferences.artworkTarget == nil { - let alert = NSAlert() - alert.messageText = NSLocalizedString("There is no folder set to save artworks to.", comment: "Error message informing the user that there is no directory set in the preferences that can be used to save artworks to.") - alert.informativeText = NSLocalizedString("You must select a folder to save artworks to. The folder can be changed in the preferences.", comment: "Informative text for the 'no folder to save artworks to' error.") - alert.addButtonWithTitle(NSLocalizedString("Use Downloads Folder", comment: "Button title offering the user to automatically use the downloads directory instead of manually choosing a directory.")) - alert.addButtonWithTitle(NSLocalizedString("Chose Folder…", comment: "Button title prompting the user to choose a folder.")) - alert.addButtonWithTitle(NSLocalizedString("Cancel", comment: "Button title")) - alert.alertStyle = .WarningAlertStyle - alert.beginSheetModalForWindow(view.window!) { response in - switch response { - case NSAlertFirstButtonReturn: - let downloadsFolder = NSURL.fileURLWithPath(NSFileManager.defaultManager().URLsForDirectory(.DownloadsDirectory, inDomains: .UserDomainMask)[0].filePathURL!.path!, isDirectory: true) - Preferences.sharedPreferences.artworkTarget = downloadsFolder - self.performSaveArtworks() - case NSAlertSecondButtonReturn: - 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(self.view.window!) { response in - if response == NSFileHandlingPanelOKButton { - Preferences.sharedPreferences.artworkTarget = openPanel.URL!.filePathURL! - self.performSaveArtworks() - } - } - case NSAlertThirdButtonReturn: - fallthrough - default: - return - } - } - } else { - performSaveArtworks() - } - } - - /// Actually performs the action for `saveArtworks`. - private func performSaveArtworks() { - let progress = NSProgress(totalUnitCount: 100) - let progressAlert = ProgressAlert(progress: progress) - progressAlert.dismissesWhenCancelled = false - progressAlert.beginSheetModalForWindow(self.view.window!) { - response in - self.outlineView.reloadData() - } - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { - progress.becomeCurrentWithPendingUnitCount(100) - self.saveArtworksForItems(self.selectedItems) - progress.resignCurrent() - if progress.cancelled { - progressAlert.dismissWithResponse(NSModalResponseAbort) - } - } - } - - /// Removes the selected items from the outline view. - @IBAction internal func removeSelectedItems(sender: AnyObject?) { - let items = outlineView.selectedRowIndexes.map { ($0, outlineView.itemAtRow($0)) } - for (row, item) in items { - if sectionOfRow(row)! != .SearchResults { - if let album = item as? Album { - albumCollection.removeAlbum(album) - } else if let track = item as? Track { - track.associatedTracks = [] - } else if let track = item as? iTunesTrack { - if let parentTrack = parentForTrack(track) { - parentTrack.associatedTracks.removeElement(track) - } else { - unsortedTracks.removeElement(track) - } - } - } - } - outlineView.reloadData() - } - - /// 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. - @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 theError = item as? NSError { - error = theError - } else if let album = item as? Album { - if let theError = albumCollection.errorForAlbum(album) { - error = theError + func lookupQueue(lookupQueue: LookupQueue, completedLookupForTracks tracks: [TagTunesTrack]) { + var newAlbumItems = [AlbumItem]() + for track in tracks { + if case .Found(let result) = track.lookupState { + if let album = result.collection.map(contentViewController.itemForEntity) as? AlbumItem { + album.addAssociatedTrack(track, forChildEntity: result) + } else if let collection = result.collection { + let album = AlbumItem(entity: collection) + album.addAssociatedTrack(track, forChildEntity: result) + contentViewController.addItem(album) + newAlbumItems.append(album) } else { - return + fatalError("Lookup returned a song without album.") } + } else { + NSApp.unsortedTracksController.addTracks([track]) // TODO: Show failure reason + } + } + for album in newAlbumItems { + album.beginLoadingChildren() + } + } + + func lookupQueueDidFinishLookup(lookupQueue: LookupQueue) { + } + + // MARK: Saving + + @IBAction func save(sender: AnyObject?) { + var items = [AnyObject]() + for item in contentViewController.selectedItems { + if let group = item as? TagTunesGroupItem { + for child in group.children { + for track in child.associatedTracks { + saveQueue.addOperation(SaveOperation(track: track, entity: child)) + } + } + items.append(group) + } else if let entity = item as? TagTunesEntityItem { + for track in entity.associatedTracks { + saveQueue.addOperation(SaveOperation(track: track, entity: entity)) + } + items.append(entity) + } else if let track = item as? TagTunesTrack { + if let entity = track.entity { + saveQueue.addOperation(SaveOperation(track: track, entity: entity)) + items.append(track) + } + } + } + contentViewController.removeItems(items) + } + + // TODO: Use Operations for this + /// Exports the artwork of the first selected item to a file. This methods + /// presents a `NSSavePanel` so the user can specify where to save the + /// artwork. + @IBAction func exportArtwork(sender: AnyObject?) { + let item: TagTunesItem + let object = contentViewController.selectedItems.first + if let track = object as? TagTunesTrack { + if let theItem = track.entity { + item = theItem } else { return } + } else if let theItem = object as? TagTunesItem { + item = theItem } else { return } - presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil) - } - -} - -// MARK: - Error Handling - -extension MainViewController { - - override internal 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 internal func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int, delegate: AnyObject?, didRecoverSelector: Selector, contextInfo: UnsafeMutablePointer) { - let didRecover = attemptRecoveryFromError(error, optionIndex: recoveryOptionIndex) - delegate?.performSelector(didRecoverSelector, withObject: didRecover, withObject: contextInfo as! AnyObject) - } - - override internal func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int) -> Bool { - if recoveryOptionIndex == 0 { - return true + var album: Album + switch item { + case let songItem as SongItem: + album = songItem.album + case let albumItem as AlbumItem: + album = albumItem.album + default: return } - if let term = searchTerm where error == searchError || error.userInfo[NSUnderlyingErrorKey] === searchError { - beginSearchForTerm(term) - return true - } else { - for album in albumCollection { - let albumError = albumCollection.errorForAlbum(album) - if error == albumError || error.userInfo[NSUnderlyingErrorKey] === albumError { - albumCollection.beginLoadingTracksForAlbum(album) - return true + let savePanel = NSSavePanel() + savePanel.allowedFileTypes = ["jpg"] + savePanel.nameFieldStringValue = Preferences.sharedPreferences.useCensoredNames ? album.censoredName : album.name + savePanel.beginSheetModalForWindow(view.window!) { response -> Void in + if response == NSModalResponseOK { + let bitmapRep = album.artwork.optimalArtworkImageForSize(CGFloat.max)?.representations[0] as? NSBitmapImageRep + guard bitmapRep != nil else { + let alert = NSAlert() + alert.messageText = NSLocalizedString("The artwork could not be saved.", comment: "Error message informing the user that an artwork could not be saved to a file.") + alert.informativeText = NSLocalizedString("Please check your network connection. Also make sure that you have write permissions to the destination you selected.", comment: "Informative text for the 'The artwork could not be saved.' error.") + alert.addButtonWithTitle(NSLocalizedString("OK", comment: "Button title")) + alert.beginSheetModalForWindow(self.view.window!, completionHandler: nil) + return } + let data = bitmapRep!.representationUsingType(.NSJPEGFileType, properties: [:]) + data?.writeToURL(savePanel.URL!, atomically: false) } } - return false } + + // MARK: Other Actions + + /// Removes the selected items from the outline view. + @IBAction internal func delete(sender: AnyObject?) { + self.contentViewController.removeSelectedItems() + } + + /// Shows the currently selected item in the iTunes store. + @IBAction internal func showInITunesStore(sender: AnyObject?) { + if let item = contentViewController.clickedItems.first as? TagTunesItem { + NSWorkspace.sharedWorkspace().openURL(item.entity.viewURL) + } + } + } // MARK: - User Interface Validations extension MainViewController: NSUserInterfaceValidations { - - internal func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool { - if anItem.action() == "performSave:" { - return canSave() - } else if anItem.action() == "saveArtworks:" { - return canSaveArtworks() - } else if anItem.action() == "addITunesSelection:" { - return canAddITunesSelection() - } else if anItem.action() == "removeSelectedItems:" { - return canRemoveSelectedItems() + + func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool { + if anItem.action() == #selector(MainViewController.save(_:)) { + return canSave + } else if anItem.action() == #selector(MainViewController.exportArtwork(_:)) { + return canExportArtworks + } else if anItem.action() == #selector(MainViewController.delete(_:)) { + return canDelete + } else if anItem.action() == #selector(MainViewController.showInITunesStore(_:)) { + return canShowInITunesStore } return false } - private func canSave() -> Bool { - for row in outlineView.selectedRowIndexes { - if sectionOfRow(row) == .Albums { - if let album = outlineView.itemAtRow(row) as? Album { - for track in album.tracks { - if !track.associatedTracks.isEmpty { - return true - } - } - } else { - return true - } - } - } - return false + /// Returns whether it is currently valid to invoke `save(_:)`. + private var canSave: Bool { + return !contentViewController.selectedItems.isEmpty } - private func canSaveArtworks() -> Bool { - for row in outlineView.selectedRowIndexes { - if sectionOfRow(row) != .UnsortedTracks { - return true - } - } - return false + /// Returns whether it is currently valid to invoke `exportArtworks(_:)`. + private var canExportArtworks: Bool { + return contentViewController.selectedItems.count == 1 } - private func canAddITunesSelection() -> Bool { - return iTunes.running && !(iTunes.selection.get() as! [AnyObject]).isEmpty + /// Returns whether it is currently valid to invoke + /// `delete(_:)`. + private var canDelete: Bool { + return !contentViewController.selectedItems.isEmpty } - - private func canRemoveSelectedItems() -> Bool { - for row in outlineView.selectedRowIndexes { - if sectionOfRow(row)! != .SearchResults { - return true - } - } - return false - } - -} - -// MARK: - Outline View - -extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { - - internal func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int { - if item == nil { - return outlineViewContents.count - } else if let album = item as? Album { - return album.tracks.count - } else if let track = item as? Track { - return track.associatedTracks.count - } else { - return 0 - } - } - - internal func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject { - if item == nil { - return outlineViewContents[index] - } else if let album = item as? Album { - return album.tracks[index] - } else if let track = item as? Track { - return track.associatedTracks[index] - } else { - return "" - } - } - - internal func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool { - return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0 - } - - internal func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool { - return Section.isHeaderItem(item) - } - - internal func outlineView(outlineView: NSOutlineView, shouldSelectItem item: AnyObject) -> Bool { - return !(self.outlineView(outlineView, isGroupItem: item) || item === OutlineViewConstants.Items.loadingItem || item === OutlineViewConstants.Items.noResultsItem || item is NSError) - } - - internal func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat { - if item is Album || item is SearchResult { - return 39 - } else if let track = item as? Track { - return track.album.hasSameArtistNameAsTracks ? 24 : 31 - } else if item === OutlineViewConstants.Items.loadingItem { - return 39 - } else if item === OutlineViewConstants.Items.noResultsItem || item is NSError { - return 32 - } else if Section.isHeaderItem(item) { - return 24 - } else { - return 17 - } - } - - internal func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? { - if item === OutlineViewConstants.Items.searchResultsHeaderItem { - 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.boldSystemFontOfSize(0) - view?.textField?.textColor = NSColor.disabledControlTextColor() - view?.textField?.stringValue = NSLocalizedString("Search Results", comment: "Header name for the seach results section") - return view - } - if item === OutlineViewConstants.Items.albumsHeaderItem { - 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.boldSystemFontOfSize(0) - view?.textField?.textColor = NSColor.disabledControlTextColor() - view?.textField?.stringValue = NSLocalizedString("Albums", comment: "Header name for the albums section") - return view - } - if item === OutlineViewConstants.Items.unsortedTracksHeaderItem { - 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.boldSystemFontOfSize(0) - view?.textField?.textColor = NSColor.disabledControlTextColor() - view?.textField?.stringValue = NSLocalizedString("Unsorted Tracks", comment: "Header name for the unsorted tracks section") - return view - } - if item === OutlineViewConstants.Items.loadingItem { - var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView - if view == nil { - view = CenteredTableCellView() - view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier - } - view?.setupForLoading() - return view - } - if item === OutlineViewConstants.Items.noResultsItem { - var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView - if view == nil { - view = CenteredTableCellView() - view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier - } - view?.setupForMessage(NSLocalizedString("No Results", comment: "Message informing the user that the search didn't return any results")) - return view - } - if let error = item as? NSError { - var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView - if view == nil { - view = CenteredTableCellView() - view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier - } - view?.button?.target = self - view?.button?.action = "showErrorDetails:" - view?.setupForError(error, errorMessage: NSLocalizedString("Failed to load results", comment: "Error message informing the user that an error occured during searching.")) - return view - } - if let searchResult = item as? SearchResult { - var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView - if view == nil { - view = AlbumTableCellView() - view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier - } - view?.button.target = self - view?.button.action = "selectSearchResult:" - let selectable = !containsAlbumForSearchResult(searchResult) - view?.setupForSearchResult(searchResult, selectable: selectable) - return view - } - if let album = item as? Album { - var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView - if view == nil { - view = AlbumTableCellView() - view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier - } - view?.setupForAlbum(album, loading: albumCollection.isAlbumLoading(album), error: albumCollection.errorForAlbum(album)) - view?.errorButton?.target = self - view?.errorButton?.action = "showErrorDetails:" - return view - } - if let track = item as? Track { - var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.trackTableCellViewIdentifier, owner: nil) as? TrackTableCellView - if view == nil { - view = TrackTableCellView() - view?.identifier = OutlineViewConstants.ViewIdentifiers.trackTableCellViewIdentifier - } - view?.setupForTrack(track) - return view - } - if let track = item as? iTunesTrack { - 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() - view?.textField?.stringValue = track.name - return view - } - return nil - } - - internal func outlineView(outlineView: NSOutlineView, writeItems items: [AnyObject], toPasteboard pasteboard: NSPasteboard) -> Bool { - var rows = [Int]() - var containsValidItems = false - for item in items { - let row = outlineView.rowForItem(item) - rows.append(row) - if sectionOfRow(row) != .SearchResults { - containsValidItems = true - } - } - if !containsValidItems { - return false - } - let data = NSKeyedArchiver.archivedDataWithRootObject(rows) - pasteboard.declareTypes([OutlineViewConstants.pasteboardType], owner: nil) - pasteboard.setData(data, forType: OutlineViewConstants.pasteboardType) - return true - } - - internal func outlineView(outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: AnyObject?, proposedChildIndex index: Int) -> NSDragOperation { - let firstUnsortedRow = outlineViewContents.count - (unsortedTracks.isEmpty ? 0 : unsortedTracks.count+1) - // Drop in the 'unsorted' section - if item == nil && index >= firstUnsortedRow || item === OutlineViewConstants.Items.unsortedTracksHeaderItem { - outlineView.setDropItem(nil, dropChildIndex: outlineViewContents.count) - return .Every - } - // Drop on iTunesTrack item or between items - if index != NSOutlineViewDropOnItemIndex || item is iTunesTrack { - return .None - } - // Drop on header row - if item != nil && self.outlineView(outlineView, isGroupItem: item!) { - return .None - } - // Drop in 'search results' section - let row = outlineView.rowForItem(item) - if sectionOfRow(row) == .SearchResults { - return .None - } - if let album = item as? Album where albumCollection.isAlbumLoading(album) || albumCollection.errorForAlbum(album) != nil { - return .None - } - return .Every - } - - internal func outlineView(outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: AnyObject?, childIndex index: Int) -> Bool { - guard let data = info.draggingPasteboard().dataForType(OutlineViewConstants.pasteboardType), draggedRows = NSKeyedUnarchiver.unarchiveObjectWithData(data) as? [Int] else { - return false - } - // Get the dragged tracks and remove them from their previous location - var draggedTracks = Set() - for row in draggedRows { - if sectionOfRow(row) != .SearchResults { - let item = outlineView.itemAtRow(row) - if let album = item as? Album { - for track in album.tracks { - draggedTracks.unionInPlace(track.associatedTracks) - track.associatedTracks.removeAll() - } - } else if let track = item as? Track { - draggedTracks.unionInPlace(track.associatedTracks) - track.associatedTracks.removeAll() - } else if let track = item as? iTunesTrack { - draggedTracks.insert(track) - if let parentTrack = parentForTrack(track) { - parentTrack.associatedTracks.removeElement(track) - } else { - unsortedTracks.removeElement(track) - } - } - } - } - - // Add the dragged tracks to the new target - if let targetTrack = item as? Track { - targetTrack.associatedTracks.appendContentsOf(draggedTracks) - } else if let targetAlbum = item as? Album { - for draggedTrack in draggedTracks { - var inserted = false - for track in targetAlbum.tracks { - if (draggedTrack.discNumber == track.discNumber || draggedTrack.discNumber == 0) && draggedTrack.trackNumber == track.trackNumber { - track.associatedTracks.append(draggedTrack) - inserted = true - break - } - } - if !inserted { - unsortedTracks.append(draggedTrack) - } - } - } else { - unsortedTracks.appendContentsOf(draggedTracks) - } - outlineView.reloadData() - return true + + private var canShowInITunesStore: Bool { + return contentViewController.clickedItems.count == 1 && contentViewController.clickedItems.first is TagTunesItem } } diff --git a/TagTunes/MainWindowController.swift b/TagTunes/MainWindowController.swift new file mode 100755 index 0000000..00bc827 --- /dev/null +++ b/TagTunes/MainWindowController.swift @@ -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) + } + } + +} diff --git a/TagTunes/OutlineContentViewController.swift b/TagTunes/OutlineContentViewController.swift new file mode 100755 index 0000000..ad98694 --- /dev/null +++ b/TagTunes/OutlineContentViewController.swift @@ -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() + var selectedEntities = Set() + var selectedTracks = Set() + + 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) { + 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 + } + +} \ No newline at end of file diff --git a/TagTunes/Preference Controllers.swift b/TagTunes/Preference Controllers.swift old mode 100644 new mode 100755 index 3e9ada5..54aa1d1 --- a/TagTunes/Preference Controllers.swift +++ b/TagTunes/Preference Controllers.swift @@ -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: diff --git a/TagTunes/Preferences.swift b/TagTunes/Preferences.swift old mode 100644 new mode 100755 index d73b3c6..2a784fa --- a/TagTunes/Preferences.swift +++ b/TagTunes/Preferences.swift @@ -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)!) } } } diff --git a/TagTunes/SaveOperation.swift b/TagTunes/SaveOperation.swift new file mode 100755 index 0000000..d0446dc --- /dev/null +++ b/TagTunes/SaveOperation.swift @@ -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: 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()) + } + + 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() + } + +} \ No newline at end of file diff --git a/TagTunes/SaveTableCellView.swift b/TagTunes/SaveTableCellView.swift new file mode 100755 index 0000000..713c74e --- /dev/null +++ b/TagTunes/SaveTableCellView.swift @@ -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() + } + +} \ No newline at end of file diff --git a/TagTunes/SearchController.swift b/TagTunes/SearchController.swift new file mode 100755 index 0000000..3bf970d --- /dev/null +++ b/TagTunes/SearchController.swift @@ -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 + } + } + +} diff --git a/TagTunes/SongItem.swift b/TagTunes/SongItem.swift new file mode 100755 index 0000000..b6097ca --- /dev/null +++ b/TagTunes/SongItem.swift @@ -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 + } + } + +} diff --git a/TagTunes/SongTableCellView.swift b/TagTunes/SongTableCellView.swift new file mode 100755 index 0000000..82a7f31 --- /dev/null +++ b/TagTunes/SongTableCellView.swift @@ -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 + } + } + +} diff --git a/TagTunes/String+AEKeyword.swift b/TagTunes/String+AEKeyword.swift new file mode 100755 index 0000000..129e990 --- /dev/null +++ b/TagTunes/String+AEKeyword.swift @@ -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(data.bytes) + for i in 0.. { 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(tracks: S) -> Set + + /// 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() { + 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(tracks: S) -> Set { + 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 { + return children.reduce(Set()) { $0.union($1.associatedTracks) } + } + + public func addAssociatedTracks(tracks: S) -> Set { + 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 +} diff --git a/TagTunes/TagTunesTrack.swift b/TagTunes/TagTunesTrack.swift new file mode 100755 index 0000000..ceea366 --- /dev/null +++ b/TagTunes/TagTunesTrack.swift @@ -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 + } + +} \ No newline at end of file diff --git a/TagTunes/UnsortedTracksController.swift b/TagTunes/UnsortedTracksController.swift new file mode 100755 index 0000000..fafdd98 --- /dev/null +++ b/TagTunes/UnsortedTracksController.swift @@ -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(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(items: S) + +} diff --git a/TagTunes/UnsortedTracksViewController.swift b/TagTunes/UnsortedTracksViewController.swift new file mode 100755 index 0000000..59d459a --- /dev/null +++ b/TagTunes/UnsortedTracksViewController.swift @@ -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? + + @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(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(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)) + } + +} diff --git a/TagTunes/de.lproj/Localizable.strings b/TagTunes/de.lproj/Localizable.strings old mode 100644 new mode 100755 index 677c420..fdac949 Binary files a/TagTunes/de.lproj/Localizable.strings and b/TagTunes/de.lproj/Localizable.strings differ diff --git a/TagTunes/de.lproj/Localizable.stringsdict b/TagTunes/de.lproj/Localizable.stringsdict old mode 100644 new mode 100755 index ecb592e..9edb630 --- a/TagTunes/de.lproj/Localizable.stringsdict +++ b/TagTunes/de.lproj/Localizable.stringsdict @@ -1,22 +1,56 @@ - + - - %d artworks could not be saved. - - NSStringLocalizedFormatKey - %#@cover@ konnten nicht gespeichert werden. - artworks - - NSStringFormatSpecTypeKey - NSStringPluralRuleType - NSStringFormatValueTypeKey - d - one - Ein Cover - other - %d Cover - - - + + Preparing Lookup for %d Tracks… + + NSStringLocalizedFormatKey + Suche nach %#@tracks@ vorbereiten… + tracks + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + einem Song + other + %d Songs + + + Looking up %d tracks… + + NSStringLocalizedFormatKey + Suchen nach %#@tracks@… + tracks + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + einem Song + other + %d Songs + + + %d Tracks Pending + + NSStringLocalizedFormatKey + %#@tracks@ in der Warteschlange. + tracks + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Keine weiteren Songs + one + Ein weiterer Song + other + %d weitere Songs + + + diff --git a/TagTunes/iTunes.h b/TagTunes/iTunes.h old mode 100644 new mode 100755 diff --git a/TagTunes/iTunes.m b/TagTunes/iTunes.m old mode 100644 new mode 100755 diff --git a/TagTunes/iTunes.swift b/TagTunes/iTunes.swift old mode 100644 new mode 100755 index 2ee41bb..6143e48 --- a/TagTunes/iTunes.swift +++ b/TagTunes/iTunes.swift @@ -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" \ No newline at end of file diff --git a/TagTunesTests/Info.plist b/TagTunesTests/Info.plist old mode 100644 new mode 100755 diff --git a/TagTunesTests/TagTunesTests.swift b/TagTunesTests/TagTunesTests.swift old mode 100644 new mode 100755 diff --git a/TagTunesUITests/Info.plist b/TagTunesUITests/Info.plist old mode 100644 new mode 100755 diff --git a/TagTunesUITests/TagTunesUITests.swift b/TagTunesUITests/TagTunesUITests.swift old mode 100644 new mode 100755 diff --git a/de.lproj/Credits.rtf b/de.lproj/Credits.rtf old mode 100644 new mode 100755