Archived
1

43 Commits

Author SHA1 Message Date
Kim Wittenburg
99f412d3aa Update Links for GitLab 2019-10-07 09:41:28 +00:00
Kim Wittenburg
0a485ff42a Stuff… 2019-02-01 22:59:01 +01:00
Kim Wittenburg
ba472864df Replaced much too complicated class based system with simple data structs. 2016-03-03 23:08:26 +01:00
Kim Wittenburg
f3669932df Updated for Version 1.2.1 2015-10-21 11:48:08 +02:00
Kim Wittenburg
cb231a9f0c Updated Translations 2015-10-21 11:45:51 +02:00
Kim Wittenburg
ed5e1083d6 The Save Button is Now Disabled if Only an Album With No Associated Tracks Is Selected 2015-10-21 10:56:18 +02:00
Kim Wittenburg
dfc3be5fd1 Fixed an issue where TagTunes would crash when the user selected an outdated track 2015-10-21 10:40:34 +02:00
Kim Wittenburg
f75c16cb27 Added "Use English Tags" Preference 2015-10-21 10:27:58 +02:00
Kim Wittenburg
98d6d492d0 Added "iTunes Store" Image 2015-10-21 10:08:12 +02:00
Kim Wittenburg
a228f91409 Moved Some Preferences to a new "iTunes Store" Panel 2015-10-21 10:07:41 +02:00
Kim Wittenburg
e152c3da44 Updated project format to Xcode 6.3 compatibility 2015-09-20 14:29:13 +02:00
Kim Wittenburg
1d55cb8c5e Changed version number to 1.2 2015-09-16 21:10:29 +02:00
Kim Wittenburg
7f6ef6588c Added German localization 2015-09-16 21:02:19 +02:00
Kim Wittenburg
e0261f7353 Added Option to automatically remove saved items. 2015-09-16 20:06:45 +02:00
Kim Wittenburg
8d4fba8d8a Added preference for the used iTunes Store. 2015-09-16 19:41:44 +02:00
Kim Wittenburg
548214479a Added option for displayed number of search results 2015-09-15 16:54:22 +02:00
Kim Wittenburg
24bb68f7d8 Updated for version 1.1 2015-09-15 16:32:43 +02:00
Kim Wittenburg
2da74aa359 Added AppKitPlus to embedded binaries 2015-09-15 16:06:31 +02:00
Kim Wittenburg
7dbdc047ae Updated Version number 2015-09-15 15:45:08 +02:00
Kim Wittenburg
c0e7727785 Added pluralization 2015-09-15 15:37:05 +02:00
Kim Wittenburg
7cd60ffed4 Added English localization 2015-09-15 15:26:56 +02:00
Kim Wittenburg
2b668967e2 Added English localization 2015-09-15 15:26:35 +02:00
Kim Wittenburg
e287a935b1 Added option to overwrite existing files when saving artwork 2015-09-15 14:29:57 +02:00
Kim Wittenburg
4548f43797 Improved description for 'low resolution artwork' option 2015-09-15 14:19:24 +02:00
Kim Wittenburg
dcd7d2620d Added option to use lower resolution artworks in TagTunes 2015-09-15 14:17:40 +02:00
Kim Wittenburg
d49ad31b79 Added 'clear Artworks' option. 2015-09-15 14:03:17 +02:00
Kim Wittenburg
bfa0deae4f Removed unnecessary todo 2015-09-15 13:51:33 +02:00
Kim Wittenburg
b694c18daf Added 'Save Artwork' icon 2015-09-15 13:43:47 +02:00
Kim Wittenburg
3a400037ff Added 'Save Artwork' toolbar button 2015-09-15 13:43:18 +02:00
Kim Wittenburg
257a7811d6 Implemented censored names preference
Added case sensitivity preference
Integer tags are not clearable anymore
2015-09-14 00:43:01 +02:00
Kim Wittenburg
e2bdcd21e5 Merge remote-tracking branch 'origin/master' 2015-09-11 16:38:30 +02:00
Kim Wittenburg
5704d0c0e5 Reorganized model for 'Albums' section 2015-09-11 16:38:17 +02:00
Kim Wittenburg
80f177807d Updated for OS X 10.11 and Swift 2
Added more descriptive errors.
2015-09-11 14:45:48 +02:00
Kim Wittenburg
45f664cb10 Added 'Tags' preferences with saving behaviors for each tag 2015-09-04 17:23:45 +02:00
Kim Wittenburg
6bf18c1aae README.md edited online with Bitbucket 2015-09-04 00:36:02 +00:00
Kim Wittenburg
b851b70ddb Optimized image resources. 2015-09-04 02:06:52 +02:00
Kim Wittenburg
2c2252f6af Added NSImage extension for color masked images. 2015-09-04 01:39:22 +02:00
Kim Wittenburg
d1c5e3b98a README.md edited online with Bitbucket 2015-09-03 22:22:50 +00:00
Kim Wittenburg
7a6192d19a Updated README 2015-09-04 00:05:53 +02:00
Kim Wittenburg
5196b6a577 README.md edited online with Bitbucket 2015-09-03 06:18:21 +00:00
Kim Wittenburg
05c7b121a5 Merge remote-tracking branch 'origin/master' 2015-09-03 08:17:01 +02:00
Kim Wittenburg
b778d14ea6 Updated project files 2015-09-03 08:16:31 +02:00
Kim Wittenburg
0bfef01875 README.md edited online with Bitbucket 2015-09-03 06:14:52 +00:00
104 changed files with 8921 additions and 2191 deletions

14
Base.lproj/Credits.rtf Executable file
View File

@@ -0,0 +1,14 @@
{\rtf1\ansi\ansicpg1252\cocoartf1404
{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
{\colortbl;\red255\green255\blue255;}
\paperw12240\paperh15840\margl1440\margr1440\vieww9000\viewh8400\viewkind0
\pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\partightenfactor0
\f0\b\fs24 \cf0 Design and Development
\b0 \
Kim Wittenburg\
\
\b Icon Attributions
\b0 \
The Save Artwork icon was made by {\field{\*\fldinst{HYPERLINK "http://www.freepik.com"}}{\fldrslt Freepik}} from {\field{\*\fldinst{HYPERLINK "http://www.flaticon.com"}}{\fldrslt Flaticon}}. It is licensed under {\field{\*\fldinst{HYPERLINK "http://creativecommons.org/licenses/by/3.0/"}}{\fldrslt CC BY 3.0}}.}

51
Changelog.txt Executable file
View File

@@ -0,0 +1,51 @@
Version 1.0
- 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
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
Version 1.2.1
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
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

59
README.md Executable file
View File

@@ -0,0 +1,59 @@
TagTunes is an application that uses Apple's [Search API][Search API] to get tags for your music files direcly from the iTunes store. With TagTunes you can easily clean your music library using exactly the tags that Apple itself uses.
#[Download TagTunes][Download 1.2.1]
------------------------------------------------------------
## All Versions
### [Version 1.2.1][Download 1.2.1]
This version adds an option to use English tags in every iTunes Store and fixes some issues.
### [Version 1.2][Download 1.2]
This version adds a German translation and a setting to search in a specific iTunes Store.
### [Version 1.1][Download 1.1]
This version adds several new settings as well as support for OS X 10.11 El Capitan. It also improves the overall stability of the app.
### [Version 1.0][Download 1.0]
The first release of TagTunes includes all functions needed to clean your iTunes library.
*For more details for each version, have a look at the changelog.*
------------------------------------------------------------
## Some random questions and answers
#### How do I install TagTunes?
> To install TagTunes [download][Download] the disk image and mount it by double-clicking it in the Finder. Then drag the *TagTunes* icon to any folder you like and run it.
#### How does TagTunes work?
> TagTunes uses Apple's [Search API][Search API] to get its tags.
#### TagTunes does not find an album but iTunes does
> There is a different iTunes Store for each country. By default TagTunes uses a store based on your current system settings. You can change the store used by TagTunes in the preferences.
#### The tags I got from TagTunes differ from the one's in the iTunes Store.
> Unfortunately Apple's Search API does not always return the name results as the iTunes Store does. This mainly affects composers (which aren't returned by the Search API at all) and track titles.
#### I have an iTunes account for a different country
> In the preferences of TagTunes you can set your preferred iTunes store. You may also choose to use English tags although you use metadata from a non-English store.
#### Does TagTunes support other sources than the Search API?
> No, currently TagTunes does not support other sources.
#### Can I contribute to TagTunes?
> Please E-Mail me at [codello@wittenburg.kim][E-Mail]
------------------------------------------------------------
## A note to developers
Currently TagTunes is not released under a open source license. This will most likely change in the future, though. Until then you can freely browse the source code. If you want to incorporate this project in your own, please write me at [dev.kwittenburg@icloud.com][E-Mail].
[Search API]: https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html
[Download 1.2.1]: https://gitlab.com/Codello/tagtunes/uploads/d070455776f6ed87a53f9acf045cc39d/TagTunes_1.2.1.zip
[Download 1.2]: https://gitlab.com/Codello/tagtunes/uploads/9e8190548179287cb4306a659c303fc7/TagTunes_1.2.zip
[Download 1.1]: https://gitlab.com/Codello/tagtunes/uploads/d519a326dc8840153b5c03b3ebce5f2f/TagTunes_1.1.zip
[Download 1.0]:
[E-Mail]: mailto:codello@wittenburg.kim

238
TagTunes.xcodeproj/project.pbxproj Normal file → Executable file
View File

@@ -3,27 +3,50 @@
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objectVersion = 47;
objects = {
/* Begin PBXBuildFile section */
3B285DB81B9128C100F0A2F1 /* PreferencesTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B285DB71B9128C100F0A2F1 /* PreferencesTabViewController.swift */; };
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 */; };
3B76C7771B909B280025D550 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3B76C7761B909B280025D550 /* Assets.xcassets */; };
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 */; };
3BBF6FA01B946B7000BB1EDB /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */; };
3BBF710B1B95E00F00BB1EDB /* AppKitPlus.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */; };
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, ); }; };
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 */
@@ -43,22 +66,45 @@
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
3B9752741BA85C2F00E26515 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
3B9752731BA85C2F00E26515 /* AppKitPlus.framework in Embed Frameworks */,
3B8546DD1C513BCD00931755 /* SearchAPI.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
3B285DB71B9128C100F0A2F1 /* PreferencesTabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesTabViewController.swift; sourceTree = "<group>"; };
3B0DA0071C9B48CA004953C8 /* ImportedTrack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportedTrack.swift; sourceTree = "<group>"; };
3B0DA0081C9B48CA004953C8 /* TagTunesTrack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagTunesTrack.swift; sourceTree = "<group>"; };
3B1A99401C970496008CD6CA /* String+AEKeyword.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+AEKeyword.swift"; sourceTree = "<group>"; };
3B2482911CBCECCD003DBC51 /* LookupPreparationOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupPreparationOperation.swift; sourceTree = "<group>"; };
3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Preference Controllers.swift"; sourceTree = "<group>"; };
3B285DBE1B912AB700F0A2F1 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = TrackTableCellView.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
3B489DC01B90B116002B7EB3 /* Artwork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Artwork.swift; sourceTree = "<group>"; };
3B489DC11B90B116002B7EB3 /* Album.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = "<group>"; };
3B489DC21B90B116002B7EB3 /* Track.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Track.swift; sourceTree = "<group>"; };
3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityViewController.swift; sourceTree = "<group>"; };
3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SongTableCellView.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
3B489DC11B90B116002B7EB3 /* AlbumItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlbumItem.swift; sourceTree = "<group>"; };
3B489DC21B90B116002B7EB3 /* SongItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SongItem.swift; sourceTree = "<group>"; };
3B489DC61B90B38C002B7EB3 /* iTunes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iTunes.swift; sourceTree = "<group>"; };
3B489DC81B90B3E2002B7EB3 /* TagTunes-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "TagTunes-Bridging-Header.h"; sourceTree = "<group>"; };
3B489DC91B90B3E3002B7EB3 /* iTunes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = iTunes.h; sourceTree = "<group>"; };
3B489DCA1B90B3E3002B7EB3 /* iTunes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = iTunes.m; sourceTree = "<group>"; };
3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AlbumTableCellView.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
3B49F84C1CB79B93004E2857 /* LookupOperationTmp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupOperationTmp.swift; sourceTree = "<group>"; };
3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveTableCellView.swift; sourceTree = "<group>"; };
3B49F8511CB7AC43004E2857 /* SaveOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveOperation.swift; sourceTree = "<group>"; };
3B49F85E1CB7FA8B004E2857 /* LookupOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupOperation.swift; sourceTree = "<group>"; };
3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsortedTracksViewController.swift; sourceTree = "<group>"; };
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 = "<group>"; };
3B76C7741B909B280025D550 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
3B76C7761B909B280025D550 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3B76C7791B909B280025D550 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
3B76C77B1B909B280025D550 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
3B76C7801B909B280025D550 /* TagTunesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TagTunesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -67,8 +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 = "<group>"; };
3B76C7911B909B280025D550 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
3BBF6F9F1B946B7000BB1EDB /* SearchResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = "<group>"; };
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 = "<absolute>"; };
3B8546DE1C5142B600931755 /* TagTunesItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagTunesItem.swift; sourceTree = "<group>"; };
3B8546E01C51685F00931755 /* Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
3B8546E21C51767300931755 /* ContentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentViewController.swift; sourceTree = "<group>"; };
3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutlineContentViewController.swift; sourceTree = "<group>"; };
3B86A4001BA9E92F00B150AE /* de */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
3B86A4031BA9E94F00B150AE /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = Base; path = Base.lproj/Credits.rtf; sourceTree = "<group>"; };
3B86A4051BA9E95100B150AE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = de; path = de.lproj/Credits.rtf; sourceTree = "<group>"; };
3B96BD651B9CA24100CC4101 /* DescriptiveError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DescriptiveError.swift; sourceTree = "<group>"; };
3BA23C601C8F7C4F0027691C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = Base; path = Base.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3BA23C621C8F7C590027691C /* de */ = {isa = PBXFileReference; explicitFileType = text.plist.xml; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3BA23C631C8F931F0027691C /* MainWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = "<group>"; };
3BB1C97C1BA76F5F0083301F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3BB8C5421BA2EEE800031021 /* Changelog.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Changelog.txt; sourceTree = "<group>"; };
3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKitPlus.framework; path = "../../../../Library/Developer/Xcode/DerivedData/TagTunes-ahlftzbggvvcneeglkkowfbohpzh/Build/Products/Debug/AppKitPlus.framework"; sourceTree = "<group>"; };
3BBF71161B98FB4200BB1EDB /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchController.swift; sourceTree = "<group>"; };
3BE526921C8D9C7600DDA4E0 /* LookupQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LookupQueue.swift; sourceTree = "<group>"; };
3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsortedTracksController.swift; sourceTree = "<group>"; };
3BFDED611BA84AD1007E7F36 /* Base */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -76,7 +140,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3BBF710B1B95E00F00BB1EDB /* AppKitPlus.framework in Frameworks */,
3B9752721BA85C2F00E26515 /* AppKitPlus.framework in Frameworks */,
3B8546DC1C513BCD00931755 /* SearchAPI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -102,13 +167,28 @@
children = (
3B489DC91B90B3E3002B7EB3 /* iTunes.h */,
3B489DCA1B90B3E3002B7EB3 /* iTunes.m */,
3B1A99401C970496008CD6CA /* String+AEKeyword.swift */,
);
name = Ultil;
sourceTree = "<group>";
};
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 = "<group>";
};
3B76C7661B909B280025D550 = {
isa = PBXGroup;
children = (
3BB8C5421BA2EEE800031021 /* Changelog.txt */,
3BBF71161B98FB4200BB1EDB /* README.md */,
3B76C7711B909B280025D550 /* TagTunes */,
3B76C7831B909B280025D550 /* TagTunesTests */,
3B76C78E1B909B280025D550 /* TagTunesUITests */,
@@ -134,7 +214,8 @@
3B76C79D1B909B8C0025D550 /* Model */,
3B76C79F1B909B960025D550 /* View */,
3B76C79E1B909B910025D550 /* Controller */,
3B76C77B1B909B280025D550 /* Info.plist */,
3B49F84B1CB79B82004E2857 /* App Operations */,
3BFDED631BA84ADB007E7F36 /* Resources */,
3B489DC81B90B3E2002B7EB3 /* TagTunes-Bridging-Header.h */,
);
path = TagTunes;
@@ -161,12 +242,15 @@
3B76C79D1B909B8C0025D550 /* Model */ = {
isa = PBXGroup;
children = (
3B489DC11B90B116002B7EB3 /* Album.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 = "<group>";
@@ -175,8 +259,15 @@
isa = PBXGroup;
children = (
3B76C7721B909B280025D550 /* AppDelegate.swift */,
3B285DB71B9128C100F0A2F1 /* Preference Controllers.swift */,
3BE526961C8D9EC700DDA4E0 /* UnsortedTracksController.swift */,
3B51AAEA1C89E9A700759F00 /* UnsortedTracksViewController.swift */,
3BA23C631C8F931F0027691C /* MainWindowController.swift */,
3B76C7741B909B280025D550 /* MainViewController.swift */,
3B285DB71B9128C100F0A2F1 /* PreferencesTabViewController.swift */,
3BE5268E1C8CE75300DDA4E0 /* SearchController.swift */,
3B4057CC1CA1AA33004F210E /* ActivityViewController.swift */,
3B8546E21C51767300931755 /* ContentViewController.swift */,
3B8546E41C517BDE00931755 /* OutlineContentViewController.swift */,
);
name = Controller;
sourceTree = "<group>";
@@ -184,10 +275,9 @@
3B76C79F1B909B960025D550 /* View */ = {
isa = PBXGroup;
children = (
3B76C7781B909B280025D550 /* Main.storyboard */,
3B76C7761B909B280025D550 /* Assets.xcassets */,
3B489DD51B90E0D8002B7EB3 /* AlbumTableCellView.swift */,
3B489DBD1B90B055002B7EB3 /* TrackTableCellView.swift */,
3B49F84F1CB7A56A004E2857 /* SaveTableCellView.swift */,
3B489DBD1B90B055002B7EB3 /* SongTableCellView.swift */,
);
name = View;
sourceTree = "<group>";
@@ -195,11 +285,25 @@
3BBF710C1B95E02E00BB1EDB /* Frameworks */ = {
isa = PBXGroup;
children = (
3B8546DB1C513BCD00931755 /* SearchAPI.framework */,
3BBF710A1B95E00F00BB1EDB /* AppKitPlus.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
3BFDED631BA84ADB007E7F36 /* Resources */ = {
isa = PBXGroup;
children = (
3B76C7781B909B280025D550 /* Main.storyboard */,
3BB1C97C1BA76F5F0083301F /* Assets.xcassets */,
3BFDED601BA84AD1007E7F36 /* Localizable.strings */,
3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */,
3B76C77B1B909B280025D550 /* Info.plist */,
3B86A4041BA9E94F00B150AE /* Credits.rtf */,
);
name = Resources;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -210,6 +314,7 @@
3B76C76B1B909B280025D550 /* Sources */,
3B76C76C1B909B280025D550 /* Frameworks */,
3B76C76D1B909B280025D550 /* Resources */,
3B9752741BA85C2F00E26515 /* Embed Frameworks */,
);
buildRules = (
);
@@ -280,12 +385,13 @@
};
};
buildConfigurationList = 3B76C76A1B909B280025D550 /* Build configuration list for PBXProject "TagTunes" */;
compatibilityVersion = "Xcode 3.2";
compatibilityVersion = "Xcode 6.3";
developmentRegion = English;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
de,
);
mainGroup = 3B76C7661B909B280025D550;
productRefGroup = 3B76C7701B909B280025D550 /* Products */;
@@ -304,8 +410,11 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3B76C7771B909B280025D550 /* Assets.xcassets in Resources */,
3BFDED621BA84AD1007E7F36 /* Localizable.strings in Resources */,
3B76C77A1B909B280025D550 /* Main.storyboard in Resources */,
3BB1C97D1BA76F5F0083301F /* Assets.xcassets in Resources */,
3B86A4021BA9E94F00B150AE /* Credits.rtf in Resources */,
3BA23C5F1C8F7C4F0027691C /* Localizable.stringsdict in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -330,18 +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 */,
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 */,
3B285DB81B9128C100F0A2F1 /* PreferencesTabViewController.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;
};
@@ -385,6 +511,34 @@
name = Main.storyboard;
sourceTree = "<group>";
};
3B86A4041BA9E94F00B150AE /* Credits.rtf */ = {
isa = PBXVariantGroup;
children = (
3B86A4031BA9E94F00B150AE /* Base */,
3B86A4051BA9E95100B150AE /* de */,
);
name = Credits.rtf;
path = ..;
sourceTree = "<group>";
};
3BA23C611C8F7C4F0027691C /* Localizable.stringsdict */ = {
isa = PBXVariantGroup;
children = (
3BA23C601C8F7C4F0027691C /* Base */,
3BA23C621C8F7C590027691C /* de */,
);
name = Localizable.stringsdict;
sourceTree = "<group>";
};
3BFDED601BA84AD1007E7F36 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
3BFDED611BA84AD1007E7F36 /* Base */,
3B86A4001BA9E92F00B150AE /* de */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -424,7 +578,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.10;
MACOSX_DEPLOYMENT_TARGET = 10.11;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -462,7 +616,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.10;
MACOSX_DEPLOYMENT_TARGET = 10.11;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
};
@@ -474,12 +628,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/TagTunes",
);
INFOPLIST_FILE = TagTunes/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = wittenburg.kim.TagTunes;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "TagTunes/TagTunes-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
@@ -489,11 +649,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/TagTunes",
);
INFOPLIST_FILE = TagTunes/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = wittenburg.kim.TagTunes;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "TagTunes/TagTunes-Bridging-Header.h";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};

0
TagTunes.xcodeproj/project.xcworkspace/contents.xcworkspacedata generated Normal file → Executable file
View File

View File

@@ -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"
}
]
}

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
type = "1"
version = "2.0">
</Bucket>

View File

@@ -5,6 +5,22 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3B76C76E1B909B280025D550"
BuildableName = "TagTunes.app"
BlueprintName = "TagTunes"
ReferencedContainer = "container:TagTunes.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
@@ -12,7 +28,36 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3B76C77F1B909B280025D550"
BuildableName = "TagTunesTests.xctest"
BlueprintName = "TagTunesTests"
ReferencedContainer = "container:TagTunes.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3B76C78A1B909B280025D550"
BuildableName = "TagTunesUITests.xctest"
BlueprintName = "TagTunesUITests"
ReferencedContainer = "container:TagTunes.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3B76C76E1B909B280025D550"
BuildableName = "TagTunes.app"
BlueprintName = "TagTunes"
ReferencedContainer = "container:TagTunes.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
@@ -26,6 +71,16 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3B76C76E1B909B280025D550"
BuildableName = "TagTunes.app"
BlueprintName = "TagTunes"
ReferencedContainer = "container:TagTunes.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
@@ -35,6 +90,16 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3B76C76E1B909B280025D550"
BuildableName = "TagTunes.app"
BlueprintName = "TagTunes"
ReferencedContainer = "container:TagTunes.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

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

View File

@@ -1,147 +0,0 @@
//
// Album.swift
// Tag for iTunes
//
// Created by Kim Wittenburg on 29.05.15.
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
//
import Cocoa
/// Represents an album of the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public class Album: iTunesType {
// MARK: Properties
public let id: iTunesId
public let name: String
public let censoredName: String
public let viewURL: NSURL
public let artwork: Artwork
public let trackCount: Int
public let releaseDate: NSDate
public let genre: String
public let artistName: String
public private(set) var tracks = [Track]()
// MARK: Initializers
internal init(id: iTunesId, name: String, censoredName: String, viewURL: NSURL, artwork: Artwork, trackCount: Int, releaseDate: NSDate, genre: String, artistName: String) {
self.id = id
self.name = name
self.censoredName = censoredName
self.viewURL = viewURL
self.artwork = artwork
self.trackCount = trackCount
self.releaseDate = releaseDate
self.genre = genre
self.artistName = artistName
}
public required init(data: [iTunesAPI.Field : AnyObject]) {
id = data[.CollectionId] as! UInt
name = data[.CollectionName] as! String
censoredName = data[.CollectionCensoredName] as! String
viewURL = NSURL(string: data[.CollectionViewUrl] as! String)!
artwork = Artwork(data: data)
trackCount = data[.TrackCount] as! Int
releaseDate = iTunesAPI.sharedDateFormatter.dateFromString(data[.ReleaseDate] as! String)!
genre = data[.PrimaryGenreName] as! String
if let artistName = data[.CollectionArtistName] as? String {
self.artistName = artistName
} else {
artistName = data[.ArtistName] as! String
}
}
public static var requiredFields: [iTunesAPI.Field] {
return [.CollectionId, .CollectionName, .CollectionCensoredName, .CollectionViewUrl, .TrackCount, .ReleaseDate, .PrimaryGenreName, .ArtistName]
}
// MARK: Methods
/// Adds a track to the album.
internal func addTrack(newTrack: Track) {
newTrack.album = self
var index = 0
for track in tracks {
if newTrack.discNumber < track.discNumber {
break
} else if newTrack.discNumber == track.discNumber {
if newTrack.trackNumber <= track.trackNumber {
break
}
}
++index
}
tracks.insert(newTrack, atIndex: index)
}
/// Returns wether all tracks in the album have the same artist name as the
/// album itself.
public var hasSameArtistNameAsTracks: Bool {
for track in tracks {
if artistName != track.artistName {
return false
}
}
return true
}
/// Saves the album to iTunes.
public func save() {
for track in tracks {
track.save()
}
}
/// Saves the album's artwork to the directory specified in the user's
/// preferences (See `Preferences` for details).
public func saveArtwork() throws {
let url = Preferences.sharedPreferences.artworkTarget
try artwork.saveToURL(url, filename: name)
}
/// Returns `true` if all tracks of the album are saved.
public var saved: Bool {
for track in tracks {
if !track.saved {
return false
}
}
return true
}
}
extension Album: CustomStringConvertible {
public var description: String {
return "\"\(name)\" (\(tracks.count) tracks)"
}
}
extension Album: Hashable {
public var hashValue: Int {
return Int(id)
}
}
public func ==(lhs: Album, rhs: Album) -> Bool {
return lhs === rhs
}

77
TagTunes/AlbumItem.swift Executable file
View File

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

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

@@ -8,9 +8,9 @@
import Cocoa
import AppKitPlus
import SearchAPI
/// A table cell view to represent an `Album`. This view can be initialized using
/// `initWithFrame`.
/// A table cell view to represent an `Album`.
public class AlbumTableCellView: AdvancedTableCellView {
// MARK: Types
@@ -37,7 +37,7 @@ public class AlbumTableCellView: AdvancedTableCellView {
/// Contains the *tick* image for saved albums.
@IBOutlet public lazy var secondaryImageView: NSImageView! = {
let secondaryImageView = NSImageView()
secondaryImageView.image = NSImage(named: "Tick")
secondaryImageView.image = NSImage(named: "Tick")?.imageByMaskingWithColor(NSColor.clearColor())
secondaryImageView.imageScaling = .ScaleProportionallyDown
secondaryImageView.translatesAutoresizingMaskIntoConstraints = false
return secondaryImageView
@@ -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 = album.name
secondaryTextField?.stringValue = album.artistName
asyncImageView.downloadImageFromURL(album.artwork.hiResURL)
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 = 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.hiResURL)
if selectable {
button.title = NSLocalizedString("Select", comment: "Button title for 'selecting a search result'")
button.enabled = true
} else {
button.title = NSLocalizedString("Added", comment: "Button title for a search result that is already present")
button.enabled = false
}
rightAccessoryView = button
secondaryTextField?.stringValue = album.artist.name
textField?.textColor = enabled ? NSColor.textColor() : NSColor.disabledControlTextColor()
secondaryTextField?.textColor = enabled ? NSColor.secondaryLabelColor() : NSColor.disabledControlTextColor()
asyncImageView.downloadImageFromURL(album.artwork.optimalArtworkURLForImageSize(height))
asyncImageView.enabled = enabled
}
}

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

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

View File

@@ -1,106 +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")
try NSFileManager.defaultManager().createDirectoryAtPath(directory, withIntermediateDirectories: true, attributes: nil)
let _ = NSFileManager.defaultManager().createFileAtPath(filePath, contents: hiResImage?.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
}
}

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

View File

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 694 KiB

View File

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

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

@@ -2,22 +2,14 @@
"images" : [
{
"idiom" : "universal",
"filename" : "Cross.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "Cross-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "Cross-2.png",
"scale" : "3x"
"filename" : "Cross.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

View File

@@ -2,7 +2,7 @@
"images" : [
{
"idiom" : "universal",
"filename" : "PauseProgressFreestandingTemplate.pdf"
"filename" : "Note.pdf"
}
],
"info" : {

View File

@@ -0,0 +1,30 @@
%PDF-1.4
%âãÏÓ
1 0 obj <</Filter/FlateDecode/Length 376>>stream
xœÍ“;N1 †ûœÂ'Å<><|$
*
D5Z<>†ëó;™<>Ý¥ƒ
<EFBFBD>~ÄqìÌ1S&ÎbôÈtHCÙa,‡ôšîÓGbúJB7|ƒ—nÓÃc¦§Ë—{öDy[¯rýiK|Ÿ/©!/¾tlïÖºiÝ«—Ë%¯Ér
›Ë"H§Í©wƒ_Q/!…º8"ƒ¤TD‰æk{¹ÙÆ,
„ÏÌ5ƒ×´iÙƒø€$ˆ8% "†rTOE†J
5‡Í}FC©¡iÏ;Àç>®§<C2AE>—hbŽ+õ¸$<07>v¬°GTe5Ø!9¨£É<ýã86!ãŠÂã˜A>Zª5ÆÒU¨i½ <C2BD>VmÈhàšó CªÅ¢\pÍ1 å¨or´wM“Ùc„3<1A>¤È¹P÷´áDáÙhŒMcWÃàZØ$ǹgîðlr×ãÎ"ñblR™'+óè¦UÞ=Îí<07>(r~÷6ìóî}Nw¿ú½þSt|ßnÙÈ
endstream
endobj
3 0 obj<</Contents 1 0 R/Type/Page/Resources<</ProcSet [/PDF /Text /ImageB /ImageC /ImageI]>>/Parent 2 0 R/MediaBox[0 0 1024 1024]>>
endobj
2 0 obj<</Kids[3 0 R]/Type/Pages/Count 1>>
endobj
4 0 obj<</Type/Catalog/Pages 2 0 R>>
endobj
5 0 obj<</ModDate(D:20150903235938Z)/Creator(http://www.fileformat.info/convert/image/svg2pdf.htm)/CreationDate(D:20150903235938Z)/Producer(iText1.2.3 by lowagie.com \(based on itext-paulo-152\))>>
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000598 00000 n
0000000458 00000 n
0000000648 00000 n
0000000692 00000 n
trailer
<</Info 5 0 R/ID [<e234db29b05ecfd32d6259a19dede815><e234db29b05ecfd32d6259a19dede815>]/Root 4 0 R/Size 6>>

View File

@@ -1,29 +0,0 @@
%PDF-1.4
%âãÏÓ
1 0 obj <</Filter/FlateDecode/Length 236>>stream
xœÍ1N1 E{ŸÂ'ˆl''W@¢ ¢@Û­`„fb¹>ÎLⶃ
EJüœïoGÊ®@È$ /.°‚ë¶À ÏðŒ_ ø`Âw»ÅGx9ž<>Ç7¢¾ÿðúSI[Ÿod@q<>Y%äTC­ˆñR<>Ëf<C38B>™CÕ-¾<>J 5oTÔâ´WYÒÑM;övû}$
”G9g£i·n”S}úP#Ñgör;ˆØÝW¤C÷¤p3<70>45ÁRÔÖ¡ó2Xµ´ñ—¡¿g×Ï<C397>K5q{+:»C×ßóÁñž~õ«þ“º­o«P˜K
endstream
endobj
3 0 obj<</Contents 1 0 R/Type/Page/Resources<</ProcSet [/PDF /Text /ImageB /ImageC /ImageI]>>/Parent 2 0 R/MediaBox[0 0 1024 1024]>>
endobj
2 0 obj<</Kids[3 0 R]/Type/Pages/Count 1>>
endobj
4 0 obj<</Type/Catalog/Pages 2 0 R>>
endobj
5 0 obj<</ModDate(D:20150902100931Z)/Creator(http://www.fileformat.info/convert/image/svg2pdf.htm)/CreationDate(D:20150902100931Z)/Producer(iText1.2.3 by lowagie.com \(based on itext-paulo-152\))>>
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000458 00000 n
0000000318 00000 n
0000000508 00000 n
0000000552 00000 n
trailer
<</Info 5 0 R/ID [<ae74877cb59949374c065f7f1deb2d92><ae74877cb59949374c065f7f1deb2d92>]/Root 4 0 R/Size 6>>
startxref
757
%%EOF

View File

@@ -2,17 +2,16 @@
"images" : [
{
"idiom" : "universal",
"filename" : "iTunes-2.png",
"filename" : "PreferenceStore.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "iTunes-1.png",
"filename" : "PreferenceStore@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "iTunes.png",
"scale" : "3x"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

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

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

View File

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "PreferencesTags.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "PreferencesTags-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "PreferencesTags-2.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Save.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View File

@@ -0,0 +1,29 @@
%PDF-1.4
%âãÏÓ
1 0 obj <</Filter/FlateDecode/Length 321>>stream
­“;N1 †{ŸÂÀ²<C380>Ø“\‰b+
<1A> áú8™É<¨(ÐJ£|~üòk?á…5ã{<gè°=úg†W¸üÅÛ| /ÞÁÃ#ãSHd%•!SÐTGæJMp‰Ú_'í0y>J$?J­‰ž¯“D«Ÿñë¬&*X<58>fH¹—…Ršˆ‡+™ ¬Ù[ÅWXÙÝÈ[]f<>IwjÒî,­žáu¯ÇàkžzðŠÂ1D·BcÎǺ¯Ñ”F|ÖmHaA<>aøÖÿ ɉ “QjúbJÙ±P6”I(+Þ01³(ª¦fÞø
E¸ImŠKëm³”Ð0lj¼`‰2rÅ”É,×ÝhXµTã>ÁS±>âha>öÓvö —åvöÅïû=,ý|
Ë®/¿¯n?®ÃÅ<C383>Oñ?2ÿþg¹ôß=K§=
endstream
endobj
3 0 obj<</Contents 1 0 R/Type/Page/Resources<</ProcSet [/PDF /Text /ImageB /ImageC /ImageI]>>/Parent 2 0 R/MediaBox[0 0 1024 1024]>>
endobj
2 0 obj<</Kids[3 0 R]/Type/Pages/Count 1>>
endobj
4 0 obj<</Type/Catalog/Pages 2 0 R>>
endobj
5 0 obj<</ModDate(D:20150904000250Z)/Creator(http://www.fileformat.info/convert/image/svg2pdf.htm)/CreationDate(D:20150904000250Z)/Producer(iText1.2.3 by lowagie.com \(based on itext-paulo-152\))>>
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000543 00000 n
0000000403 00000 n
0000000593 00000 n
0000000637 00000 n
trailer
<</Info 5 0 R/ID [<e10888a855614e5c858d394112bdd7fb><e10888a855614e5c858d394112bdd7fb>]/Root 4 0 R/Size 6>>
startxref
842

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "SaveArtwork.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "SaveToITunes.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "SaveToITunes-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "SaveToITunes-2.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Tick.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

4125
TagTunes/Assets.xcassets/Tick.imageset/Tick.pdf vendored Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "TickBW.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "TickBW-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "TickBW-2.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,11 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"data" : [
{
"idiom" : "universal"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

64
TagTunes/DescriptiveError.swift Executable file
View File

@@ -0,0 +1,64 @@
//
// DescriptiveError.swift
// TagTunes
//
// Created by Kim Wittenburg on 06.09.15.
// Copyright © 2015 Kim Wittenburg. All rights reserved.
//
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".
public class DescriptiveError: NSError {
/// Initializes the receiver with the specified `underlyingError`.
/// The underlying error is added to the receiver's `userInfo` dictionary as
/// is the specified dictionary.
public init(underlyingError: NSError, userInfo: [NSString: AnyObject]?) {
var actualUserInfo = userInfo ?? [NSString: AnyObject]()
actualUserInfo[NSUnderlyingErrorKey] = underlyingError
super.init(domain: underlyingError.domain, code: underlyingError.code, userInfo: actualUserInfo)
}
public required init?(coder aDecoder: NSCoder) {
fatalError("DescriptiveError instances should not be encoded.")
}
/// Returns the value for the NSUnderlyingErrorKey in the error's `userInfo`
/// dictionary as an `NSError` instance.
public var underlyingError: NSError {
return userInfo[NSUnderlyingErrorKey] as! 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:
return NSLocalizedString("The network request timed out.", comment: "Error message informing the user that the network request timed out.")
default: break
}
}
return super.localizedDescription
}
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:
return NSLocalizedString("Please check your network connection and try again. If that does not help it may be possible that Apple's Search API service is currently offline. Please try again later.", comment: "Error recovery suggestion for 'time out' error.")
default: break
}
}
return super.localizedRecoverySuggestion
}
}

View File

@@ -1,13 +0,0 @@
//
// Error Handler.swift
// TagTunes
//
// Created by Kim Wittenburg on 30.08.15.
// Copyright © 2015 Kim Wittenburg. All rights reserved.
//
import Cocoa
class Error_Handler: T {
}

91
TagTunes/ImportedTrack.swift Executable file
View File

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

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

@@ -17,15 +17,28 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>2.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>4242</string>
<string>2674</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>mzstatic.com</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2015 Kim Wittenburg. All rights reserved.</string>
<key>NSMainStoryboardFile</key>

73
TagTunes/LookupOperation.swift Executable file
View File

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

141
TagTunes/LookupOperationTmp.swift Executable file
View File

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

View File

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

181
TagTunes/LookupQueue.swift Executable file
View File

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

861
TagTunes/MainViewController.swift Normal file → Executable file
View File

@@ -6,748 +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"
}
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 view controller that manages the display of the content.
internal var contentViewController: ContentViewController!
// MARK: View Life Cycle
/// The URL task currently loading the search results
private var searchTask: NSURLSessionDataTask?
/// 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 URL tasks currently loading the tracks for the respective albums.
private var trackTasks = [Album: NSURLSessionDataTask]()
/// Errors that occured during loading the tracks for the respective album.
private var trackErrors = [Album: NSError]()
// MARK: Overrides
override internal func viewDidLoad() {
override func viewDidLoad() {
super.viewDidLoad()
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)
}
// MARK: Outline View Content
internal private(set) var searchResults = [SearchResult]()
internal private(set) var albums = [Album]()
internal private(set) var unsortedTracks = [iTunesTrack]()
/// Returns all `iTunesTrack` objects that are somewhere down the outline
/// view.
private var allITunesTracks: Set<iTunesTrack> {
return Set(unsortedTracks).union(albums.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.extend(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 !albums.isEmpty {
contents.append(OutlineViewConstants.Items.albumsHeaderItem)
}
}
contents.extend(albums as [AnyObject])
if !unsortedTracks.isEmpty {
contents.append(OutlineViewConstants.Items.unsortedTracksHeaderItem)
contents.extend(unsortedTracks as [AnyObject])
}
return contents
}
/// 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
}
/// Returns the section the specified item resides in or `nil` if the item is
/// not part of the outline view's contents.
internal func sectionOfItem(item: AnyObject) -> Section? {
if let album = item as? Album where albums.contains(album) {
return .Albums
} else if let track = item as? Track where albums.contains(track.album) {
return .Albums
} else if let track = item as? iTunesTrack {
if let parentTrack = outlineView.parentForItem(track) as? Track where albums.contains(parentTrack.album) {
return .Albums
} else {
return unsortedTracks.contains(track) ? .UnsortedTracks : nil
}
} else if item === OutlineViewConstants.Items.loadingItem || item === OutlineViewConstants.Items.noResultsItem || item is NSError {
return .SearchResults
} else if let string = item as? String {
return Section(rawValue: string)
} else {
return nil
override func prepareForSegue(segue: NSStoryboardSegue, sender: AnyObject?) {
if segue.identifier == Constants.OutlineContentViewControllerEmbedSegueIdentifier {
contentViewController = segue.destinationController as? ContentViewController
}
}
/// Returns `true` if the specified `album` is currently loading its tracks.
internal func isAlbumLoading(album: Album) -> Bool {
return trackTasks[album] != nil
deinit {
Preferences.sharedPreferences.removeObserver(self, forKeyPath: "useCensoredNames")
Preferences.sharedPreferences.removeObserver(self, forKeyPath: "caseSensitiv")
}
// MARK: Notifications
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
if context == &Constants.PreferencesKVOContext {
contentViewController.updateAllItems()
}
}
// MARK: Searching
/// Starts a search for the specified search term. Calling this method
internal func beginSearchForTerm(term: String) {
searchTask?.cancel()
searchResults.removeAll()
if let url = iTunesAPI.createAlbumSearchURLForTerm(term) {
showsSearch = true
searchTask = urlSession.dataTaskWithURL(url, completionHandler: processSearchResults)
searchTask?.resume()
} else {
showsSearch = false
}
outlineView.reloadData()
func searchController(searchController: SearchController, didSelectSearchResult searchResult: TagTunesItem) {
contentViewController.addItem(searchResult)
}
/// Processes the data returned from a network request into the
/// `searchResults`.
private func processSearchResults(data: NSData?, response: NSURLResponse?, error: NSError?) {
searchTask = nil
if let theError = error {
searchError = theError
} else if let theData = data {
do {
let searchResults = try iTunesAPI.parseAPIData(theData).map { SearchResult(representedAlbum: $0) }
self.searchResults = searchResults
} catch let error as NSError {
searchError = error
}
}
showsSearch = true
outlineView.reloadData()
// MARK: Lookup Panel
func lookupQueue(lookupQueue: LookupQueue, willBeginLookupForTracks tracks: [TagTunesTrack]) {
}
/// 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
}
var albumAlreadyPresent = false
for album in albums {
if album == searchResult {
albumAlreadyPresent = true
}
}
if !albumAlreadyPresent {
albums.append(beginLoadingTracksForSearchResult(searchResult))
}
outlineView.reloadData()
}
// MARK: Albums
private func beginLoadingTracksForSearchResult(searchResult: SearchResult) -> Album {
let album = Album(searchResult: searchResult)
let url = iTunesAPI.createAlbumLookupURLForId(album.id)
let task = urlSession.dataTaskWithURL(url) { (data, response, var error) -> Void in
self.trackTasks[album] = nil
do {
if let theData = data {
let newAlbum = try iTunesAPI.parseAPIData(theData)[0]
let index = self.albums.indexOf(album)!
self.albums.removeAtIndex(index)
self.albums.insert(newAlbum, atIndex: index)
}
} catch let theError as NSError {
error = theError
} catch _ {
// Will never happen
}
self.trackErrors[album] = error
self.outlineView.reloadData()
}
trackTasks[album] = task
task.resume()
return album
}
func cancelLoadingTracksForAlbum(album: Album) {
trackTasks[album]?.cancel()
trackTasks[album] = nil
}
private func saveTracks(tracks: [Track: [iTunesTrack]]) {
let numberOfTracks = tracks.reduce(0) { (count: Int, element: (key: Track, value: [iTunesTrack])) -> Int in
return count + element.value.count
}
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
for (parentTrack, targetTracks) in tracks {
for track in targetTracks {
parentTrack.saveToTrack(track)
++progress.completedUnitCount
NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription
}
}
}
private func saveArtworks(tracks: [Track: [iTunesTrack]]) {
var albums = Set<Album>()
for (track, _) in tracks {
albums.insert(track.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
for album in albums {
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()
if errorCount == 1 {
alert.messageText = NSLocalizedString("1 artwork could not be saved.", comment: "Error message indicating that one of the artworks could not be saved.")
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 {
alert.messageText = String(format: NSLocalizedString("%d artworks could not be saved.", comment: "Error message indicating that n artworks could not be saved."), errorCount)
fatalError("Lookup returned a song without album.")
}
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: 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.extend(newTracks)
outlineView.reloadData()
}
}
/// 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?) {
var itemsToBeSaved = [Track: [iTunesTrack]]()
for row in outlineView.selectedRowIndexes where sectionOfRow(row) == .Albums {
let item = outlineView.itemAtRow(row)
if let album = item as? Album {
for track in album.tracks where !track.associatedTracks.isEmpty {
itemsToBeSaved[track] = track.associatedTracks
}
} else if let track = item as? Track {
itemsToBeSaved[track] = track.associatedTracks
} else if let track = item as? iTunesTrack {
if let parentTrack = outlineView.parentForItem(track) as? Track {
if itemsToBeSaved[parentTrack] != nil {
itemsToBeSaved[parentTrack]?.append(track)
} else {
itemsToBeSaved[parentTrack] = [track]
}
}
}
}
guard !itemsToBeSaved.isEmpty else {
return
}
let progress = NSProgress(totalUnitCount: 100)
progress.beginProgressSheetModalForWindow(self.view.window!) {
reponse 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.saveTracks(itemsToBeSaved)
progress.resignCurrent()
if Preferences.sharedPreferences.saveArtwork {
progress.becomeCurrentWithPendingUnitCount(10)
self.saveArtworks(itemsToBeSaved)
progress.resignCurrent()
NSApp.unsortedTracksController.addTracks([track]) // TODO: Show failure reason
}
}
for album in newAlbumItems {
album.beginLoadingChildren()
}
}
/// Removes the selected items from the outline view.
@IBAction internal func delete(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 {
cancelLoadingTracksForAlbum(album)
albums.removeElement(album)
} else if let track = item as? Track {
track.associatedTracks = []
} else if let track = item as? iTunesTrack {
if let parentTrack = outlineView.parentForItem(track) as? Track {
parentTrack.associatedTracks.removeElement(track)
} else {
unsortedTracks.removeElement(track)
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)
}
}
}
outlineView.reloadData()
contentViewController.removeItems(items)
}
/// 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 = trackErrors[album] {
error = theError
} else {
return
}
// 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)
var album: Album
switch item {
case let songItem as SongItem:
album = songItem.album
case let albumItem as AlbumItem:
album = albumItem.album
default: return
}
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)
}
}
}
// 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 {
func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool {
if anItem.action() == "performSave:" {
for row in outlineView.selectedRowIndexes {
return sectionOfRow(row) == .Albums
}
} else if anItem.action() == "addITunesSelection:" {
guard iTunes.running else {
return false
}
return !(iTunes.selection.get() as! [AnyObject]).isEmpty
} else if anItem.action() == "delete:" {
for row in outlineView.selectedRowIndexes {
if sectionOfRow(row) != .SearchResults {
return true
}
}
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
}
}
// MARK: - Outline View
extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
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
}
/// Returns whether it is currently valid to invoke `save(_:)`.
private var canSave: Bool {
return !contentViewController.selectedItems.isEmpty
}
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 ""
}
/// Returns whether it is currently valid to invoke `exportArtworks(_:)`.
private var canExportArtworks: Bool {
return contentViewController.selectedItems.count == 1
}
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0
}
func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool {
return Section.isHeaderItem(item)
}
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)
}
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
}
}
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 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: isAlbumLoading(album), error: trackErrors[album])
view?.errorButton?.target = self
view?.errorButton?.action = "showErrorDetails:"
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 = albums.filter { $0.id == searchResult.id }.isEmpty
view?.setupForSearchResult(searchResult, selectable: selectable)
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
/// Returns whether it is currently valid to invoke
/// `delete(_:)`.
private var canDelete: Bool {
return !contentViewController.selectedItems.isEmpty
}
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
}
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 isAlbumLoading(album) || trackErrors[album] != nil {
return .None
}
return .Every
}
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<iTunesTrack>()
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 = outlineView.parentForItem(track) as? 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.extend(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.extend(draggedTracks)
}
outlineView.reloadData()
return true
private var canShowInITunesStore: Bool {
return contentViewController.clickedItems.count == 1 && contentViewController.clickedItems.first is TagTunesItem
}
}

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
//
// PreferencesTabViewController.swift
// Harmony
//
// Created by Kim Wittenburg on 26.01.15.
// Copyright (c) 2015 Das Code Kollektiv. All rights reserved.
//
import Cocoa
class GeneralPreferencesViewController: NSViewController {
}
class StorePreferencesViewController: NSViewController {
dynamic var iTunesStores: [String] {
return NSLocale.ISOCountryCodes().map { NSLocale.currentLocale().displayNameForKey(NSLocaleCountryCode, value: $0)! }
}
dynamic var currentITunesStoreIndex: Int {
set {
let countryCode = NSLocale.ISOCountryCodes()[newValue]
Preferences.sharedPreferences.iTunesStore = countryCode
}
get {
return NSLocale.ISOCountryCodes().indexOf(Preferences.sharedPreferences.iTunesStore)!
}
}
}
class TagsPreferencesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
// MARK: Types
private struct TableViewConstants {
static let textTableCellViewIdentifier = "textCell"
static let popupTableCellViewIdentifier = "popupCell"
static let tagTableColumnIdentifier = "tagColumn"
static let savingBehaviorTableColumnIdentifier = "savingBehaviorColumn"
}
// MARK: Properties
@IBOutlet weak var tableView: NSTableView!
// MARK: Table View
func numberOfRowsInTableView(tableView: NSTableView) -> Int {
return Tag.allTags.count
}
func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
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
return view
} else if tableColumn?.identifier == TableViewConstants.savingBehaviorTableColumnIdentifier {
let popupButton = tableView.makeViewWithIdentifier(TableViewConstants.popupTableCellViewIdentifier, owner: nil) as? NSPopUpButton
popupButton?.removeAllItems()
if tag.isReturnedBySearchAPI {
popupButton?.addItemWithTitle(NSLocalizedString("Save", comment: "Menu item title for a tag that is going to be saved"))
}
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
switch Preferences.sharedPreferences.tagSavingBehaviors[tag]! {
case .Save:
selectedIndex = 0
case .Clear:
selectedIndex = 1
case .Ignore:
selectedIndex = 2
}
if !tag.isReturnedBySearchAPI {
selectedIndex -= 1
}
popupButton?.selectItemAtIndex(selectedIndex)
return popupButton
}
return nil
}
@IBAction private func savingBehaviorChanged(sender: NSPopUpButton) {
let tag = Tag.allTags[tableView.rowForView(sender)]
let selectedIndex = sender.indexOfItem(sender.selectedItem!)
var savingBehavior = Preferences.sharedPreferences.tagSavingBehaviors[tag]!
switch selectedIndex {
case 0:
savingBehavior = tag.isReturnedBySearchAPI ? .Save : .Clear
case 1:
savingBehavior = tag.isReturnedBySearchAPI ? .Clear : .Ignore
case 2:
savingBehavior = .Ignore
default:
break
}
Preferences.sharedPreferences.tagSavingBehaviors[tag] = savingBehavior
}
}

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

@@ -8,6 +8,15 @@
import Cocoa
/// Internal class to be used in IB to bind to the shared preferences.
@objc internal class PreferencesSingleton: NSObject {
internal dynamic var sharedPreferences: Preferences {
return Preferences.sharedPreferences
}
}
/// A custom interface for the `NSUserDefaults`. It is recommended to use this
/// class insted of accessing the user defaults directly to prevent errors due
/// to misspelled strings.
@@ -15,6 +24,41 @@ import Cocoa
/// All properties in this class are KCO compliant.
@objc public class Preferences: NSObject {
// MARK: Types
internal struct UserDefaultsConstants {
static let numberOfSearchResultsKey = "Number of Search Results"
static let iTunesStoreKey = "iTunes Store"
static let useEnglishTagsKey = "Use English Tags"
static let removeSavedItemsKey = "Remove Saved Items"
static let useCensoredNamesKey = "Use Censored Names"
static let caseSensitiveKey = "Case Sensitive"
static let tagSavingBehaviorsKey = "Tag Saving Behaviors"
}
/// Specifies the way a tag is saved to iTunes.
public enum TagSavingBehavior: String {
/// Sets the tag's value to the value returned from the Search API.
case Save = "save"
/// Sets the tag's value to an empty string.
case Clear = "clear"
/// Does not alter the tag's value.
case Ignore = "ignore"
}
// MARK: Initialization
public static var sharedPreferences = Preferences()
/// Initializes the default preferences. This method must be called the very
@@ -22,48 +66,103 @@ import Cocoa
/// this method every time the application launches. Existing values are not
/// overridden.
public func initializeDefaultValues() {
let initialArtworkFolder = NSURL.fileURLWithPath(NSHomeDirectory(), isDirectory: true)
NSUserDefaults.standardUserDefaults().registerDefaults([
"Save Artwork": false,
"Keep Search Results": false,
"Remove Saved Albums": false])
if NSUserDefaults.standardUserDefaults().URLForKey("Artwork Target") == nil {
NSUserDefaults.standardUserDefaults().setURL(initialArtworkFolder, forKey: "Artwork Target")
UserDefaultsConstants.numberOfSearchResultsKey: 10,
UserDefaultsConstants.iTunesStoreKey: NSLocale.currentLocale().objectForKey(NSLocaleCountryCode)!,
UserDefaultsConstants.useEnglishTagsKey: false,
UserDefaultsConstants.removeSavedItemsKey: false,
UserDefaultsConstants.useCensoredNamesKey: false,
UserDefaultsConstants.caseSensitiveKey: true
])
if NSUserDefaults.standardUserDefaults().dictionaryForKey(UserDefaultsConstants.tagSavingBehaviorsKey) == nil {
tagSavingBehaviors = [:]
}
var savingBehaviors = tagSavingBehaviors
for tag in Tag.allTags {
if savingBehaviors[tag] == nil {
savingBehaviors[tag] = tag.isReturnedBySearchAPI ? .Save : .Clear
}
}
tagSavingBehaviors = savingBehaviors
}
/// If `true` the album artwork should be saved to the `artworkTarget` URL
/// when an item is saved.
public dynamic var saveArtwork: Bool {
// MARK: General Preferences
/// The number of search results that should be displayed.
public dynamic var numberOfSearchResults: Int {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: "Save Artwork")
NSUserDefaults.standardUserDefaults().setInteger(newValue, forKey: UserDefaultsConstants.numberOfSearchResultsKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey("Save Artwork")
return NSUserDefaults.standardUserDefaults().integerForKey(UserDefaultsConstants.numberOfSearchResultsKey)
}
}
/// The URL of the folder album artwork is saved to.
/// The iTunes Store from which the metadata should be loaded.
///
/// The URL must be a valid file URL pointing to a directory.
public dynamic var artworkTarget: NSURL {
/// The value of this property must be a valid two-letter ISO country code.
public dynamic var iTunesStore: String {
set {
NSUserDefaults.standardUserDefaults().setURL(newValue, forKey: "Artwork Target")
NSUserDefaults.standardUserDefaults().setObject(newValue, forKey: UserDefaultsConstants.iTunesStoreKey)
}
get {
return NSUserDefaults.standardUserDefaults().URLForKey("Artwork Target")!
}
}
/// 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: "Keep Search Results")
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey("Keep Search Results")
return NSUserDefaults.standardUserDefaults().stringForKey(UserDefaultsConstants.iTunesStoreKey)!
}
}
/// If `true` the Search API Request adds the "lang=en" option.
public dynamic var useEnglishTags: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.useEnglishTagsKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.useEnglishTagsKey)
}
}
/// If `true` all saved items are removed from the list after saving.
public dynamic var removeSavedItems: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.removeSavedItemsKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.removeSavedItemsKey)
}
}
// MARK: Tag Preferences
/// If `true` TagTunes displays and saves censored names instead of the
/// original names.
public dynamic var useCensoredNames: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.useCensoredNamesKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.useCensoredNamesKey)
}
}
/// If `true` TagTunes ignores cases when comparing track titles and albums.
public dynamic var caseSensitive: Bool {
set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: UserDefaultsConstants.caseSensitiveKey)
}
get {
return NSUserDefaults.standardUserDefaults().boolForKey(UserDefaultsConstants.caseSensitiveKey)
}
}
/// The ways different tags are saved (or not saved).
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 { (Tag(rawValue: $0)!, TagSavingBehavior(rawValue: $1 as! String)!) }
}
}
}

View File

@@ -1,46 +0,0 @@
//
// PreferencesTabViewController.swift
// Harmony
//
// Created by Kim Wittenburg on 26.01.15.
// Copyright (c) 2015 Das Code Kollektiv. All rights reserved.
//
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
saveArtworkStateChanged(self)
}
@IBAction internal func saveArtworkStateChanged(sender: AnyObject) {
artworkPathControl.enabled = Preferences.sharedPreferences.saveArtwork
chooseArtworkButton.enabled = Preferences.sharedPreferences.saveArtwork
}
@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 prompting the user to choose a directory")
openPanel.beginSheetModalForWindow(view.window!) {
result in
if result == NSModalResponseOK {
Preferences.sharedPreferences.artworkTarget = openPanel.URL!.filePathURL!
self.artworkPathControl.URL = openPanel.URL
}
}
}
}

99
TagTunes/SaveOperation.swift Executable file
View File

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

View File

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

182
TagTunes/SearchController.swift Executable file
View File

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

View File

@@ -1,65 +0,0 @@
//
// SearchResult.swift
// TagTunes
//
// Created by Kim Wittenburg on 31.08.15.
// Copyright © 2015 Kim Wittenburg. All rights reserved.
//
import Cocoa
/// Represents an `Album` returned fromm the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public class SearchResult: Equatable {
public let id: iTunesId
public let name: String
public let censoredName: String
public let viewURL: NSURL
public let artwork: Artwork
public let trackCount: Int
public let releaseDate: NSDate
public let genre: String
public let artistName: String
public init(representedAlbum: Album) {
id = representedAlbum.id
name = representedAlbum.name
censoredName = representedAlbum.censoredName
viewURL = representedAlbum.viewURL
artwork = representedAlbum.artwork
trackCount = representedAlbum.trackCount
releaseDate = representedAlbum.releaseDate
genre = representedAlbum.genre
artistName = representedAlbum.artistName
}
}
extension Album {
public convenience init(searchResult: SearchResult) {
self.init(id: searchResult.id, name: searchResult.name, censoredName: searchResult.censoredName, viewURL: searchResult.viewURL, artwork: searchResult.artwork, trackCount: searchResult.trackCount, releaseDate: searchResult.releaseDate, genre: searchResult.genre, artistName: searchResult.artistName)
}
}
public func ==(lhs: SearchResult, rhs: SearchResult) -> Bool {
return lhs.id == rhs.id
}
public func ==(lhs: SearchResult, rhs: Album) -> Bool {
return lhs.id == rhs.id
}
public func ==(lhs: Album, rhs: SearchResult) -> Bool {
return lhs.id == rhs.id
}

71
TagTunes/SongItem.swift Executable file
View File

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

View File

@@ -9,9 +9,22 @@
import Cocoa
import AppKitPlus
/// A table cell view to display information for a `Track`. This class should
/// only be initialized from a nib or storyboard.
public class TrackTableCellView: AdvancedTableCellView {
/// 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
@@ -82,26 +95,26 @@ public class TrackTableCellView: AdvancedTableCellView {
// MARK: Methods
/// Sets up the receiver to display `track`.
public func setupForTrack(track: Track) {
style = track.album.hasSameArtistNameAsTracks ? .Simple : .CompactSubtitle
/// 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 = track.name
if track.associatedTracks.isEmpty {
textField?.stringValue = Preferences.sharedPreferences.useCensoredNames ? songItem.song.censoredName : songItem.song.name
if songItem.associatedTracks.isEmpty {
textField?.textColor = NSColor.disabledControlTextColor()
} else if track.associatedTracks.count > 1 || track.associatedTracks[0].name != track.name {
} 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 = track.artistName
trackNumberTextField?.stringValue = "\(track.trackNumber)"
if track.associatedTracks.isEmpty {
imageView?.image = NSImage(named: "TickBW")
secondaryTextField?.stringValue = songItem.song.artist.name
trackNumberTextField?.stringValue = "\(songItem.song.trackNumber)"
if songItem.associatedTracks.isEmpty {
imageView?.image = SongTableCellView.Images.grayTickImage
} else {
imageView?.image = NSImage(named: "Tick")
imageView?.image = SongTableCellView.Images.tickImage
}
if track.saved {
if songItem.saved {
let aspectRatioConstraint = NSLayoutConstraint(
item: savedImageView,
attribute: .Width,

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

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

78
TagTunes/Tag.swift Executable file
View File

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

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

345
TagTunes/TagTunesItem.swift Executable file
View File

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

286
TagTunes/TagTunesTrack.swift Executable file
View File

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

View File

@@ -1,129 +0,0 @@
//
// Track.swift
// Tag for iTunes
//
// Created by Kim Wittenburg on 30.05.15.
// Copyright (c) 2015 Kim Wittenburg. All rights reserved.
//
import Cocoa
/// Represents a track of the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public class Track: iTunesType {
// MARK: Properties
public let id: iTunesId
public let name: String
public let censoredName: String
public let artistName: String
public let releaseDate: NSDate
public let trackNumber: Int
public let trackCount: Int
public let discNumber: Int
public let discCount: Int
public let genre: String
public internal(set) weak var album: Album!
/// These tracks will be changed, if `save()` is called.
public var associatedTracks = [iTunesTrack]()
// MARK: Initializers
public required init(data: [iTunesAPI.Field: AnyObject]) {
id = data[.TrackId] as! UInt
name = data[.TrackName] as! String
censoredName = data[.TrackCensoredName] as! String
artistName = data[.ArtistName] as! String
releaseDate = iTunesAPI.sharedDateFormatter.dateFromString(data[.ReleaseDate] as! String)!
trackNumber = data[.TrackNumber] as! Int
trackCount = data[.TrackCount] as! Int
discNumber = data[.DiscNumber] as! Int
discCount = data[.DiscCount] as! Int
genre = data[.PrimaryGenreName] as! String
}
public static var requiredFields: [iTunesAPI.Field] {
return [.TrackId, .TrackName, .TrackCensoredName, .ArtistName, .ReleaseDate, .TrackNumber, .TrackCount, .DiscNumber, .DiscCount, .PrimaryGenreName]
}
// MARK: Methods
/// Saves the track. This means that its properties are applied to every
/// `iTunesTrack` in its `associatedTracks`.
///
/// This method respects the user's preferences (See `Preferences` class).
public func save() {
saveToTracks(associatedTracks)
}
/// Applies the receiver's properties to the specified tracks.
///
/// This method respects the user's preferences (See `Preferences` class).
public func saveToTracks(tracks: [iTunesTrack]) {
for track in tracks {
saveToTrack(track)
}
}
/// Applies the receiver's properties to the specified `track`.
///
/// This method respects the user's preferences (See `Preferences` class).
public func saveToTrack(track: iTunesTrack) {
let components = NSCalendar.currentCalendar().components(.Year, fromDate: releaseDate)
track.name = name
track.artist = artistName
track.year = components.year
track.trackNumber = trackNumber
track.trackCount = trackCount
track.discNumber = discNumber
track.discCount = discCount
track.genre = genre
track.album = album?.name
track.albumArtist = album?.artistName
track.sortName = ""
track.sortAlbum = ""
track.sortAlbumArtist = ""
track.sortArtist = ""
track.composer = ""
track.sortComposer = ""
track.comment = ""
track.artworks().removeAllObjects()
}
/// Returns `true` if all `associatedTrack`s contain the same values as the
/// `Track` instance.
public var saved: Bool {
let components = NSCalendar.currentCalendar().components(.Year, fromDate: releaseDate)
for track in associatedTracks {
guard track.name == name && track.artist == artistName && track.year == components.year && track.trackNumber == trackNumber && track.trackCount == trackCount && track.discNumber == discNumber && track.discCount == discCount && track.genre == genre && track.album == album.name && track.albumArtist == album.artistName && track.composer == "" else {
return false
}
}
return true
}
}
extension Track: Hashable {
public var hashValue: Int {
return Int(id)
}
}
public func ==(lhs: Track, rhs: Track) -> Bool {
return lhs.id == rhs.id
}

View File

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

View File

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

Binary file not shown.

View File

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

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

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

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

@@ -7,171 +7,17 @@
//
import Foundation
import AppKitPlus
import SearchAPI
/// The Cocoa Scripting Bridge interface of iTunes.
public let iTunes = iTunesApplication(bundleIdentifier: "com.apple.iTunes")!
/// An ID as returned from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
public typealias iTunesId = UInt
/// A pasteboard type for `TagTunesTrack` objects. This type is used for dragging
/// tracks in TagTunes. It's data should be a single encoded `TagTunesTrack`
/// object.
public let TrackPboardType = "public.item.tagtunestrack"
/// This struct contains static helper objects and methods to work with the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public struct iTunesAPI {
// MARK: Types
/// Error types indicating error responses from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public enum Error: ErrorType {
case InvalidCountryCode
case InvalidLanguageCode
case UnknownError
}
/// Contains constants identifying the fields in a response from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
public enum Field: String {
case WrapperType = "wrapperType"
case Kind = "kind"
case TrackId = "trackId"
case TrackName = "trackName"
case TrackCensoredName = "trackCensoredName"
case ArtistName = "artistName"
case ReleaseDate = "releaseDate"
case TrackNumber = "trackNumber"
case TrackCount = "trackCount"
case DiscNumber = "discNumber"
case DiscCount = "discCount"
case PrimaryGenreName = "primaryGenreName"
case CollectionId = "collectionId"
case CollectionName = "collectionName"
case CollectionCensoredName = "collectionCensoredName"
case CollectionViewUrl = "collectionViewUrl"
case CollectionArtistName = "collectionArtistName"
case ArtworkUrl60 = "artworkUrl60"
case ArtworkUrl100 = "artworkUrl100"
}
// MARK: Static Properties and Functions
/// This formatter is configured to be used to parse dates returned from the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
internal static let sharedDateFormatter: NSDateFormatter = {
let dateFormatter = NSDateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return dateFormatter
}()
/// Processes the data returned from a request to the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html).
/// The `data` has to be in a valid JSON format. See `NSJSONSerialization`
/// for details.
///
/// Currently only tracks and albums are supported. If there are any other
/// entries in the specified data this function will raise an exception.
///
/// - throws: Parsing errors and `iTUnesAPI.Error` constants.
/// - returns: An array of albums populated with all associated tracks in the
/// specified data.
public static func parseAPIData(data: NSData) throws -> [Album] {
guard let parsedData = try NSJSONSerialization.JSONObjectWithData(data, options: []) as? [String: AnyObject] else {
throw Error.UnknownError
}
// Handle API Errors
if let errorMessage = parsedData["errorMessage"] as? String {
switch errorMessage {
case "Invalid value(s) for key(s): [country]":
throw Error.InvalidCountryCode
case "Invalid value(s) for key(s): [language]":
throw Error.InvalidLanguageCode
default:
throw Error.UnknownError
}
}
// Parse API Results
var albums = [iTunesId: Album]()
if let results = parsedData["results"] as? [[String: AnyObject]] {
for result in results {
let convertedResult = result.filter({ (key, value) -> Bool in Field(rawValue: key) != nil }).map({ (Field(rawValue: $0)!, $1)
})
let albumId = convertedResult[.CollectionId] as! iTunesId
if albums[albumId] == nil {
albums[albumId] = Album(data: convertedResult)
}
if isTrack(convertedResult) {
albums[albumId]?.addTrack(Track(data: convertedResult))
}
}
}
return Array(albums.values)
}
private static func isTrack(data: [Field: AnyObject]) -> Bool {
return data[.WrapperType] as! String == "track" && data[.Kind] as! String == "song"
}
/// Creates an URL that searches the
/// [Search API](https://www.apple.com/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html)
/// for albums matching a specific `term`.
///
/// This function respects the user's preferences (See `Preferences` class).
///
/// - returns: The query URL or `nil` if `term` is invalid.
public static func createAlbumSearchURLForTerm(term: String) -> NSURL? {
var searchTerm = term.stringByReplacingOccurrencesOfString(" ", withString: "+")
searchTerm = CFURLCreateStringByAddingPercentEscapes(nil, searchTerm, nil, nil, CFStringBuiltInEncodings.UTF8.rawValue) as String
if searchTerm.isEmpty {
return nil
}
return NSURL(string: "https://itunes.apple.com/search?term=\(searchTerm)&media=music&entity=album&limit=10&country=de&lang=de_DE")
}
/// 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=de&lang=de_DE&limit=200")!
}
}
/// 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"

0
TagTunesTests/Info.plist Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More