├── .yo-rc.json
├── app
├── views
│ ├── home.html
│ ├── logout.html
│ ├── configuration
│ │ ├── server.html
│ │ ├── accounts.html
│ │ └── rules.html
│ ├── login.html
│ └── connect.html
├── .buildignore
├── robots.txt
├── styles
│ ├── configuration
│ │ ├── server.scss
│ │ ├── base.scss
│ │ ├── rules.scss
│ │ └── accounts.scss
│ ├── components
│ │ ├── dropdown.scss
│ │ └── message.scss
│ ├── modals
│ │ └── deleteAccount.scss
│ ├── mixins
│ │ └── no-select.scss
│ ├── directives
│ │ ├── configuration
│ │ │ ├── header.scss
│ │ │ └── option.scss
│ │ ├── plex
│ │ │ ├── pin.scss
│ │ │ ├── login.scss
│ │ │ └── home.scss
│ │ ├── button-input.scss
│ │ └── trakt
│ │ │ └── login.scss
│ ├── login.scss
│ ├── connect.scss
│ └── main.scss
├── scripts
│ ├── metadata.js
│ ├── core
│ │ ├── utils.js
│ │ └── differ.js
│ ├── utils
│ │ ├── string.js
│ │ └── version.js
│ ├── directives
│ │ ├── configuration
│ │ │ ├── group.js
│ │ │ ├── header.js
│ │ │ └── option.js
│ │ ├── authentication
│ │ │ ├── tabs.js
│ │ │ ├── plex.js
│ │ │ └── trakt.js
│ │ ├── button.js
│ │ ├── button-input.js
│ │ ├── plex
│ │ │ ├── home.js
│ │ │ ├── pin.js
│ │ │ └── login.js
│ │ └── trakt
│ │ │ └── login.js
│ ├── services
│ │ ├── raven
│ │ │ └── tags.js
│ │ └── authentication.js
│ ├── controllers
│ │ ├── home.js
│ │ ├── logout.js
│ │ ├── footer.js
│ │ ├── login.js
│ │ ├── configuration
│ │ │ ├── server.js
│ │ │ ├── rules.js
│ │ │ └── accounts.js
│ │ └── connect.js
│ ├── filters
│ │ └── orderObjectBy.js
│ ├── models
│ │ ├── plex
│ │ │ ├── connection.js
│ │ │ ├── connection_manager.js
│ │ │ └── server.js
│ │ ├── authentication
│ │ │ ├── trakt
│ │ │ │ ├── pin.js
│ │ │ │ ├── basic.js
│ │ │ │ └── main.js
│ │ │ ├── base.js
│ │ │ ├── main.js
│ │ │ └── plex.js
│ │ ├── configuration
│ │ │ └── rules
│ │ │ │ ├── user
│ │ │ │ ├── rule.js
│ │ │ │ └── collection.js
│ │ │ │ └── client
│ │ │ │ ├── rule.js
│ │ │ │ └── collection.js
│ │ ├── options.js
│ │ └── account.js
│ └── app.js
├── modals
│ ├── deleteAccount.html
│ ├── disconnectAccount.html
│ └── authenticationDetails.html
├── directives
│ ├── button.html
│ ├── plex
│ │ ├── pin.html
│ │ ├── home.html
│ │ └── login.html
│ ├── button-input.html
│ ├── configuration
│ │ ├── option
│ │ │ ├── integer.html
│ │ │ ├── string.html
│ │ │ ├── boolean.html
│ │ │ └── enum.html
│ │ ├── header.html
│ │ └── group.html
│ ├── authentication
│ │ ├── tabs.html
│ │ ├── plex.html
│ │ └── trakt.html
│ └── trakt
│ │ └── login.html
├── 404.html
└── index.html
├── .gitattributes
├── .bowerrc
├── .gitignore
├── .travis.yml
├── README.md
├── test
├── .jshintrc
├── spec
│ └── controllers
│ │ ├── main.js
│ │ └── about.js
└── karma.conf.js
├── .jshintrc
├── .editorconfig
├── package.json
└── bower.json
/.yo-rc.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/app/views/home.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/views/logout.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
--------------------------------------------------------------------------------
/app/.buildignore:
--------------------------------------------------------------------------------
1 | *.coffee
--------------------------------------------------------------------------------
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "bower_components"
3 | }
4 |
--------------------------------------------------------------------------------
/app/robots.txt:
--------------------------------------------------------------------------------
1 | # robotstxt.org
2 |
3 | User-agent: *
4 | Disallow:
5 |
--------------------------------------------------------------------------------
/app/styles/configuration/server.scss:
--------------------------------------------------------------------------------
1 | .scroll-container > .options {
2 | margin-bottom: 80px;
3 | }
4 |
--------------------------------------------------------------------------------
/app/styles/components/dropdown.scss:
--------------------------------------------------------------------------------
1 | .f-dropdown {
2 | li.active {
3 | background: #DFDFDF;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/app/scripts/metadata.js:
--------------------------------------------------------------------------------
1 | if(typeof window.tfpc == "undefined") {
2 | window.tfpc = {};
3 | }
4 | window.tfpc.metadata = {};
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea
2 | /.sass-cache
3 | /.tmp
4 | /app/scripts/metadata.js
5 | /bower_components
6 | /dist
7 | /node_modules
8 |
9 | *.iml
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - 'iojs'
5 | - '0.12'
6 | - '0.10'
7 | before_script:
8 | - 'npm install -g bower grunt-cli'
9 | - 'bower install'
10 |
--------------------------------------------------------------------------------
/app/styles/modals/deleteAccount.scss:
--------------------------------------------------------------------------------
1 | .reveal-modal div.delete-account {
2 | .controls {
3 | padding-top: 20px;
4 | }
5 |
6 | a.button {
7 | margin-bottom: 0;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/app/styles/mixins/no-select.scss:
--------------------------------------------------------------------------------
1 | @mixin no-select {
2 | -webkit-touch-callout: none;
3 | -webkit-user-select: none;
4 | -khtml-user-select: none;
5 | -moz-user-select: none;
6 | -ms-user-select: none;
7 | user-select: none;
8 | }
9 |
--------------------------------------------------------------------------------
/app/scripts/core/utils.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('Utils', function () {
5 | return {
6 | isDefined: function(value) {
7 | return !!(typeof value !== 'undefined' && value !== null);
8 | }
9 | };
10 | });
11 |
--------------------------------------------------------------------------------
/app/scripts/utils/string.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('StringUtil', function() {
5 | return {
6 | endsWith: function(str, suffix) {
7 | return str.indexOf(suffix, str.length - suffix.length) !== -1;
8 | }
9 | };
10 | });
11 |
--------------------------------------------------------------------------------
/app/styles/directives/configuration/header.scss:
--------------------------------------------------------------------------------
1 | co-configuration-header {
2 | .title {
3 | padding: 0 10px;
4 | }
5 |
6 | .actions {
7 | padding: 5px 0 0;
8 |
9 | text-align: right;
10 |
11 | button {
12 | font-size: 17px;
13 | padding: 0.375rem 1.25rem;
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/modals/deleteAccount.html:
--------------------------------------------------------------------------------
1 |
2 |
Are you sure you want to delete {{ account.name }} ?
3 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/app/scripts/directives/configuration/group.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coConfigurationGroup', function() {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | groups: '=coGroups'
9 | },
10 | templateUrl: 'directives/configuration/group.html'
11 | };
12 | });
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Trakt.tv (for Plex) - Configuration
2 |
3 | [][license]
4 |
5 | Configuration site for the *Trakt.tv (for Plex)* plugin, latest build is available at http://trakt-for-plex.github.io/configuration.
6 |
7 | [license]: https://github.com/trakt-for-plex/configuration/blob/master/LICENSE.md
8 |
--------------------------------------------------------------------------------
/test/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "bitwise": true,
3 | "browser": true,
4 | "curly": true,
5 | "eqeqeq": true,
6 | "esnext": true,
7 | "jasmine": true,
8 | "latedef": true,
9 | "noarg": true,
10 | "node": true,
11 | "strict": true,
12 | "undef": true,
13 | "unused": true,
14 | "globals": {
15 | "angular": false,
16 | "inject": false
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/modals/disconnectAccount.html:
--------------------------------------------------------------------------------
1 |
2 |
Are you sure you want to disconnect {{ username }} from {{ account.name }} ?
3 |
4 |
8 |
9 |
--------------------------------------------------------------------------------
/app/directives/button.html:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/scripts/services/raven/tags.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('RavenTags', function() {
5 | var tags = {};
6 |
7 | return {
8 | update: function(update) {
9 | _.each(update, function(value, key) {
10 | tags[key] = value;
11 | });
12 |
13 | Raven.setTagsContext(tags);
14 | }
15 | }
16 | });
17 |
--------------------------------------------------------------------------------
/app/views/configuration/server.html:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/scripts/controllers/home.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:HomeController
6 | * @description
7 | * # HomeController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('HomeController', function($location) {
12 | $location.path('/configuration/server');
13 | $location.search('');
14 | });
15 |
--------------------------------------------------------------------------------
/app/scripts/controllers/logout.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:LogoutController
6 | * @description
7 | * # LogoutController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('LogoutController', function(Authentication, $location) {
12 | Authentication.logout();
13 |
14 | $location.path('/login');
15 | $location.search('');
16 | });
17 |
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "bitwise": true,
3 | "browser": true,
4 | "curly": true,
5 | "eqeqeq": true,
6 | "esnext": true,
7 | "latedef": true,
8 | "noarg": true,
9 | "node": true,
10 | "strict": true,
11 | "undef": true,
12 | "unused": true,
13 | "globals": {
14 | "angular": false,
15 | "Ladda": false,
16 | "md5": false,
17 | "plex": false,
18 | "semver": false,
19 | "trakt": false,
20 | "$": false,
21 | "_": false
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/directives/plex/pin.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ current.code }}
7 |
8 |
9 | Error
10 | Unable to retrieve pin details from plex.tv
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/views/login.html:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/styles/components/message.scss:
--------------------------------------------------------------------------------
1 | small.message {
2 | display: block;
3 |
4 | margin: 0 -1px 10px !important;
5 |
6 | padding: 0.375rem 0.5625rem 0.375rem 0.5625rem;
7 |
8 | font-size: 0.75rem;
9 | font-style: italic;
10 | font-weight: normal;
11 |
12 | color: #FFFFFF;
13 | }
14 |
15 | small.message.info {
16 | background: #13736F;
17 | }
18 |
19 | small.message.warning {
20 | background: #BDB820;
21 | }
22 |
23 | small.message.error {
24 | background: #BD2820;
25 | }
26 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 |
8 | [*]
9 |
10 | # Change these settings to your own preference
11 | indent_style = space
12 | indent_size = 2
13 |
14 | # We recommend you to keep these unchanged
15 | end_of_line = lf
16 | charset = utf-8
17 | trim_trailing_whitespace = true
18 | insert_final_newline = true
19 |
20 | [*.md]
21 | trim_trailing_whitespace = false
22 |
--------------------------------------------------------------------------------
/app/scripts/directives/configuration/header.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coConfigurationHeader', function() {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | title: '=coTitle',
9 |
10 | self: '=coSelf',
11 | refresh: '=coRefresh',
12 | discard: '=coDiscard',
13 | save: '=coSave',
14 | delete: '=coDelete'
15 | },
16 | templateUrl: 'directives/configuration/header.html'
17 | };
18 | });
19 |
--------------------------------------------------------------------------------
/app/scripts/directives/authentication/tabs.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coAuthenticationTabs', function(Utils) {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | account: '=coAccount'
9 | },
10 | templateUrl: 'directives/authentication/tabs.html',
11 |
12 | controller: function($scope) {
13 | $scope.selected = 'trakt';
14 |
15 | $scope.select = function(key) {
16 | $scope.selected = key;
17 | }
18 | }
19 | };
20 | });
21 |
--------------------------------------------------------------------------------
/app/scripts/filters/orderObjectBy.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .filter('orderObjectBy', function() {
5 | return function(items, field, reverse) {
6 | var filtered = [];
7 |
8 | angular.forEach(items, function(item) {
9 | filtered.push(item);
10 | });
11 |
12 | filtered.sort(function (a, b) {
13 | return (a[field] > b[field] ? 1 : -1);
14 | });
15 |
16 | if(reverse) {
17 | filtered.reverse();
18 | }
19 |
20 | return filtered;
21 | };
22 | });
23 |
--------------------------------------------------------------------------------
/app/styles/directives/plex/pin.scss:
--------------------------------------------------------------------------------
1 | co-plex-pin {
2 | .code {
3 | position: relative;
4 |
5 | height: 112px;
6 |
7 | background-color: rgba(0,0,0,0.3);
8 | color: white;
9 |
10 | .code-spinner {
11 | position: absolute;
12 | top: 0;
13 | left: 0;
14 | right: 0;
15 | bottom: 0;
16 | }
17 |
18 | .code-value {
19 | font-size: 74px;
20 | letter-spacing: 10px;
21 | line-height: 112px;
22 | text-align: center;
23 | }
24 |
25 | .code-error {
26 | padding: 10px;
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/test/spec/controllers/main.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('Controller: MainCtrl', function () {
4 |
5 | // load the controller's module
6 | beforeEach(module('configurationApp'));
7 |
8 | var MainCtrl;
9 |
10 | // Initialize the controller and a mock scope
11 | beforeEach(inject(function ($controller, $rootScope) {
12 | MainCtrl = $controller('MainCtrl', {
13 | // place here mocked dependencies
14 | });
15 | }));
16 |
17 | it('should attach a list of awesomeThings to the scope', function () {
18 | expect(MainCtrl.awesomeThings.length).toBe(3);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/app/scripts/core/differ.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('Differ', function () {
5 | function run(a, b) {
6 | var r = {};
7 |
8 | _.each(a, function(v, k) {
9 | if(b[k] === v) {
10 | return;
11 | }
12 |
13 | if(_.isObject(v)) {
14 | v = run(v, b[k]);
15 |
16 | if(Object.keys(v).length === 0) {
17 | return;
18 | }
19 | }
20 |
21 | r[k] = v;
22 | });
23 |
24 | return r;
25 | }
26 |
27 | return {
28 | run: run
29 | };
30 | });
31 |
--------------------------------------------------------------------------------
/test/spec/controllers/about.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('Controller: AboutCtrl', function () {
4 |
5 | // load the controller's module
6 | beforeEach(module('configurationApp'));
7 |
8 | var AboutCtrl;
9 |
10 | // Initialize the controller and a mock scope
11 | beforeEach(inject(function ($controller, $rootScope) {
12 | AboutCtrl = $controller('AboutCtrl', {
13 | // place here mocked dependencies
14 | });
15 | }));
16 |
17 | it('should attach a list of awesomeThings to the scope', function () {
18 | expect(AboutCtrl.awesomeThings.length).toBe(3);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/app/scripts/controllers/footer.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:FooterController
6 | * @description
7 | * # FooterController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('FooterController', function($scope) {
12 | $scope.connectionVisible = false;
13 | $scope.versionVisible = false;
14 |
15 | $scope.toggleConnection = function() {
16 | $scope.connectionVisible = !$scope.connectionVisible;
17 | };
18 |
19 | $scope.toggleVersion = function() {
20 | $scope.versionVisible = !$scope.versionVisible;
21 | };
22 | });
23 |
--------------------------------------------------------------------------------
/app/views/connect.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
{{ server.name }}
9 |
{{ server.status }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/directives/button-input.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
14 |
15 |
16 |
17 |
18 |
19 | {{ error }}
20 |
--------------------------------------------------------------------------------
/app/scripts/models/plex/connection.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('PlexConnection', function($http) {
5 | function PlexConnection() {
6 | this.client = null;
7 | this.uri = null;
8 | }
9 |
10 | PlexConnection.prototype.request = function(path, config) {
11 | // build url
12 | config.url = this.uri + '/' + path;
13 |
14 | // create request
15 | return $http(config);
16 | };
17 |
18 | PlexConnection.fromElement = function(e) {
19 | var c = new PlexConnection();
20 |
21 | // Set attributes
22 | c.uri = e._uri;
23 |
24 | return c;
25 | };
26 |
27 | return PlexConnection;
28 | });
29 |
--------------------------------------------------------------------------------
/app/styles/directives/button-input.scss:
--------------------------------------------------------------------------------
1 | co-button-input {
2 | .cobi {
3 | display: table;
4 | width: 100%;
5 | }
6 |
7 | .cobi-input {
8 | display: none;
9 | width: 100%;
10 |
11 | vertical-align: middle;
12 |
13 | input {
14 | height: 32px;
15 |
16 | margin: 0;
17 | }
18 | }
19 |
20 | .cobi-button {
21 | display: table-cell;
22 |
23 | vertical-align: middle;
24 |
25 | button {
26 | margin: 0 -1px 0 0;
27 | }
28 | }
29 |
30 | .cobi.opened {
31 | .cobi-input {
32 | display: table-cell;
33 | }
34 | }
35 |
36 | .error {
37 | width: 100%;
38 |
39 | margin-top: 0;
40 |
41 | font-size: 0.7rem;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/styles/login.scss:
--------------------------------------------------------------------------------
1 | .login.panel {
2 | width: 320px;
3 |
4 | margin-left: auto;
5 | margin-right: auto;
6 | margin-top: 10%;
7 |
8 | padding: 0;
9 |
10 | .header {
11 | position: relative;
12 |
13 | padding: 10px 14px;
14 |
15 | background: rgba(0, 0, 0, 0.08);
16 |
17 | h5 {
18 | margin: 0;
19 | }
20 |
21 | .help {
22 | position: absolute;
23 | top: 0;
24 | right: 14px;
25 | bottom: 0;
26 |
27 | a.details {
28 | display: block;
29 | margin-top: 2px;
30 |
31 | font-size: 22px;
32 |
33 | color: #AAA;
34 | }
35 | }
36 | }
37 |
38 | .basic-authentication, .pin-authentication {
39 | padding: 14px;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/directives/configuration/option/integer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ option.label }}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/directives/configuration/option/string.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ option.label }}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/directives/configuration/header.html:
--------------------------------------------------------------------------------
1 |
2 |
{{ title }}
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/directives/configuration/option/boolean.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
{{ option.label }}
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/scripts/utils/version.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('VersionUtil', function() {
5 | return {
6 | compare: function(left, right) {
7 | if (typeof left + typeof right !== 'stringstring') {
8 | return false;
9 | }
10 |
11 | var a = left.split('.'),
12 | b = right.split('.'),
13 | i = 0,
14 | len = Math.max(a.length, b.length);
15 |
16 | for (; i < len; i++) {
17 | if ((a[i] && !b[i] && parseInt(a[i]) > 0) || (parseInt(a[i]) > parseInt(b[i]))) {
18 | return 1;
19 | } else if ((b[i] && !a[i] && parseInt(b[i]) > 0) || (parseInt(a[i]) < parseInt(b[i]))) {
20 | return -1;
21 | }
22 | }
23 |
24 | return 0;
25 | }
26 | };
27 | });
28 |
--------------------------------------------------------------------------------
/app/directives/configuration/option/enum.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ option.label }}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/styles/directives/trakt/login.scss:
--------------------------------------------------------------------------------
1 | @import '../../mixins/no-select';
2 |
3 | co-trakt-login {
4 | .fields {
5 | margin-bottom: 14px;
6 | }
7 |
8 | label > input[type="text"], input[type="password"] {
9 | margin: 1px 0 1rem 0;
10 | }
11 |
12 | label:last-child > input[type="text"], input[type="password"] {
13 | margin-bottom: 0;
14 | }
15 |
16 | a.button, input[type="submit"] {
17 | margin: 0;
18 | }
19 |
20 | .buttons {
21 | text-align: right;
22 | }
23 |
24 | .buttons.small {
25 | height: 44px;
26 | }
27 |
28 | .buttons.tiny {
29 | height: 33px;
30 | }
31 |
32 | .buttons-left {
33 | float: left;
34 |
35 | .button {
36 | margin-right: 6px;
37 | }
38 | }
39 |
40 | .buttons,
41 | .buttons .button,
42 | .buttons-left {
43 | @include no-select();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/modals/authenticationDetails.html:
--------------------------------------------------------------------------------
1 | Login to plex.tv
2 |
3 | This web-application requires access to your plex.tv account to:
4 |
5 | Discover servers linked to your account
6 | Connect to your server
7 | Authenticate with the trakt plugin
8 |
9 |
10 |
11 | Your plex.tv credentials are sent directly to the official plex.tv API to exchange for an authentication token, this token is stored locally in your browser until you logout or clear your browser “Cookies and other site and plugin data”.
12 |
13 |
14 |
15 | Access to your plex.tv account can be revoked at any time at http://app.plex.tv/web/app#!/settings/devices , look for a device named “trakt (for Plex) - Configuration”.
16 |
17 |
18 | ×
19 |
--------------------------------------------------------------------------------
/app/scripts/controllers/login.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:LoginController
6 | * @description
7 | * # LoginController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('LoginController', function(Authentication, $location, $modal, $q, $scope) {
12 | $scope.onAuthenticated = function(token, user){
13 | if(Authentication.login(token, user)) {
14 | // Login successful
15 | $scope.$r.redirect();
16 | }
17 | };
18 |
19 | $scope.showAuthenticationDetails = function() {
20 | var modal = $modal.open({
21 | templateUrl: 'modals/authenticationDetails.html',
22 | windowClass: 'small'
23 | });
24 |
25 | modal.result.then(function() {
26 | console.log('Modal closed');
27 | }, function () {
28 | console.log('Modal dismissed');
29 | });
30 | };
31 | });
32 |
--------------------------------------------------------------------------------
/app/directives/authentication/tabs.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Trakt
5 |
6 |
7 | Plex
8 |
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/scripts/controllers/configuration/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:ServerController
6 | * @description
7 | * # ServerController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('ServerController', function (Options, $q, $rootScope, $scope) {
12 | $scope.options = null;
13 |
14 | $scope.refresh = function() {
15 | // Retrieve server options
16 | return $rootScope.$s.call('option.list', []).then(function(options) {
17 | // Parse options
18 | $scope.options = new Options(options);
19 | }, function() {
20 | return $q.reject();
21 | });
22 | };
23 |
24 | $scope.discard = function() {
25 | return $scope.options.discard();
26 | };
27 |
28 | $scope.save = function() {
29 | return $scope.options.save($rootScope.$s);
30 | };
31 |
32 | // Initial preferences refresh
33 | $scope.refresh();
34 | });
35 |
--------------------------------------------------------------------------------
/app/styles/configuration/base.scss:
--------------------------------------------------------------------------------
1 | .options {
2 | margin-top: 25px;
3 |
4 | .group > .header,
5 | .sub-group > div > .header {
6 | margin: 10px 0;
7 | padding: 5px 10px;
8 |
9 | background-color: rgba(0,0,0,0.05);
10 |
11 | .columns {
12 | padding: 0;
13 | }
14 |
15 | .switch {
16 | margin: 0;
17 |
18 | text-align: right;
19 |
20 | label {
21 | margin-top: 7px;
22 | }
23 | }
24 | }
25 |
26 | h5, h6 {
27 | line-height: 2.6;
28 |
29 | margin: 0;
30 | padding-left: 5px;
31 | }
32 |
33 | h6 {
34 | line-height: 1.6;
35 |
36 | border-bottom: 1px solid #AAA;
37 | }
38 |
39 | .group-options {
40 | label + select, label + input {
41 | margin-top: 3px;
42 | }
43 | }
44 |
45 | .group > .columns, .sub-group > .columns {
46 | padding: 0;
47 | }
48 |
49 | .sub-group {
50 | .group-options {
51 | margin: 0;
52 | }
53 | }
54 |
55 | .sub-group > div > .header {
56 | background-color: transparent;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/styles/connect.scss:
--------------------------------------------------------------------------------
1 | .panel.connect {
2 | height: 300px;
3 |
4 | margin-top: 10%;
5 | padding: 0;
6 |
7 | .header {
8 | position: relative;
9 |
10 | padding: 10px 14px;
11 |
12 | background: rgba(0, 0, 0, 0.08);
13 |
14 | h5 {
15 | margin: 0;
16 | }
17 | }
18 |
19 | .servers {
20 | position: absolute;
21 | top: 38px;
22 | left: 0;
23 | right: 0;
24 | bottom: 0;
25 |
26 | overflow-y: scroll;
27 |
28 | .server {
29 | padding: 10px 15px;
30 |
31 | .error {
32 | font-size: 10px;
33 | }
34 | }
35 |
36 | .server:hover {
37 | background-color: rgba(0, 0, 0, 0.05);
38 | cursor: pointer;
39 | }
40 | }
41 |
42 | .status {
43 | position: absolute;
44 | top: 0;
45 | left: 0;
46 | right: 0;
47 | bottom: 0;
48 |
49 | height: 298px;
50 |
51 | background-color: #f2f2f2;
52 |
53 | .busy {
54 | position: relative;
55 | top: 50%;
56 |
57 | transform: translateY(-50%);
58 |
59 | .spinner {
60 | margin: 0 auto;
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/app/styles/directives/plex/login.scss:
--------------------------------------------------------------------------------
1 | @import '../../mixins/no-select';
2 |
3 | co-plex-login {
4 | .error {
5 | margin: 0 -1px;
6 | }
7 |
8 | .fields {
9 | margin-bottom: 14px;
10 | }
11 |
12 | label > input[type="text"], input[type="password"] {
13 | margin: 1px 0 1rem 0;
14 | }
15 |
16 | label:last-child > input[type="text"], input[type="password"] {
17 | margin-bottom: 0;
18 | }
19 |
20 | a.button, input[type="submit"] {
21 | margin: 0;
22 | }
23 |
24 | .buttons {
25 | text-align: right;
26 | }
27 |
28 | .buttons.small {
29 | height: 44px;
30 | }
31 |
32 | .buttons.tiny {
33 | height: 33px;
34 | }
35 |
36 | .buttons-left {
37 | float: left;
38 |
39 | .button {
40 | margin-right: 6px;
41 | }
42 | }
43 |
44 | .buttons,
45 | .buttons .button,
46 | .buttons-left {
47 | @include no-select();
48 | }
49 |
50 | .pin-authentication {
51 | .instructions {
52 | margin-bottom: 1px;
53 |
54 | font-size: 14px;
55 | line-height: 21px;
56 | }
57 |
58 | co-plex-pin .code {
59 | margin-bottom: 14px;
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/app/directives/authentication/plex.html:
--------------------------------------------------------------------------------
1 |
2 | {{ message.content }}
3 |
4 |
5 |
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
{{ plex.title }}
24 |
25 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/directives/configuration/group.html:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "configuration",
3 | "private": true,
4 | "devDependencies": {
5 | "grunt": "^0.4.5",
6 | "grunt-angular-templates": "^0.5.7",
7 | "grunt-autoprefixer": "^2.0.0",
8 | "grunt-build-control": "^0.5.0",
9 | "grunt-concurrent": "^1.0.0",
10 | "grunt-contrib-clean": "^0.6.0",
11 | "grunt-contrib-compass": "^1.0.0",
12 | "grunt-contrib-concat": "^0.5.0",
13 | "grunt-contrib-connect": "^0.9.0",
14 | "grunt-contrib-copy": "^0.7.0",
15 | "grunt-contrib-cssmin": "^0.12.0",
16 | "grunt-contrib-htmlmin": "^0.4.0",
17 | "grunt-contrib-imagemin": "^0.9.4",
18 | "grunt-contrib-jshint": "^0.11.0",
19 | "grunt-contrib-uglify": "^0.7.0",
20 | "grunt-contrib-watch": "^0.6.1",
21 | "grunt-filerev": "^2.1.2",
22 | "grunt-git-describe": "^2.3.2",
23 | "grunt-karma": "*",
24 | "grunt-newer": "^1.1.0",
25 | "grunt-ng-annotate": "^0.9.2",
26 | "grunt-svgmin": "^2.0.0",
27 | "grunt-usemin": "^3.0.0",
28 | "grunt-wiredep": "^2.0.0",
29 | "jit-grunt": "^0.9.1",
30 | "jshint-stylish": "^1.0.0",
31 | "karma-jasmine": "*",
32 | "karma-phantomjs-launcher": "*",
33 | "time-grunt": "^1.0.0"
34 | },
35 | "engines": {
36 | "node": ">=0.10.0"
37 | },
38 | "scripts": {
39 | "test": "grunt test"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "configuration",
3 | "version": "1.4.2",
4 | "dependencies": {
5 | "angular": "^1.4.1",
6 | "angular-animate": "^1.4.1",
7 | "angular-aria": "^1.4.1",
8 | "angular-cookies": "^1.4.1",
9 | "angular-messages": "^1.4.1",
10 | "angular-resource": "^1.4.1",
11 | "angular-route": "^1.4.1",
12 | "angular-sanitize": "^1.4.1",
13 | "angular-spinner": "~0.6.2",
14 | "angular-touch": "^1.4.1",
15 | "foundation": "~5.5.2",
16 | "angular-foundation": "~0.6.0",
17 | "ua-parser-js": "~0.7.7",
18 | "angular-xml": "~2.1.0",
19 | "underscore": "~1.8.3",
20 | "foundation-icon-fonts": "*",
21 | "cerealizer.js": "git@github.com:fuzeman/Cerealizer.js.git",
22 | "ladda-foundation-5": "git@github.com:fuzeman/ladda-foundation-5.git",
23 | "trakt.js": "git@github.com:fuzeman/trakt.js.git",
24 | "plex.js": "git@github.com:fuzeman/plex.js.git",
25 | "selectize": "~0.12.1",
26 | "fontawesome": "~4.3.0",
27 | "angular-selectize2": "~1.2.1",
28 | "jquery-sortable": "~0.9.13",
29 | "raven-js": "^3.9.1",
30 | "angular-raven": "^0.6.2",
31 | "JavaScript-MD5": "~1.1.0",
32 | "angulartics": "~0.19.0",
33 | "angular-marked": "^1.2.0"
34 | },
35 | "devDependencies": {
36 | "angular-mocks": "^1.4.1"
37 | },
38 | "appPath": "app",
39 | "moduleName": "configurationApp"
40 | }
41 |
--------------------------------------------------------------------------------
/app/scripts/models/authentication/trakt/pin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('TraktPinAuthentication', function(BaseAuthentication, Utils, $q) {
5 | function TraktPinAuthentication(main) {
6 | this.main = main;
7 |
8 | this.changed = false;
9 | this.original = null;
10 |
11 | // Authorization details
12 | this.authorization = null;
13 | this.code = null;
14 |
15 | // State
16 | this.messages = [];
17 | this.state = '';
18 | }
19 |
20 | TraktPinAuthentication.prototype.current = function() {
21 | var authorization = this.authorization;
22 |
23 | if(!Utils.isDefined(authorization)) {
24 | return {};
25 | }
26 |
27 | return {
28 | authorization: {
29 | oauth: $.extend({
30 | code: this.code
31 | }, authorization)
32 | }
33 | };
34 | };
35 |
36 | TraktPinAuthentication.prototype.update = function(data) {
37 | this.changed = false;
38 | this.original = angular.copy(data);
39 |
40 | // Authorization details
41 | this.authorization = null;
42 | this.code = data.code;
43 |
44 | // State
45 | this.messages = [];
46 | this.state = data.state;
47 | };
48 |
49 | TraktPinAuthentication.prototype.updateAuthorization = function(authorization) {
50 | this.authorization = authorization;
51 | this.changed = true;
52 | };
53 |
54 | return TraktPinAuthentication;
55 | });
56 |
--------------------------------------------------------------------------------
/app/directives/authentication/trakt.html:
--------------------------------------------------------------------------------
1 |
2 | {{ message.content }}
3 | {{ message.content }}
4 | {{ message.content }}
5 |
6 |
7 |
8 |
9 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
{{ trakt.username }}
29 |
30 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/scripts/models/authentication/base.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('BaseAuthentication', function() {
5 | var stateIds = {
6 | 'valid': 0,
7 | 'empty': 1,
8 | 'warning': 2,
9 | 'error': 3
10 | },
11 | stateKeys = {
12 | 0: 'valid',
13 | 1: 'empty',
14 | 2: 'warning',
15 | 3: 'error'
16 | };
17 |
18 | return {
19 | selectPriorityState: function(states, type) {
20 | type = typeof type !== 'undefined' ? type : 'top';
21 |
22 | if(states.length === 0) {
23 | return null;
24 | }
25 |
26 | // Ensure states are unique
27 | states = _.uniq(states);
28 |
29 | if(states.length === 1) {
30 | return states[0];
31 | }
32 |
33 | // Map states to sort indices
34 | var ids =_.map(states, function(key) {
35 | return stateIds[key];
36 | });
37 |
38 | // Sort state indices
39 | ids = _.sortBy(ids, function(id) { return id; });
40 |
41 | // Retrieve highest priority state
42 | var id;
43 |
44 | if(type === 'top') {
45 | id = ids[0];
46 | } else if(type === 'bottom') {
47 | id = ids[ids.length - 1];
48 | } else {
49 | console.warn('Unknown "type" provided for selectPriorityState()');
50 | return null;
51 | }
52 |
53 | // Map id to state key
54 | return stateKeys[id];
55 | }
56 | };
57 | });
58 |
--------------------------------------------------------------------------------
/app/scripts/directives/button.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coButton', function(Utils) {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | self: '=coSelf',
9 | callback: '=coCallback',
10 |
11 | class: '@coClass',
12 | size: '@coSize',
13 | tooltip: '@coTooltip'
14 | },
15 | templateUrl: 'directives/button.html',
16 | transclude: true,
17 |
18 | compile: function(element, attrs) {
19 | if(!Utils.isDefined(attrs.coSize)) {
20 | attrs.coSize = 'tiny';
21 | }
22 |
23 | return this.link;
24 | },
25 | controller: function($scope) {
26 | $scope.call = function() {
27 | if(typeof $scope.self === 'undefined' || $scope.self === null) {
28 | return $scope.callback();
29 | }
30 |
31 | return $.proxy($scope.callback, $scope.self)();
32 | };
33 |
34 | $scope.click = function() {
35 | $scope.button.start();
36 |
37 | $scope.call().then(function() {
38 | $scope.button.stop();
39 | }, function() {
40 | $scope.button.stop();
41 | });
42 | };
43 | },
44 | link: function(scope, element) {
45 | var $button = $('button', element);
46 |
47 | $button.addClass(scope.class)
48 | .addClass(scope.size)
49 | .addClass('ladda-button')
50 | .css('display', '');
51 |
52 | if(scope.class === 'secondary') {
53 | $button.attr('data-spinner-color', '#333333');
54 | }
55 |
56 | scope.button = Ladda.create($button[0]);
57 | }
58 | };
59 | });
60 |
--------------------------------------------------------------------------------
/app/scripts/models/authentication/trakt/basic.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('TraktBasicAuthentication', function(BaseAuthentication, Utils, $q) {
5 | function TraktBasicAuthentication(main) {
6 | this.main = main;
7 |
8 | this.changed = false;
9 | this.original = null;
10 |
11 | // Authorization details
12 | this.username = null;
13 | this.password = null;
14 |
15 | // State
16 | this.messages = [];
17 | this.state = '';
18 | }
19 |
20 | TraktBasicAuthentication.prototype.appendMessage = function(type, content) {
21 | this.messages.push({
22 | type: type,
23 | content: content
24 | });
25 | };
26 |
27 | TraktBasicAuthentication.prototype.current = function() {
28 | return {
29 | username: this.username,
30 |
31 | authorization: {
32 | basic: {
33 | password: this.password
34 | }
35 | }
36 | };
37 | };
38 |
39 | TraktBasicAuthentication.prototype.update = function(data) {
40 | this.changed = false;
41 | this.original = angular.copy(data);
42 |
43 | // Authorization details
44 | this.username = data.username;
45 | this.password = data.password;
46 |
47 | // State
48 | this.messages = [];
49 | this.state = data.state;
50 | };
51 |
52 | TraktBasicAuthentication.prototype.updateAuthorization = function() {
53 | // Clear messages
54 | this.messages = [];
55 |
56 | // Set `changed` flag
57 | this.changed = true;
58 |
59 | // Update messages
60 | this.appendMessage('info', "Account details are unavailable until changes have been saved");
61 | };
62 |
63 | return TraktBasicAuthentication;
64 | });
65 |
--------------------------------------------------------------------------------
/app/scripts/directives/button-input.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coButtonInput', function($timeout) {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | callback: '=coCallback',
9 |
10 | placeholder: '@coPlaceholder',
11 | tooltip: '@coTooltip'
12 | },
13 | templateUrl: 'directives/button-input.html',
14 | transclude: true,
15 |
16 | controller: function($scope) {
17 | $scope.opened = false;
18 | $scope.value = null;
19 |
20 | $scope.click = function() {
21 | if(!$scope.opened) {
22 | $scope.open();
23 | return;
24 | }
25 |
26 | $scope.button.start();
27 |
28 | $scope.callback($scope.value).then(function() {
29 | $scope.close();
30 | $scope.button.stop();
31 | }, function(error) {
32 | $scope.error = error;
33 |
34 | $scope.button.stop();
35 | });
36 | };
37 |
38 | $scope.open = function() {
39 | $scope.opened = true;
40 |
41 | $timeout(function() {
42 | $scope.input.focus();
43 | }, 100);
44 | };
45 |
46 | $scope.close = function() {
47 | $scope.error = null;
48 | $scope.opened = false;
49 | $scope.value = null;
50 | };
51 |
52 | $scope.keyup = function(event) {
53 | if(event.keyCode === 13) {
54 | // ENTER
55 | $scope.click();
56 | } else if(event.keyCode === 27) {
57 | // ESC
58 | $scope.close();
59 | }
60 | };
61 | },
62 | link: function(scope, element) {
63 | scope.button = Ladda.create($('button', element)[0]);
64 | scope.input = $('input', element);
65 | }
66 | };
67 | });
68 |
--------------------------------------------------------------------------------
/app/views/configuration/accounts.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
34 |
35 |
36 |
37 |
42 |
43 |
44 | {{ message.content }}
45 |
46 |
47 |
49 |
50 |
51 |
52 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/app/scripts/controllers/connect.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:ConnectController
6 | * @description
7 | * # ConnectController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('ConnectController', function(PlexServer, RavenTags, $location, $rootScope, $scope) {
12 | $scope.state = '';
13 | $scope.selected = null;
14 |
15 | $scope.servers = [];
16 |
17 | plex.cloud['/api'].resources(true).then(function(response) {
18 | // Retrieve devices
19 | var data = response.data,
20 | devices = data.MediaContainer.Device;
21 |
22 | if(typeof devices.length === 'undefined') {
23 | devices = [devices];
24 | }
25 |
26 | // Filter devices to servers only
27 | var servers = _.filter(devices, function(device) {
28 | return device._provides === 'server';
29 | });
30 |
31 | $scope.$apply(function() {
32 | // Build `Server` objects
33 | $scope.servers = _.map(servers, function (server) {
34 | return PlexServer.fromElement(server);
35 | });
36 | });
37 | });
38 |
39 | $scope.select = function(server) {
40 | server.connect().then(function() {
41 | console.log('Connection successful');
42 |
43 | server.authenticate().then(function() {
44 | console.log('Authentication successful');
45 |
46 | // Update current server
47 | $rootScope.$s = server;
48 |
49 | // Update raven tags
50 | RavenTags.update({
51 | plugin_version: server.plugin_version
52 | });
53 |
54 | // Redirect to original view
55 | $scope.$r.redirect();
56 | }, function() {
57 | console.warn('Authentication failed');
58 |
59 | $scope.state = '';
60 | });
61 | }, function() {
62 | console.warn('Unable to find valid connection');
63 |
64 | $scope.state = '';
65 | });
66 |
67 | $scope.state = 'connecting';
68 | $scope.selected = server;
69 | };
70 | });
71 |
--------------------------------------------------------------------------------
/app/scripts/models/authentication/main.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('AccountAuthentication', function(BaseAuthentication, PlexAuthentication, TraktAuthentication, Utils) {
5 | function AccountAuthentication() {
6 | this.plex = new PlexAuthentication();
7 | this.trakt = new TraktAuthentication();
8 |
9 | // State
10 | this.messages = [];
11 | this.state = '';
12 | }
13 |
14 | AccountAuthentication.prototype.appendMessage = function(type, content) {
15 | this.messages.push({
16 | type: type,
17 | content: content
18 | });
19 | };
20 |
21 | AccountAuthentication.prototype.check = function() {
22 | this.plex.check();
23 | this.trakt.check();
24 |
25 | // Retrieve message states
26 | var states = _.map(this.messages, function(message) {
27 | return message.type;
28 | });
29 |
30 | // Extend `states` with authentication states
31 | states.push(this.plex.state);
32 | states.push(this.trakt.state);
33 |
34 | // Select highest severity state
35 | this.state = BaseAuthentication.selectPriorityState(states, 'bottom');
36 | };
37 |
38 | AccountAuthentication.prototype.clear = function() {
39 | // Clear messages
40 | this.messages = [];
41 |
42 | // Clear children
43 | this.plex.clear();
44 | this.trakt.clear();
45 |
46 | // Update state
47 | this.check();
48 | };
49 |
50 | AccountAuthentication.prototype.update = function(data) {
51 | // Update handlers
52 | this.plex.update(data.plex);
53 | this.trakt.update(data.trakt);
54 |
55 | // Reset state
56 | this.messages = [];
57 | this.state = '';
58 |
59 | // Check current authentication
60 | this.check();
61 | };
62 |
63 | AccountAuthentication.prototype.onSaveError = function(error) {
64 | if(!Utils.isDefined(error) || !Utils.isDefined(error.message)) {
65 | return;
66 | }
67 |
68 | // Store error message
69 | this.appendMessage('error', error.message);
70 | };
71 |
72 | return AccountAuthentication;
73 | });
74 |
--------------------------------------------------------------------------------
/app/directives/plex/home.html:
--------------------------------------------------------------------------------
1 |
2 |
24 |
25 |
26 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
{{ current._title }}
42 |
43 |
44 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/app/directives/trakt/login.html:
--------------------------------------------------------------------------------
1 | {{ message.content }}
2 |
3 |
4 |
29 |
30 |
31 |
32 |
Authentication PIN Get PIN
33 |
34 |
35 |
36 |
50 |
51 |
52 |
53 |
61 |
--------------------------------------------------------------------------------
/app/styles/main.scss:
--------------------------------------------------------------------------------
1 | // bower:scss
2 | // endbower
3 |
4 | @import 'mixins/no-select';
5 |
6 | @import 'components/dropdown';
7 | @import 'components/message';
8 |
9 | @import 'configuration/base';
10 | @import 'configuration/accounts';
11 | @import 'configuration/server';
12 | @import 'configuration/rules';
13 |
14 | @import 'directives/configuration/header';
15 | @import 'directives/configuration/option';
16 | @import 'directives/plex/home';
17 | @import 'directives/plex/login';
18 | @import 'directives/plex/pin';
19 | @import 'directives/trakt/login';
20 | @import 'directives/button-input';
21 |
22 | @import 'modals/deleteAccount';
23 |
24 | @import 'connect';
25 | @import 'login';
26 |
27 | .browsehappy {
28 | margin: 0.2em 0;
29 | background: #ccc;
30 | color: #000;
31 | padding: 0.2em 0;
32 | }
33 |
34 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
35 | display: none !important;
36 | }
37 |
38 | body {
39 | padding: 0;
40 | }
41 |
42 | button, a {
43 | outline: none;
44 | }
45 |
46 | button.secondary:focus {
47 | background-color: #e7e7e7;
48 | }
49 |
50 | button.secondary:hover {
51 | background-color: #b9b9b9;
52 | }
53 |
54 | button:focus {
55 | background-color: #008CBA;
56 | }
57 |
58 | button:hover {
59 | background-color: #007095;
60 | }
61 |
62 | .scroll-container {
63 | max-width: 1280px;
64 |
65 | margin-left: auto;
66 | margin-right: auto;
67 | }
68 |
69 | .footer {
70 | position: fixed;
71 | bottom: 0;
72 | left: 0;
73 |
74 | padding: 5px;
75 |
76 | font-size: 10px;
77 |
78 | i {
79 | font-size: 14px;
80 |
81 | vertical-align: middle;
82 |
83 | color: #CCC;
84 | }
85 |
86 | a:hover i, a.active i {
87 | color: #888;
88 | }
89 |
90 | .extra + .extra {
91 | margin-top: 3px;
92 | }
93 |
94 | .extra.connection .extended {
95 | margin-left: 1px;
96 | }
97 |
98 | .extended {
99 | display: inline-block;
100 | }
101 | }
102 |
103 | @-webkit-keyframes scaleout {
104 | 0% { -webkit-transform: scale(0.0) }
105 | 100% {
106 | -webkit-transform: scale(1.0);
107 | opacity: 0;
108 | }
109 | }
110 |
111 | @keyframes scaleout {
112 | 0% {
113 | transform: scale(0.0);
114 | -webkit-transform: scale(0.0);
115 | } 100% {
116 | transform: scale(1.0);
117 | -webkit-transform: scale(1.0);
118 | opacity: 0;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/app/styles/directives/plex/home.scss:
--------------------------------------------------------------------------------
1 | @import '../../mixins/no-select';
2 |
3 | .accounts .authentication co-plex-home .avatar {
4 | float: none;
5 |
6 | width: 60px;
7 | height: 60px;
8 |
9 | margin-right: 0;
10 |
11 | box-shadow: 0 0 0 4px #AAA;
12 | }
13 |
14 | co-plex-home {
15 | h5, h6 {
16 | line-height: 1.3 !important;
17 | margin-bottom: 10px !important;
18 | }
19 |
20 | .header {
21 | overflow: auto;
22 |
23 | a {
24 | float: left;
25 | }
26 |
27 | h5, h6 {
28 | float: left;
29 | }
30 |
31 | a.back i {
32 | margin-right: 3px;
33 |
34 | color: rgba(0, 0, 0, 0.4);
35 |
36 | font-size: 20px;
37 | line-height: 1.1;
38 | }
39 |
40 | a.back:hover i {
41 | color: rgba(0, 0, 0, 0.6);
42 | }
43 | }
44 |
45 | .user {
46 | margin: 0 auto;
47 | }
48 |
49 | .tile {
50 | position: relative;
51 |
52 | padding: 24px 0;
53 |
54 | background-color: rgba(0, 0, 0, 0.12);
55 | border-radius: 5px 5px 0 0;
56 |
57 | text-align: center;
58 | }
59 |
60 | .title {
61 | display: block;
62 | height: 30px;
63 |
64 | padding: 6px 0 0;
65 |
66 | background-color: #555;
67 | border-radius: 0 0 5px 5px;
68 |
69 | color: #BBB;
70 | font-size: 12px;
71 | text-align: center;
72 | text-overflow: ellipsis;
73 |
74 | overflow: hidden;
75 | white-space: nowrap;
76 | }
77 |
78 | .tags {
79 | position: absolute;
80 | left: 6px;
81 | right: 6px;
82 | bottom: 6px;
83 |
84 | overflow: auto;
85 |
86 | i {
87 | width: 25px;
88 |
89 | background-color: #F3F3F3;
90 | border-radius: 50%;
91 | }
92 |
93 | i.admin {
94 | float: right;
95 |
96 | color: #DFA700;
97 | }
98 |
99 | i.protected {
100 | float: left;
101 |
102 | color: #00A925;
103 | }
104 | }
105 |
106 | .pin-authentication {
107 | padding-bottom: 20px;
108 |
109 | .user {
110 | padding-left: 0;
111 | }
112 |
113 | .fields {
114 | span {
115 | padding: 0 5px;
116 | }
117 |
118 | input[type="password"] {
119 | height: 110px;
120 |
121 | margin-top: 9px;
122 |
123 | font-size: 70px;
124 | text-align: center;
125 | }
126 | }
127 | }
128 |
129 | a.user:hover {
130 | .avatar {
131 | box-shadow: 0 0 0 4px #FFF;
132 | }
133 |
134 | .title {
135 | color: #FFF;
136 | }
137 | }
138 |
139 | .user,
140 | .tile,
141 | .title {
142 | @include no-select();
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/app/directives/plex/login.html:
--------------------------------------------------------------------------------
1 | {{ message.content }}
2 |
3 |
4 |
23 |
24 |
42 |
43 |
44 |
47 |
48 |
51 |
52 |
53 |
65 |
66 |
67 |
68 |
79 |
--------------------------------------------------------------------------------
/app/scripts/directives/authentication/plex.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coAuthenticationPlex', function(Utils, $modal, $rootScope) {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | account: '=coAccount',
9 | plex: '=coPlex'
10 | },
11 | templateUrl: 'directives/authentication/plex.html',
12 |
13 | controller: function($scope, $timeout) {
14 | $scope._state = null;
15 |
16 | $scope.isAuthenticated = function() {
17 | return !!(
18 | Utils.isDefined($scope.plex.title) &&
19 | $scope.plex.title.length !== 0
20 | );
21 | };
22 |
23 | $scope.state = function(value) {
24 | if(Utils.isDefined(value)) {
25 | $scope._state = value;
26 | return;
27 | }
28 |
29 | if(!Utils.isDefined($scope.plex)) {
30 | // Not initialized yet
31 | return 'view';
32 | }
33 |
34 | if(!$scope.isAuthenticated()) {
35 | // Account hasn't been authenticated yet
36 | return 'edit';
37 | }
38 |
39 | if(Utils.isDefined($scope._state)) {
40 | return $scope._state;
41 | }
42 |
43 | return 'view';
44 | };
45 |
46 | $scope.disconnect = function() {
47 | // Create new scope for modal
48 | var scope = $scope.$new(true);
49 | scope.account = $scope.account;
50 | scope.username = $scope.plex.title;
51 |
52 | // Create modal
53 | var modal = $modal.open({
54 | templateUrl: 'modals/disconnectAccount.html',
55 | windowClass: 'small',
56 | scope: scope
57 | });
58 |
59 | // Display modal, wait for result
60 | return modal.result.then(function() {
61 | // Delete plex account on server
62 | return $scope.plex.delete($rootScope.$s).then(function() {
63 | $rootScope.$broadcast('account.plex.deleted');
64 | });
65 | });
66 | };
67 |
68 | $scope.switch = function(state) {
69 | $scope.state(state);
70 | };
71 |
72 | $scope.onAuthenticated = function(token, user) {
73 | $scope.plex.updateAuthorization(token, user);
74 | $scope.state('view');
75 | };
76 |
77 | $scope.onCancelled = function() {
78 | $scope.state('view');
79 | };
80 |
81 | // Watch for account changes
82 | $scope.$watch(
83 | function(scope) { return scope.plex; },
84 | function() {
85 | $scope._state = null;
86 |
87 | // Broadcast events
88 | $scope.$broadcast('reset');
89 |
90 | $timeout(function() {
91 | $scope.$broadcast('activate');
92 | });
93 | }
94 | );
95 | }
96 | };
97 | });
98 |
--------------------------------------------------------------------------------
/app/scripts/directives/configuration/option.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coConfigurationOption', function() {
5 | var descriptionBottomOffset = 20;
6 |
7 | return {
8 | restrict: 'E',
9 | scope: {
10 | option: '='
11 | },
12 | template: ' ',
13 | controller: function($scope, $element) {
14 | var baseUrl = 'directives/configuration/option/',
15 | templateMap = {
16 | boolean: 'boolean.html',
17 | enum: 'enum.html',
18 | integer: 'integer.html',
19 | string: 'string.html'
20 | };
21 |
22 | $scope.descriptionOpened = false;
23 |
24 | $scope.getTemplateUrl = function() {
25 | return baseUrl + templateMap[$scope.option.type];
26 | };
27 |
28 | $scope.parseKey = function(id) {
29 | if(id === 'null') {
30 | return null;
31 | }
32 |
33 | // Try parse integer from string
34 | var number = parseInt(id, 10);
35 |
36 | if(!isNaN(number) && number.toString() === id) {
37 | return number;
38 | }
39 |
40 | // Return original string
41 | return id;
42 | };
43 |
44 | $scope.closeDescription = function() {
45 | $scope.descriptionOpened = false;
46 | };
47 |
48 | $scope.openDescription = function() {
49 | $($element).css('top', '');
50 |
51 | $scope.descriptionOpened = true;
52 | };
53 | },
54 | link: function(scope, element, attrs) {
55 | var $description = null;
56 |
57 | $(element).hover(function() {
58 | if($description == null) {
59 | $description = $('.description', element);
60 | }
61 |
62 | var documentHeight = $(document).height();
63 |
64 | // Open description
65 | $description.addClass('visible');
66 |
67 | if($description.css('display') != 'flex') {
68 | // Small screens (description is displayed in a modal)
69 | $description.removeClass('visible');
70 | return;
71 | }
72 |
73 | // Position description (ensure it is within the document height)
74 | var offset = $description.offset(),
75 | bottom = offset.top + $description.height() + descriptionBottomOffset,
76 | hidden = bottom - documentHeight;
77 |
78 | if(hidden > 0) {
79 | $description.css('top', (-hidden) + 'px');
80 | }
81 | }, function() {
82 | if($description == null) {
83 | $description = $('.description', element);
84 | }
85 |
86 | // Reset offset
87 | $description.css('top', '');
88 |
89 | // Close description
90 | $description.removeClass('visible');
91 | })
92 | }
93 | };
94 | });
95 |
--------------------------------------------------------------------------------
/app/scripts/models/configuration/rules/user/rule.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('UserRule', function() {
5 | function UserRule(collection, data, state) {
6 | this.collection = collection;
7 |
8 | this.id = null;
9 |
10 | this.name = null;
11 |
12 | this.account = null;
13 | this.priority = null;
14 |
15 | this.state = typeof state !== 'undefined' ? state: 'view';
16 |
17 | this.update(data);
18 | }
19 |
20 | UserRule.prototype.delete = function() {
21 | this.collection.delete(this);
22 | };
23 |
24 | UserRule.prototype.edit = function() {
25 | this.collection.reset();
26 |
27 | this.state = 'edit';
28 | };
29 |
30 | UserRule.prototype.focus = function() {
31 | if(typeof this.priority === 'undefined' || this.priority === null) {
32 | return;
33 | }
34 |
35 | var $tr = $('.rules .users .rule.p-' + this.priority);
36 |
37 | if($tr === null || $tr.length === 0) {
38 | console.warn('Unable to find TR element for rule', this);
39 | return;
40 | }
41 |
42 | console.log($tr);
43 |
44 | $('td.account input', $tr).focus();
45 | };
46 |
47 | UserRule.prototype.save = function() {
48 | this.state = 'view';
49 |
50 | // Update account name
51 | var account = this.collection.accountsById[this.account.id];
52 |
53 | if(typeof account === 'undefined') {
54 | this.account.name = 'None';
55 | } else {
56 | this.account.name = account.text;
57 | }
58 | };
59 |
60 | function parseAccount(data) {
61 | if(data.account_function === '@') {
62 | return {
63 | id: '@',
64 | name: 'Map'
65 | }
66 | }
67 |
68 | if(typeof data.account !== 'undefined' && data.account !== null) {
69 | return data.account;
70 | }
71 |
72 | return {
73 | id: '-',
74 | name: 'None'
75 | };
76 | }
77 |
78 | function attributeValue(value) {
79 | if(value === null) {
80 | // Any
81 | return '*';
82 | }
83 |
84 | return value;
85 | }
86 |
87 | UserRule.prototype.current = function() {
88 | var data = {
89 | id: this.id,
90 |
91 | name: this.name,
92 |
93 | account: null,
94 | account_function: null,
95 |
96 | priority: this.priority
97 | };
98 |
99 | // Parse account field
100 | if(this.account.id === '-') {
101 | data['account'] = null;
102 | } else if(this.account.id === '@') {
103 | data['account_function'] = '@';
104 | } else {
105 | data['account'] = this.account.id;
106 | }
107 |
108 | return data;
109 | };
110 |
111 | UserRule.prototype.update = function(data) {
112 | this.original = angular.copy(data);
113 |
114 | this.id = typeof data.id !== 'undefined' ? data.id : null;
115 |
116 | this.name = attributeValue(data.name);
117 |
118 | this.account = parseAccount(data);
119 | this.priority = data.priority;
120 | };
121 |
122 | return UserRule;
123 | });
124 |
--------------------------------------------------------------------------------
/app/scripts/models/options.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('Options', function (StringUtil, $q) {
5 | function parse(groups, options, depth) {
6 | depth = typeof depth !== 'undefined' ? depth : 0;
7 |
8 | for(var i = 0; i < options.length; ++i) {
9 | var option = options[i],
10 | groupName = option.group[depth],
11 | group = groups[groupName];
12 |
13 | if(typeof group === 'undefined') {
14 | group = groups[groupName] = {
15 | name: groupName,
16 | groups: {},
17 |
18 | enabled: null,
19 | options: [],
20 |
21 | order: null
22 | };
23 | }
24 |
25 | // Update group order
26 | if(group.order === null) {
27 | group.order = option.order;
28 | } else if(option.order < group.order) {
29 | group.order = option.order;
30 | }
31 |
32 | // Update group options
33 | if(option.group.length - depth === 1) {
34 | if(StringUtil.endsWith(option.key, '.enabled') && option.type === 'boolean') {
35 | group.enabled = option;
36 | } else {
37 | group.options.push(option);
38 | }
39 | } else {
40 | parse(group.groups, [option], depth + 1);
41 | }
42 | }
43 | }
44 |
45 | function Options(options, account) {
46 | this.current = options;
47 | this.account = typeof account !== 'undefined' ? account : null;
48 |
49 | this.original = angular.copy(options);
50 |
51 | // Parse options
52 | this.groups = {};
53 |
54 | parse(this.groups, options);
55 | }
56 |
57 | Options.prototype.discard = function() {
58 | for(var i = 0; i < this.current.length; ++i) {
59 | var c = this.current[i],
60 | o = this.original[i];
61 |
62 | c.value = o.value;
63 | }
64 |
65 | return $q.resolve();
66 | };
67 |
68 | Options.prototype.save = function(server) {
69 | var self = this,
70 | changes = {};
71 |
72 | for(var i = 0; i < this.current.length; ++i) {
73 | var c = this.current[i],
74 | o = this.original[i];
75 |
76 | changes[c.key] = {
77 | from: o.value,
78 | to: c.value
79 | };
80 | }
81 |
82 | return server.call('option.update', [], {
83 | changes: changes,
84 | account: this.account !== null ? this.account.id : null
85 | }).then(function(result) {
86 | // Build object of updated options
87 | var updated = _.object(
88 | _.map(
89 | result.updated,
90 | function(key) {
91 | return [key, true];
92 | }
93 | )
94 | );
95 |
96 | // Update `original` option values to match the update
97 | _.each(self.original, function(option, index) {
98 | if(typeof updated[option.key] === 'undefined') {
99 | return;
100 | }
101 |
102 | option.value = self.current[index].value;
103 | });
104 | }, function(error) {
105 | return $q.reject(error);
106 | });
107 | };
108 |
109 | return Options;
110 | });
111 |
--------------------------------------------------------------------------------
/app/scripts/models/configuration/rules/client/rule.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('ClientRule', function() {
5 | function ClientRule(collection, data, state) {
6 | this.collection = collection;
7 |
8 | this.id = null;
9 |
10 | this.key = null;
11 | this.name = null;
12 | this.address = null;
13 |
14 | this.account = null;
15 | this.priority = null;
16 |
17 | this.state = typeof state !== 'undefined' ? state: 'view';
18 |
19 | this.update(data);
20 | }
21 |
22 | ClientRule.prototype.delete = function() {
23 | this.collection.delete(this);
24 | };
25 |
26 | ClientRule.prototype.edit = function() {
27 | this.collection.reset();
28 |
29 | this.state = 'edit';
30 | };
31 |
32 | ClientRule.prototype.focus = function() {
33 | if(typeof this.priority === 'undefined' || this.priority === null) {
34 | return;
35 | }
36 |
37 | var $tr = $('.rules .clients .rule.p-' + this.priority);
38 |
39 | if($tr === null || $tr.length === 0) {
40 | console.warn('Unable to find TR element for rule', this);
41 | return;
42 | }
43 |
44 | console.log($tr);
45 |
46 | $('td.account input', $tr).focus();
47 | };
48 |
49 | ClientRule.prototype.save = function() {
50 | this.state = 'view';
51 |
52 | // Update account name
53 | var account = this.collection.accountsById[this.account.id];
54 |
55 | if(typeof account === 'undefined') {
56 | this.account.name = 'None';
57 | } else {
58 | this.account.name = account.text;
59 | }
60 | };
61 |
62 | function parseAccount(data) {
63 | if(data.account_function === '@') {
64 | return {
65 | id: '@',
66 | name: 'Map'
67 | }
68 | }
69 |
70 | if(typeof data.account !== 'undefined' && data.account !== null) {
71 | return data.account;
72 | }
73 |
74 | return {
75 | id: '-',
76 | name: 'None'
77 | };
78 | }
79 |
80 | function attributeValue(value) {
81 | if(value === null) {
82 | // Any
83 | return '*';
84 | }
85 |
86 | return value;
87 | }
88 |
89 | ClientRule.prototype.current = function() {
90 | var data = {
91 | id: this.id,
92 |
93 | key: this.key,
94 | name: this.name,
95 | address: this.address,
96 |
97 | account: null,
98 | account_function: null,
99 |
100 | priority: this.priority
101 | };
102 |
103 | // Parse account field
104 | if(this.account.id === '-') {
105 | data['account'] = null;
106 | } else if(this.account.id === '@') {
107 | data['account_function'] = '@';
108 | } else {
109 | data['account'] = this.account.id;
110 | }
111 |
112 | return data;
113 | };
114 |
115 | ClientRule.prototype.update = function(data) {
116 | this.original = angular.copy(data);
117 |
118 | this.id = typeof data.id !== 'undefined' ? data.id : null;
119 |
120 | this.key = attributeValue(data.key);
121 | this.name = attributeValue(data.name);
122 | this.address = attributeValue(data.address);
123 |
124 | this.account = parseAccount(data);
125 | this.priority = data.priority;
126 | };
127 |
128 | return ClientRule;
129 | });
130 |
--------------------------------------------------------------------------------
/app/scripts/directives/authentication/trakt.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coAuthenticationTrakt', function(Utils, $modal, $rootScope) {
5 | return {
6 | restrict: 'E',
7 | scope: {
8 | account: '=coAccount',
9 | trakt: '=coTrakt'
10 | },
11 | templateUrl: 'directives/authentication/trakt.html',
12 |
13 | controller: function($scope) {
14 | $scope._state = null;
15 |
16 | $scope.isAuthenticated = function() {
17 | return !!(
18 | Utils.isDefined($scope.trakt.username) &&
19 | $scope.trakt.username.length !== 0
20 | );
21 | };
22 |
23 | $scope.state = function(value) {
24 | if(Utils.isDefined(value)) {
25 | $scope._state = value;
26 | return;
27 | }
28 |
29 | if(!Utils.isDefined($scope.trakt)) {
30 | // Not initialized yet
31 | return 'view';
32 | }
33 |
34 | if(!$scope.isAuthenticated()) {
35 | // Account hasn't been authenticated yet
36 | return 'edit';
37 | }
38 |
39 | if(Utils.isDefined($scope._state)) {
40 | return $scope._state;
41 | }
42 |
43 | return 'view';
44 | };
45 |
46 | $scope.disconnect = function() {
47 | // Create new scope for modal
48 | var scope = $scope.$new(true);
49 | scope.account = $scope.account;
50 | scope.username = $scope.trakt.username;
51 |
52 | // Create modal
53 | var modal = $modal.open({
54 | templateUrl: 'modals/disconnectAccount.html',
55 | windowClass: 'small',
56 | scope: scope
57 | });
58 |
59 | // Display modal, wait for result
60 | return modal.result.then(function() {
61 | // Delete trakt account on server
62 | return $scope.trakt.delete($rootScope.$s).then(function() {
63 | $rootScope.$broadcast('account.trakt.deleted');
64 | });
65 | });
66 | };
67 |
68 | $scope.switch = function(state) {
69 | // Change view state
70 | $scope.state(state);
71 | };
72 |
73 | $scope.onBasicAuthenticated = function() {
74 | // Clear messages
75 | $scope.messages = [];
76 |
77 | // Update account details
78 | $scope.trakt.basic.updateAuthorization();
79 | $scope.trakt.updateDetails();
80 |
81 | // Change view state
82 | $scope.state('view');
83 | };
84 |
85 | $scope.onPinAuthenticated = function(authorization, settings) {
86 | // Update account details
87 | $scope.trakt.pin.updateAuthorization(authorization);
88 | $scope.trakt.updateDetails(settings);
89 |
90 | // Change view state
91 | $scope.state('view');
92 | };
93 |
94 | $scope.onCancelled = function() {
95 | // Change view state
96 | $scope.state('view');
97 | };
98 |
99 | // Watch for account changes
100 | $scope.$watch(
101 | function(scope) { return scope.trakt; },
102 | function() {
103 | $scope._state = null;
104 |
105 | // Broadcast reset event to child directives
106 | $scope.$broadcast('reset');
107 | }
108 | );
109 | }
110 | };
111 | });
112 |
--------------------------------------------------------------------------------
/app/scripts/models/authentication/trakt/main.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('TraktAuthentication', function(TraktBasicAuthentication, TraktPinAuthentication, BaseAuthentication, Utils) {
5 | function TraktAuthentication() {
6 | this.original = null;
7 |
8 | // Account details
9 | this.id = null;
10 | this.username = null;
11 | this.thumb_url = null;
12 |
13 | this.basic = new TraktBasicAuthentication(this);
14 | this.pin = new TraktPinAuthentication(this);
15 |
16 | // State
17 | this.messages = [];
18 | this.state = '';
19 | }
20 |
21 | TraktAuthentication.prototype.appendMessage = function(type, content) {
22 | this.messages.push({
23 | type: type,
24 | content: content
25 | });
26 | };
27 |
28 | TraktAuthentication.prototype.check = function() {
29 | // Retrieve message states
30 | var states = _.map(this.messages, function(message) {
31 | return message.type;
32 | });
33 |
34 | // Extend `states` with authentication state
35 | states.push(BaseAuthentication.selectPriorityState([
36 | this.basic.state,
37 | this.pin.state
38 | ]));
39 |
40 | // Select highest severity state
41 | this.state = BaseAuthentication.selectPriorityState(states, 'bottom');
42 | };
43 |
44 | TraktAuthentication.prototype.clear = function(data) {
45 | // Clear messages
46 | this.messages = [];
47 |
48 | // Update state
49 | this.check();
50 | };
51 |
52 | TraktAuthentication.prototype.current = function() {
53 | var data = {
54 | trakt: {}
55 | };
56 |
57 | // Basic
58 | if(this.basic.changed) {
59 | $.extend(true, data.trakt, this.basic.current());
60 | }
61 |
62 | // PIN
63 | if(this.pin.changed) {
64 | $.extend(true, data.trakt, this.pin.current());
65 | }
66 |
67 | return data;
68 | };
69 |
70 | TraktAuthentication.prototype.delete = function(server) {
71 | var self = this;
72 |
73 | return server.call('account.trakt.delete', [], {id: self.id}).then(function(success) {
74 | if(!success) {
75 | return $q.reject();
76 | }
77 | });
78 | };
79 |
80 | TraktAuthentication.prototype.update = function(data) {
81 | this.original = angular.copy(data);
82 |
83 | // Account details
84 | this.id = data.id;
85 | this.username = data.username;
86 | this.thumb_url = data.thumb_url;
87 |
88 | this.basic.update(data.authorization.basic);
89 | this.pin.update(data.authorization.oauth);
90 |
91 | // Reset state
92 | this.messages = [];
93 | this.state = '';
94 | };
95 |
96 | TraktAuthentication.prototype.updateDetails = function(settings) {
97 | if(!Utils.isDefined(settings) || !Utils.isDefined(settings.user)) {
98 | this.username = 'Unknown';
99 | this.thumb_url = null;
100 |
101 | // TODO Add message to indicate a save is required (for basic authorization)
102 | return;
103 | }
104 |
105 | var avatar = settings.user.images.avatar,
106 | user = settings.user;
107 |
108 | this.username = user.username;
109 | this.thumb_url = avatar.full;
110 | };
111 |
112 | TraktAuthentication.prototype.onSaveError = function(error) {
113 | if(!Utils.isDefined(error) || !Utils.isDefined(error.message)) {
114 | return;
115 | }
116 |
117 | // Store error message
118 | this.appendMessage('error', error.message);
119 | };
120 |
121 | return TraktAuthentication;
122 | });
123 |
--------------------------------------------------------------------------------
/app/scripts/models/plex/connection_manager.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('PlexConnectionManager', function($http, $q) {
5 | function PlexConnectionManager(server, connections) {
6 | this.server = server;
7 | this.available = connections;
8 |
9 | this.client = null;
10 | this.current = null;
11 | }
12 |
13 | PlexConnectionManager.prototype.reset = function() {
14 | // Update server
15 | this.client = null;
16 | this.current = null;
17 |
18 | this.server.client = null;
19 | };
20 |
21 | PlexConnectionManager.prototype.test = function() {
22 | var self = this,
23 | connections = angular.copy(self.available),
24 | deferred = $q.defer(),
25 | error = 'Unable to find valid connection';
26 |
27 | console.debug('[%s] Testing %s connections', self.server.identifier, connections.length);
28 |
29 | function testOne() {
30 | var connection = connections.shift();
31 |
32 | if(connection == null) {
33 | deferred.reject(error);
34 | return;
35 | }
36 |
37 | self.testConnection(connection).then(function(connection) {
38 | deferred.resolve(connection);
39 | }, function(connectionError) {
40 | if(typeof connectionError !== 'undefined' && connectionError !== null) {
41 | error = connectionError;
42 | }
43 |
44 | testOne();
45 | });
46 | }
47 |
48 | // Reset current connection details
49 | self.reset();
50 |
51 | // Start testing connections
52 | testOne();
53 |
54 | return deferred.promise;
55 | };
56 |
57 | PlexConnectionManager.prototype.testConnection = function(connection) {
58 | var self = this,
59 | client = new plex.Server(connection.uri);
60 |
61 | console.debug('[%s] Testing connection: %s', self.server.identifier, connection.uri);
62 |
63 | // Setup client
64 | client.client_identifier = localStorage['plex.client.identifier'];
65 | client.token = self.server.token_plex;
66 |
67 | client.http.headers.setProduct('trakt (for Plex) - Configuration', '1.0.0');
68 | client.http.xmlParser = 'x2js';
69 |
70 | // Ensure we don't attempt an http:// connection while https:// is being used
71 | if(window.location.protocol === 'https:' && connection.uri.startsWith('http:')) {
72 | return $q.reject("Only secure servers are supported when browsing over https://");
73 | }
74 |
75 | // Test connection
76 | var deferred = $q.defer();
77 |
78 | client.identity({
79 | timeout: 1500
80 | }).then(function(response) {
81 | var data = response.data,
82 | connectionIdentifier = data.MediaContainer._machineIdentifier;
83 |
84 | if(connectionIdentifier !== self.server.identifier) {
85 | console.debug("Connection identifier %s doesn't match the server identifier %s", connectionIdentifier, self.server.identifier);
86 | deferred.reject();
87 | return;
88 | }
89 |
90 | console.log('[%s] Using connection: %s', self.server.identifier, connection.uri);
91 |
92 | // Update connection
93 | connection.client = client;
94 |
95 | // Update server
96 | self.client = client;
97 | self.current = connection;
98 |
99 | self.server.client = client;
100 |
101 | // Resolve promise
102 | deferred.resolve(connection);
103 | }, function() {
104 | deferred.reject();
105 | });
106 |
107 | return deferred.promise;
108 | };
109 |
110 | return PlexConnectionManager;
111 | });
112 |
--------------------------------------------------------------------------------
/app/scripts/models/authentication/plex.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('PlexAuthentication', function(BaseAuthentication, Utils, $q) {
5 | function PlexAuthentication() {
6 | this.changed = false;
7 | this.original = null;
8 |
9 | // Account details
10 | this.id = null;
11 | this.username = null;
12 |
13 | this.title = null;
14 | this.thumb_url = null;
15 |
16 | // Authorization details
17 | this.authorization = null;
18 |
19 | // State
20 | this.messages = [];
21 | this.state = '';
22 | }
23 |
24 | PlexAuthentication.prototype.appendMessage = function(type, content) {
25 | this.messages.push({
26 | type: type,
27 | content: content
28 | });
29 | };
30 |
31 | PlexAuthentication.prototype.check = function() {
32 | if((!Utils.isDefined(this.title) ||
33 | this.title.length === 0) &&
34 | this.authorization.basic.state === 'valid') {
35 | // Update warnings
36 | this.appendMessage('warning', "Account hasn't completed the authentication process");
37 |
38 | // Update state
39 | this.authorization.basic.state = 'warning';
40 | }
41 |
42 | // Retrieve message states
43 | var states = _.map(this.messages, function(message) {
44 | return message.type;
45 | });
46 |
47 | // Extend `states` with authentication states
48 | states.push(this.authorization.basic.state);
49 |
50 | // Update state
51 | this.state = BaseAuthentication.selectPriorityState(states, 'bottom');
52 | };
53 |
54 | PlexAuthentication.prototype.clear = function(data) {
55 | // Clear error messages
56 | this.messages = [];
57 |
58 | // Update state
59 | this.check();
60 | };
61 |
62 | PlexAuthentication.prototype.current = function() {
63 | if(!this.changed) {
64 | return {};
65 | }
66 |
67 | return {
68 | plex: {
69 | username: this.username,
70 |
71 | authorization: {
72 | basic: {
73 | token_plex: this.authorization.basic.token_plex
74 | }
75 | }
76 | }
77 | };
78 | };
79 |
80 | PlexAuthentication.prototype.delete = function(server) {
81 | var self = this;
82 |
83 | return server.call('account.plex.delete', [], {id: self.id}).then(function(success) {
84 | if(!success) {
85 | return $q.reject();
86 | }
87 | });
88 | };
89 |
90 | PlexAuthentication.prototype.update = function(data) {
91 | this.changed = false;
92 | this.original = angular.copy(data);
93 |
94 | // Account details
95 | this.id = data.id;
96 | this.username = data.username;
97 |
98 | this.title = data.title;
99 | this.thumb_url = data.thumb_url;
100 |
101 | // Authorization details
102 | this.authorization = data.authorization;
103 |
104 | // State
105 | this.messages = [];
106 | this.state = '';
107 | };
108 |
109 | PlexAuthentication.prototype.updateAuthorization = function(token_plex, user) {
110 | // Update account details
111 | this.username = user.username;
112 |
113 | this.title = user._title;
114 | this.thumb_url = user._thumb;
115 |
116 | // Update token
117 | this.authorization.basic.token_plex = token_plex;
118 |
119 | // Set `changed` flag
120 | this.changed = true;
121 | };
122 |
123 | PlexAuthentication.prototype.onSaveError = function(error) {
124 | if(!Utils.isDefined(error) || !Utils.isDefined(error.message)) {
125 | return;
126 | }
127 |
128 | // Store error message
129 | this.appendMessage('error', error.message);
130 | };
131 |
132 | return PlexAuthentication;
133 | });
134 |
--------------------------------------------------------------------------------
/app/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Page Not Found :(
6 |
136 |
137 |
138 |
139 |
Not found :(
140 |
Sorry, but the page you were trying to view does not exist.
141 |
It looks like this was the result of either:
142 |
143 | a mistyped address
144 | an out-of-date link
145 |
146 |
149 |
150 |
151 |
152 |
153 |
--------------------------------------------------------------------------------
/app/scripts/controllers/configuration/rules.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:RulesController
6 | * @description
7 | * # RulesController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('RulesController', function (ClientRuleCollection, UserRuleCollection, $q, $rootScope, $scope) {
12 | $scope.client = new ClientRuleCollection();
13 | $scope.user = new UserRuleCollection();
14 |
15 | // Setup sortable rule tables
16 | $('.rules table').sortable({
17 | handle: 'a.move',
18 |
19 | containerSelector: 'table',
20 | itemPath: '> tbody',
21 | itemSelector: 'tr',
22 | placeholder: ' ',
23 |
24 | onDragStart: function($item, container, _super) {
25 | var node = $item[0],
26 | tbody = node.parentNode,
27 | index = Array.prototype.indexOf.call(tbody.children, $item[0]);
28 |
29 | $item.addClass(container.group.options.draggedClass);
30 | $("body").addClass(container.group.options.bodyClass);
31 |
32 | $item.data('drag-from', index);
33 | },
34 | onDrop: function($item, container, _super) {
35 | var node = $item[0],
36 | tbody = node.parentNode,
37 | from = $item.data('drag-from'),
38 | to = Array.prototype.indexOf.call(tbody.children, $item[0]);
39 |
40 | // Update dragged item
41 | $item.removeClass(container.group.options.draggedClass).removeAttr("style");
42 |
43 | // Update cursor
44 | $("body").removeClass(container.group.options.bodyClass);
45 |
46 | // Retrieve collection
47 | var $table = $item.parents('table'),
48 | collectionName = $table.data('collection'),
49 | collection = null;
50 |
51 | if(collectionName === 'client') {
52 | collection = $scope.client;
53 | } else if(collectionName === 'user') {
54 | collection = $scope.user;
55 | } else {
56 | console.warn('Unable to find collection with the name "%s"', collectionName);
57 | return;
58 | }
59 |
60 | // Retrieve rule from `collection`
61 | var item = collection.rules[from];
62 |
63 | if(typeof item === 'undefined') {
64 | console.warn('Unable to retrieve item with index %s', from);
65 | return;
66 | }
67 |
68 | // Move rule inside `collection`
69 | collection.rules.splice(from, 1);
70 | collection.rules.splice(to, 0, item);
71 |
72 | // Update rule priorities
73 | collection.updatePriorities();
74 |
75 | $scope.$apply();
76 | }
77 | });
78 |
79 | $scope.refresh = function() {
80 | var promises = [
81 | // Retrieve accounts
82 | $rootScope.$s.call('account.list').then(function(accounts) {
83 | // Filter accounts (remove "server" account)
84 | accounts = _.filter(accounts, function (account) {
85 | return account.id > 0 && !account.deleted;
86 | });
87 |
88 | // Map accounts
89 | accounts = _.map(accounts, function(account) {
90 | return {
91 | $order: 10,
92 | type: 'name',
93 |
94 | value: account.id,
95 | text: account.name
96 | }
97 | });
98 |
99 | // Update collections
100 | $scope.client.updateAccounts(accounts);
101 | $scope.user.updateAccounts(accounts);
102 | }, function() {
103 | return $q.reject('Unable to retrieve accounts');
104 | }),
105 |
106 | // Refresh rule collections
107 | $scope.client.refresh(),
108 | $scope.user.refresh()
109 | ];
110 |
111 | return $q.all(promises);
112 | };
113 |
114 | $scope.attributeLabel = function(value) {
115 | if(value === null || value === '*') {
116 | return 'Any';
117 | }
118 |
119 | if(value === '@') {
120 | return 'Map';
121 | }
122 |
123 | return value;
124 | };
125 |
126 | // Initial rules refresh
127 | $scope.refresh();
128 | });
129 |
--------------------------------------------------------------------------------
/app/scripts/services/authentication.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('Authentication', function(RavenTags, Utils, $http, $q, $rootScope) {
5 | var identifierSalt = 'MLawOtoMiFf5Ni9nbu0bTes2+UkrVLMZ8LSPwA+qTtA=',
6 | tokenRegex = /^server\.\w+\.((token_channel)|(token_channel_expire)|(token_plex))$/,
7 | user = null;
8 |
9 | function updateAuthentication(authenticated, user) {
10 | // Update authentication scope
11 | $rootScope.$a = {
12 | authenticated: Utils.isDefined(authenticated) ? authenticated : false,
13 | user: Utils.isDefined(user) ? user : null
14 | };
15 |
16 | // Update server scope
17 | if(!$rootScope.$a.authenticated) {
18 | $rootScope.$s = null;
19 | }
20 |
21 | // Update plex.js token
22 | if(Utils.isDefined(user)) {
23 | plex.cloud.token = user._authenticationToken;
24 | } else {
25 | plex.cloud.token = null;
26 | }
27 | }
28 |
29 | updateAuthentication();
30 |
31 | var Authentication = {
32 | authenticated: function() {
33 | return user !== null;
34 | },
35 | user: function(value) {
36 | if(value !== undefined) {
37 | // Update user details
38 | user = value;
39 |
40 | // Update raven context
41 | if(value !== null) {
42 | RavenTags.update({
43 | user_identifier: typeof user.username !== 'undefined' ?
44 | md5(user.username + identifierSalt) :
45 | null
46 | });
47 | } else {
48 | RavenTags.update({
49 | user_identifier: null
50 | });
51 | }
52 |
53 | return value;
54 | }
55 |
56 | return user;
57 | },
58 | token: function(value) {
59 | if(value === undefined) {
60 | return localStorage['plex.token'];
61 | }
62 |
63 | if(value === null) {
64 | delete localStorage['plex.token'];
65 | } else {
66 | localStorage['plex.token'] = value;
67 | }
68 |
69 | return value;
70 | },
71 | login: function(token, user) {
72 | if(Utils.isDefined(token) && Utils.isDefined(user)) {
73 | // Update with new authentication
74 | this.token(token);
75 | this.user(user);
76 |
77 | updateAuthentication(true, user);
78 | return true;
79 | }
80 |
81 | // Clear current authentication
82 | updateAuthentication();
83 | return false;
84 | },
85 | logout: function() {
86 | // Destroy plex authentication details
87 | this.token(null);
88 | this.user(null);
89 |
90 | // Destroy server tokens
91 | var deleteKeys = [];
92 |
93 | for(var i = 0; i < localStorage.length; ++i) {
94 | var key = localStorage.key(i);
95 |
96 | if(tokenRegex.exec(key) === null) {
97 | continue;
98 | }
99 |
100 | deleteKeys.push(key);
101 | }
102 |
103 | _.each(deleteKeys, function(key) {
104 | delete localStorage[key];
105 | });
106 |
107 | updateAuthentication();
108 | },
109 | get: function() {
110 | var deferred = $q.defer(),
111 | self = this,
112 | token = self.token(),
113 | user = self.user();
114 |
115 | if(typeof token === 'undefined' || token === null) {
116 | deferred.reject();
117 | return deferred.promise;
118 | }
119 |
120 | if(user !== null) {
121 | deferred.resolve(user);
122 | return deferred.promise;
123 | }
124 |
125 | // Send request
126 | plex.cloud['/users'].account(token).then(function(response) {
127 | var data = response.data;
128 |
129 | self.user(data.user);
130 |
131 | updateAuthentication(true, data.user);
132 |
133 | deferred.resolve(data.user);
134 | }, function() {
135 | updateAuthentication();
136 |
137 | deferred.reject();
138 | });
139 |
140 | return deferred.promise;
141 | }
142 | };
143 |
144 | return Authentication;
145 | });
146 |
--------------------------------------------------------------------------------
/app/scripts/controllers/configuration/accounts.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc function
5 | * @name configurationApp.controller:AccountsController
6 | * @description
7 | * # AccountsController
8 | * Controller of the configurationApp
9 | */
10 | angular.module('configurationApp')
11 | .controller('AccountsController', function (Account, Utils, $location, $modal, $q, $rootScope, $routeParams, $scope) {
12 | $scope.accounts = {};
13 | $scope.account = null;
14 |
15 | function selectAccount(id) {
16 | id = parseInt(id);
17 |
18 | if(id === null || isNaN(id)) {
19 | $scope.account = null;
20 | return $q.reject();
21 | }
22 |
23 | if(Utils.isDefined($scope.accounts[id])) {
24 | // Set current account
25 | $scope.account = $scope.accounts[id];
26 | } else if(Object.keys($scope.accounts).length > 0) {
27 | // Missing account, select the first account
28 | return selectFirstAccount();
29 | } else {
30 | return $q.reject();
31 | }
32 |
33 | // Initial account preferences refresh
34 | return $scope.accountRefresh();
35 | }
36 |
37 | function selectFirstAccount() {
38 | // Update selected account parameter
39 | $location.search('id', Object.keys($scope.accounts)[0]);
40 |
41 | return $q.resolve();
42 | }
43 |
44 | function select() {
45 | if(typeof $routeParams.id !== 'undefined') {
46 | // Load account from parameter
47 | return selectAccount($routeParams.id);
48 | }
49 |
50 | // Load first account
51 | return selectFirstAccount();
52 | }
53 |
54 | $scope.accountRefresh = function() {
55 | if(typeof $scope.account === 'undefined' || $scope.account === null) {
56 | // Currently selected account has been removed
57 | return $q.reject();
58 | }
59 |
60 | return $scope.account.refresh($rootScope.$s);
61 | };
62 |
63 | $scope.accountDiscard = function() {
64 | return $scope.account.discard();
65 | };
66 |
67 | $scope.accountSave = function() {
68 | return $scope.account.save($rootScope.$s);
69 | };
70 |
71 | $scope.accountDelete = function() {
72 | var modal = $modal.open({
73 | templateUrl: 'modals/deleteAccount.html',
74 | windowClass: 'small',
75 | scope: $scope
76 | });
77 |
78 | return modal.result.then(function() {
79 | return $scope.account.delete($rootScope.$s).then(function () {
80 | // Refresh accounts
81 | return $scope.refresh();
82 | });
83 | });
84 | };
85 |
86 | $scope.create = function(name) {
87 | if(name === null || name === '') {
88 | return $q.reject('Name is required');
89 | }
90 |
91 | return $rootScope.$s.call('account.create', [], {name: name}).then(function() {
92 | // Refresh accounts
93 | return $scope.refresh();
94 | }, function(error) {
95 | if(error === null || typeof error.message !== 'string') {
96 | return $q.reject('Unknown error');
97 | }
98 |
99 | return $q.reject(error.message);
100 | });
101 | };
102 |
103 | $scope.refresh = function() {
104 | // Retrieve accounts from server
105 | return $rootScope.$s.call('account.list', [], {full: true}).then(function (accounts) {
106 | // Parse accounts
107 | $scope.accounts = _.indexBy(_.map(_.filter(accounts, function (account) {
108 | return account.id > 0 && !account.deleted;
109 | }), function (account) {
110 | return new Account(account);
111 | }), function (account) {
112 | return account.id;
113 | });
114 |
115 | // Trigger initial account load
116 | return select();
117 | }, function() {
118 | return $q.reject();
119 | });
120 | };
121 |
122 | // Initial account refresh
123 | $scope.refresh();
124 |
125 | // Refresh when authentication accounts are deleted
126 | $scope.$on('account.plex.deleted', function() { $scope.refresh(); });
127 | $scope.$on('account.trakt.deleted', function() { $scope.refresh(); });
128 |
129 | // Watch for selected account change
130 | $scope.$on('$routeUpdate', function() {
131 | select();
132 | });
133 | });
134 |
--------------------------------------------------------------------------------
/app/scripts/models/account.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('Account', function(AccountAuthentication, Differ, Options, Utils, $q) {
5 | var defaults = {
6 | plex: {
7 | authorization: {
8 | basic: { valid: false }
9 | }
10 | },
11 | trakt: {
12 | authorization: {
13 | basic: { valid: false },
14 | oauth: { valid: false }
15 | }
16 | }
17 | };
18 |
19 | function Account(data) {
20 | this.authentication = new AccountAuthentication();
21 |
22 | this.original = null;
23 | this.options = null;
24 |
25 | this.id = null;
26 | this.name = null;
27 |
28 | this.thumb_url = null;
29 |
30 | // Update with initial data
31 | this.update(data);
32 | }
33 |
34 | Account.prototype.current = function() {
35 | var self = this,
36 | promises = [
37 | this.authentication.plex.current(),
38 | this.authentication.trakt.current()
39 | ];
40 |
41 | return $q.all(promises).then(function(results) {
42 | var current = {
43 | name: self.name
44 | };
45 |
46 | _.each(results, function(result) {
47 | $.extend(true, current, result);
48 | });
49 |
50 | return current;
51 | }, function(error) {
52 | return $q.reject(error);
53 | });
54 | };
55 |
56 | Account.prototype.update = function(data) {
57 | // Set defaults
58 | data = $.extend(true, angular.copy(defaults), data);
59 |
60 | // Copy original values for the differ
61 | this.original = angular.copy(data);
62 |
63 | // Update attributes
64 | this.id = data.id;
65 | this.name = data.name;
66 |
67 | this.thumb_url = data.thumb_url;
68 |
69 | this.authentication.update(data);
70 | };
71 |
72 | Account.prototype.refresh = function(server) {
73 | var self = this;
74 |
75 | // Retrieve account options
76 | return $q.all([
77 | server.call('account.get', [], {full: true, id: self.id}).then(function(data) {
78 | self.update(data);
79 | }),
80 | server.call('option.list', [], {account: self.id}).then(function(options) {
81 | self.options = new Options(options, self);
82 | })
83 | ]);
84 | };
85 |
86 | Account.prototype.delete = function(server) {
87 | var self = this;
88 |
89 | return server.call('account.delete', [], {id: self.id}).then(function(success) {
90 | if(!success) {
91 | return $q.reject();
92 | }
93 | });
94 | };
95 |
96 | Account.prototype.discard = function() {
97 | // Discard account authentication/details
98 | this.update(this.original);
99 |
100 | // Discard account options
101 | return this.options.discard();
102 | };
103 |
104 | Account.prototype.save = function(server) {
105 | var self = this;
106 |
107 | // Clear authentication errors
108 | this.authentication.clear();
109 |
110 | // Save account changes
111 | return $q.all([
112 | // Update account details
113 | this.current().then(function(data) {
114 | // Send changes to server
115 | return server.call('account.update', [], {id: self.id, data: data})
116 | .then(function(account) {
117 | // Account updated successfully, update local data
118 | self.update(account);
119 | });
120 | }),
121 |
122 | // Update account options
123 | this.options.save(server)
124 | ]).catch(function(error) {
125 | // Unable to save changes to server
126 | self.handleError(error);
127 | return $q.reject(error);
128 | });
129 | };
130 |
131 | Account.prototype.handleError = function(error) {
132 | if(!Utils.isDefined(error) || !Utils.isDefined(error.code) || !Utils.isDefined(error.message)) {
133 | return;
134 | }
135 |
136 | // Process error
137 | if(error.code.indexOf("error.account.trakt.") === 0) {
138 | this.authentication.trakt.onSaveError(error);
139 | } else if(error.code.indexOf("error.account.plex.") === 0) {
140 | this.authentication.plex.onSaveError(error);
141 | } else {
142 | this.authentication.onSaveError(error);
143 | }
144 |
145 | // Check current authentication state
146 | this.authentication.check();
147 | };
148 |
149 | return Account;
150 | });
151 |
--------------------------------------------------------------------------------
/app/scripts/models/configuration/rules/user/collection.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('UserRuleCollection', function(UserRule, $q, $rootScope, $timeout) {
5 | var accountFunctions = [
6 | { $order: 1, value: '-', text: 'None' },
7 | { $order: 2, value: '@', text: 'Map' }
8 | ],
9 | attributeFunctions = [
10 | { $order: 1, value: '*', text: 'Any' }
11 | ];
12 |
13 | function UserRuleCollection() {
14 | this.available = {
15 | names: null
16 | };
17 |
18 | this.accounts = null;
19 | this.accountsById = null;
20 |
21 | this.original = null;
22 | this.rules = null;
23 | }
24 |
25 | UserRuleCollection.prototype.create = function() {
26 | // Reset rule states to "view"
27 | this.reset();
28 |
29 | // Create new rule
30 | var rule = new UserRule(
31 | this,
32 | {
33 | name: '*',
34 |
35 | account: {
36 | id: '-',
37 | name: 'None'
38 | },
39 | priority: this.rules.length + 1
40 | },
41 | 'edit'
42 | );
43 |
44 | this.rules.push(rule);
45 |
46 | // Focus new rule
47 | $timeout(function() {
48 | rule.focus();
49 | })
50 | };
51 |
52 | UserRuleCollection.prototype.delete = function(rule) {
53 | var index = this.rules.indexOf(rule);
54 |
55 | if(index === -1) {
56 | return;
57 | }
58 |
59 | // Delete rule from collection
60 | this.rules.splice(index, 1);
61 |
62 | // Update rule priorities
63 | this.updatePriorities();
64 | };
65 |
66 | UserRuleCollection.prototype.discard = function() {
67 | // Discard rule changes
68 | this.updateRules(this.original);
69 |
70 | // Update rule priorities
71 | this.updatePriorities();
72 |
73 | return $q.resolve();
74 | };
75 |
76 | UserRuleCollection.prototype.refresh = function() {
77 | var self = this;
78 |
79 | return $q.all([
80 | // Retrieve seen users
81 | $rootScope.$s.call('session.user.list').then(
82 | $.proxy(self.updateUsers, self),
83 | function() {
84 | return $q.reject('Unable to retrieve users');
85 | }
86 | ),
87 |
88 | // Retrieve user rules
89 | $rootScope.$s.call('session.user.rule.list', [], {full: true}).then(
90 | $.proxy(self.updateRules, self),
91 | function() {
92 | return $q.reject('Unable to retrieve user rules');
93 | }
94 | )
95 | ]);
96 | };
97 |
98 | UserRuleCollection.prototype.reset = function() {
99 | // Reset rules back to view mode
100 | _.each(this.rules, function(rule) {
101 | rule.save();
102 | });
103 | };
104 |
105 | UserRuleCollection.prototype.save = function() {
106 | var self = this,
107 | current = _.map(this.rules, function(rule) {
108 | return rule.current();
109 | });
110 |
111 | return $rootScope.$s.call('session.user.rule.update', [], {current: current, full: true}).then(
112 | $.proxy(self.updateRules, self),
113 | function() {
114 | return $q.reject('Unable to update user rules');
115 | }
116 | );
117 | };
118 |
119 | UserRuleCollection.prototype.updateAccounts = function(accounts) {
120 | this.accounts = [].concat(accountFunctions, accounts);
121 |
122 | this.accountsById = _.indexBy(this.accounts, 'value');
123 | };
124 |
125 | UserRuleCollection.prototype.updatePriorities = function() {
126 | // Update rule priorities
127 | for(var i = 0; i < this.rules.length; ++i) {
128 | this.rules[i].priority = i + 1;
129 | }
130 | };
131 |
132 | UserRuleCollection.prototype.updateRules = function(rules) {
133 | var self = this;
134 |
135 | // Store original rules
136 | this.original = angular.copy(rules);
137 |
138 | // Parse rules
139 | this.rules = _.map(rules, function(rule) {
140 | return new UserRule(self, rule);
141 | });
142 | };
143 |
144 | UserRuleCollection.prototype.updateUsers = function(users) {
145 | this.available.names = [].concat(attributeFunctions, _.map(users, function (user) {
146 | return {
147 | $order: 10,
148 | type: 'name',
149 |
150 | value: user.name,
151 | text: user.name
152 | };
153 | }));
154 | };
155 |
156 | return UserRuleCollection;
157 | });
158 |
--------------------------------------------------------------------------------
/test/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 | // http://karma-runner.github.io/0.12/config/configuration-file.html
3 | // Generated on 2015-07-01 using
4 | // generator-karma 1.0.0
5 |
6 | module.exports = function(config) {
7 | 'use strict';
8 |
9 | config.set({
10 | // enable / disable watching file and executing tests whenever any file changes
11 | autoWatch: true,
12 |
13 | // base path, that will be used to resolve files and exclude
14 | basePath: '../',
15 |
16 | // testing framework to use (jasmine/mocha/qunit/...)
17 | // as well as any additional frameworks (requirejs/chai/sinon/...)
18 | frameworks: [
19 | "jasmine"
20 | ],
21 |
22 | // list of files / patterns to load in the browser
23 | files: [
24 | // bower:js
25 | 'bower_components/modernizr/modernizr.js',
26 | 'bower_components/jquery/dist/jquery.js',
27 | 'bower_components/angular/angular.js',
28 | 'bower_components/angular-animate/angular-animate.js',
29 | 'bower_components/angular-aria/angular-aria.js',
30 | 'bower_components/angular-cookies/angular-cookies.js',
31 | 'bower_components/angular-messages/angular-messages.js',
32 | 'bower_components/angular-resource/angular-resource.js',
33 | 'bower_components/angular-route/angular-route.js',
34 | 'bower_components/angular-sanitize/angular-sanitize.js',
35 | 'bower_components/spin.js/spin.js',
36 | 'bower_components/angular-spinner/angular-spinner.js',
37 | 'bower_components/angular-touch/angular-touch.js',
38 | 'bower_components/fastclick/lib/fastclick.js',
39 | 'bower_components/jquery.cookie/jquery.cookie.js',
40 | 'bower_components/jquery-placeholder/jquery.placeholder.js',
41 | 'bower_components/foundation/js/foundation.js',
42 | 'bower_components/angular-foundation/mm-foundation-tpls.js',
43 | 'bower_components/ua-parser-js/src/ua-parser.js',
44 | 'bower_components/x2js/xml2json.min.js',
45 | 'bower_components/angular-xml/angular-xml.js',
46 | 'bower_components/underscore/underscore.js',
47 | 'bower_components/cerealizer.js/dist/cerealizer.js',
48 | 'bower_components/ladda-foundation-5/dist/ladda.js',
49 | 'bower_components/trakt.js/dist/trakt.js',
50 | 'bower_components/httpinvoke/httpinvoke-browser.js',
51 | 'bower_components/plex.js/dist/global/when.js',
52 | 'bower_components/plex.js/dist/global/plex.js',
53 | 'bower_components/sifter/sifter.js',
54 | 'bower_components/microplugin/src/microplugin.js',
55 | 'bower_components/selectize/dist/js/selectize.js',
56 | 'bower_components/angular-selectize2/dist/selectize.js',
57 | 'bower_components/jquery-sortable/source/js/jquery-sortable.js',
58 | 'bower_components/raven-js/dist/raven.js',
59 | 'bower_components/angular-raven/angular-raven.js',
60 | 'bower_components/JavaScript-MD5/js/md5.js',
61 | 'bower_components/waypoints/lib/noframework.waypoints.min.js',
62 | 'bower_components/SHA-1/sha1.js',
63 | 'bower_components/marked/lib/marked.js',
64 | 'bower_components/angular-marked/dist/angular-marked.js',
65 | 'bower_components/angular-mocks/angular-mocks.js',
66 | // endbower
67 |
68 | 'bower_components/angulartics/src/angulartics.js',
69 | 'bower_components/angulartics/src/angulartics-ga.js',
70 |
71 | "app/scripts/**/*.js",
72 | "test/mock/**/*.js",
73 | "test/spec/**/*.js"
74 | ],
75 |
76 | // list of files / patterns to exclude
77 | exclude: [
78 | ],
79 |
80 | // web server port
81 | port: 8080,
82 |
83 | // Start these browsers, currently available:
84 | // - Chrome
85 | // - ChromeCanary
86 | // - Firefox
87 | // - Opera
88 | // - Safari (only Mac)
89 | // - PhantomJS
90 | // - IE (only Windows)
91 | browsers: [
92 | "PhantomJS"
93 | ],
94 |
95 | // Which plugins to enable
96 | plugins: [
97 | "karma-phantomjs-launcher",
98 | "karma-jasmine"
99 | ],
100 |
101 | // Continuous Integration mode
102 | // if true, it capture browsers, run tests and exit
103 | singleRun: false,
104 |
105 | colors: true,
106 |
107 | // level of logging
108 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
109 | logLevel: config.LOG_INFO,
110 |
111 | // Uncomment the following lines if you are using grunt's server to run the tests
112 | // proxies: {
113 | // '/': 'http://localhost:9000/'
114 | // },
115 | // URL root prevent conflicts with the site root
116 | // urlRoot: '_karma_'
117 | });
118 | };
119 |
--------------------------------------------------------------------------------
/app/scripts/directives/plex/home.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coPlexHome', function() {
5 | function toNumber(keyCode) {
6 | if(keyCode >= 48 && keyCode <= 57) {
7 | return String.fromCharCode(keyCode);
8 | }
9 |
10 | if(keyCode >= 96 && keyCode <= 105) {
11 | return String.fromCharCode(keyCode - 48);
12 | }
13 |
14 | return null;
15 | }
16 |
17 | function PlexHome($scope) {
18 | this.$scope = $scope;
19 |
20 | // Bind scope functions
21 | var self = this;
22 |
23 | $scope.$on('reset', function() {
24 | self.reset();
25 | });
26 |
27 | $scope.pinKeyDown = function($event) {
28 | self.pinKeyDown($event);
29 | };
30 |
31 | $scope.select = function(user) {
32 | self.select(user);
33 | };
34 |
35 | $scope.switch = function(state) {
36 | $scope.state = state;
37 | };
38 | }
39 |
40 | PlexHome.prototype.reset = function() {
41 | var $scope = this.$scope;
42 |
43 | // Reset scope values
44 | $scope.current = null;
45 | $scope.state = 'list';
46 |
47 | $scope.users = [];
48 | };
49 |
50 | PlexHome.prototype.refresh = function() {
51 | var $scope = this.$scope;
52 |
53 | plex.cloud['/api/home'].users().then(function(response) {
54 | var data = response.data,
55 | users = data.MediaContainer.User;
56 |
57 | if(typeof users.length === 'undefined') {
58 | users = [users];
59 | }
60 |
61 | $scope.$apply(function() {
62 | $scope.users = users;
63 | });
64 |
65 | console.log($scope.users);
66 | }, function() {
67 | $scope.$apply(function() {
68 | $scope.users = [];
69 | });
70 | });
71 | };
72 |
73 | PlexHome.prototype.select = function(user) {
74 | if(user._protected === '1') {
75 | this.pinLogin(user);
76 | } else {
77 | this.basicLogin(user);
78 | }
79 | };
80 |
81 | PlexHome.prototype.basicLogin = function(user, pin) {
82 | var $scope = this.$scope;
83 |
84 | // Fire callback
85 | $scope.onAuthenticated(user._id, pin);
86 | };
87 |
88 | PlexHome.prototype.pinLogin = function(user) {
89 | var $scope = this.$scope;
90 |
91 | // Switch to PIN input state
92 | $scope.current = user;
93 | $scope.state = 'pin';
94 | };
95 |
96 | PlexHome.prototype.pinKeyDown = function($event) {
97 | var $scope = this.$scope,
98 | $input = $($event.target),
99 | $cell = $input.parent('span'),
100 | $container = $cell.parent('div');
101 |
102 | // Prevent default character insertion
103 | $event.preventDefault();
104 |
105 | // Handle key
106 | if($event.keyCode === 8) {
107 | if($input.val() !== '') {
108 | // Clear current field
109 | $input.val('');
110 | return;
111 | }
112 |
113 | // Find previous field
114 | var $prev = $cell.prev();
115 |
116 | if($prev.length === 0) {
117 | return;
118 | }
119 |
120 | // Clear previous field
121 | $('input', $prev)
122 | .val('')
123 | .focus();
124 | } else {
125 | // Set character
126 | var char = toNumber($event.keyCode);
127 |
128 | if(char === null) {
129 | return;
130 | }
131 |
132 | $input.val(char);
133 |
134 | // Move to next field
135 | var $next = $cell.next();
136 |
137 | if($next.length === 0) {
138 | // Done
139 | var $fields = $('input', $container),
140 | pin = '';
141 |
142 | // Build pin from fields
143 | for(var i = 0; i < $fields.length; ++i) {
144 | var val = $($fields[i]).val();
145 |
146 | if(val === '') {
147 | return;
148 | }
149 |
150 | pin += val;
151 | }
152 |
153 | // Fire callback
154 | $scope.onAuthenticated($scope.current._id, pin);
155 | return;
156 | }
157 |
158 | $('input', $next)
159 | .focus();
160 | }
161 | };
162 |
163 | return {
164 | restrict: 'E',
165 | scope: {
166 | onAuthenticated: '=coAuthenticated'
167 | },
168 | templateUrl: 'directives/plex/home.html',
169 |
170 | controller: function($scope) {
171 | // Set initial scope values
172 | $scope.current = null;
173 | $scope.state = 'list';
174 |
175 | $scope.users = [];
176 |
177 | // Construct main controller
178 | var main = new PlexHome($scope);
179 |
180 | // Initial account refresh
181 | main.refresh();
182 | },
183 |
184 | link: function(scope, element, attrs) {
185 | }
186 | };
187 | });
188 |
--------------------------------------------------------------------------------
/app/styles/configuration/rules.scss:
--------------------------------------------------------------------------------
1 | body.dragging, body.dragging * {
2 | cursor: move !important;
3 | }
4 |
5 | .rules {
6 | margin-top: 25px;
7 |
8 | h5, h6 {
9 | line-height: 2.6;
10 |
11 | margin: 0;
12 | padding-left: 5px;
13 | }
14 |
15 | table {
16 | width: 100%;
17 |
18 | background: transparent;
19 | border: none;
20 |
21 | thead {
22 | background: rgba(0, 0, 0, 0.14);
23 |
24 | th {
25 | padding-left: 19px;
26 | }
27 | }
28 |
29 | tbody {
30 | tr {
31 | background: rgba(0, 0, 0, 0.035);
32 | }
33 |
34 | tr:nth-of-type(even) {
35 | background: rgba(0, 0, 0, 0.055);
36 | }
37 | }
38 |
39 | tfoot {
40 | background: transparent;
41 | }
42 |
43 | tr {
44 | height: 44px;
45 | }
46 |
47 | th.actions {
48 | width: 64px;
49 | }
50 |
51 | th.account {
52 | min-width: 145px;
53 | width: 18%;
54 | }
55 |
56 | th.name {
57 | min-width: 100px;
58 | }
59 |
60 | td {
61 | position: relative;
62 |
63 | padding: 0;
64 |
65 | input, select {
66 | margin: 0;
67 | }
68 |
69 | select, .selectize-control {
70 | height: 30px;
71 | }
72 |
73 | .selectize-control {
74 | margin-bottom: 6px;
75 |
76 | .selectize-dropdown,
77 | .selectize-input,
78 | .selectize-input input {
79 | font-size: 14px;
80 | }
81 |
82 | .selectize-dropdown {
83 | .option {
84 | overflow: hidden;
85 | text-overflow: ellipsis;
86 | white-space: nowrap;
87 | }
88 |
89 | .optgroup-header {
90 | font-weight: bold;
91 | }
92 | }
93 |
94 | input {
95 | height: 18px;
96 | }
97 | }
98 |
99 | .selectize-input .item {
100 | display: block;
101 | float: left;
102 |
103 | max-width: 88%;
104 |
105 | overflow: hidden;
106 | text-overflow: ellipsis;
107 | white-space: nowrap;
108 | }
109 |
110 | .wrapper {
111 | position: absolute;
112 | left: 10px;
113 | right: 10px;
114 | top: 0;
115 | bottom: 0;
116 | }
117 |
118 | .wrapper.edit {
119 | top: 4px;
120 | }
121 |
122 | .wrapper.plain {
123 | top: 3px;
124 |
125 | padding: 10px 9px;
126 |
127 | overflow: hidden;
128 | text-overflow: ellipsis;
129 | white-space: nowrap;
130 | }
131 | }
132 |
133 | td.actions {
134 | padding: 5px;
135 |
136 | font-size: 17px;
137 | text-align: right;
138 |
139 | // disable text selection on action buttons
140 | -moz-user-select: none;
141 | -ms-user-select: none;
142 | -webkit-user-select: none;
143 | user-select: none;
144 |
145 | a {
146 | display: inline-block;
147 | width: 14px;
148 |
149 | margin-left: 3px;
150 |
151 | color: #333;
152 | }
153 |
154 | a:hover {
155 | color: #777;
156 | }
157 |
158 | a.move {
159 | display: block;
160 | float: left;
161 | position: absolute;
162 |
163 | width: 20px;
164 | margin-left: -6px;
165 |
166 | text-align: center;
167 |
168 | color: #BBB;
169 | }
170 |
171 | a.move:hover {
172 | color: #777;
173 | cursor: -webkit-grab;
174 | }
175 | }
176 |
177 | td.account .wrapper span.deleted {
178 | color: #AAA;
179 |
180 | font-style: italic;
181 | }
182 |
183 | tfoot td.actions {
184 | a {
185 | color: #999;
186 | }
187 |
188 | a:hover {
189 | color: #777;
190 | }
191 | }
192 |
193 | .dragged {
194 | position: absolute;
195 | opacity: 0.5;
196 | z-index: 2000;
197 | }
198 |
199 | tr.placeholder {
200 | position: relative;
201 |
202 | height: 0;
203 | }
204 |
205 | tr.placeholder:before {
206 | position: absolute;
207 | width: 0;
208 | height: 0;
209 |
210 | margin-top: -5px;
211 | left: 5px;
212 |
213 | content: "";
214 |
215 | border: 5px solid transparent;
216 | border-left-color: #333;
217 | border-right: none;
218 | }
219 | }
220 |
221 | .clients {
222 | table {
223 | th.machine-identifier {
224 | min-width: 100px;
225 | width: 24%;
226 | }
227 |
228 | th.name {
229 | min-width: 100px;
230 | }
231 |
232 | th.address {
233 | min-width: 100px;
234 | }
235 |
236 | td.machine-identifier {
237 | .selectize-dropdown {
238 | .option div {
239 | overflow: hidden;
240 | text-overflow: ellipsis;
241 | white-space: nowrap;
242 | }
243 |
244 | .option small {
245 | font-size: 10px;
246 | color: rgba(0, 0, 0, 0.65);
247 | }
248 | }
249 | }
250 | }
251 | }
252 | }
253 |
--------------------------------------------------------------------------------
/app/scripts/directives/trakt/login.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coTraktLogin', function(Utils, $http, $q) {
5 | var tr = new trakt.Client(
6 | 'c9ccd3684988a7862a8542ae0000535e0fbd2d1c0ca35583af7ea4e784650a61',
7 | 'bf00575b1ad252b514f14b2c6171fe650d474091daad5eb6fa890ef24d581f65'
8 | );
9 |
10 | function TraktLogin($scope) {
11 | this.$scope = $scope;
12 |
13 | // Bind scope functions
14 | var self = this;
15 |
16 | $scope.$on('reset', function() {
17 | self.reset();
18 | });
19 |
20 | $scope.basicLogin = function() {
21 | return self.basicLogin();
22 | };
23 |
24 | $scope.pinLogin = function() {
25 | return self.pinLogin();
26 | };
27 |
28 | $scope.switch = function(method) {
29 | $scope.messages = [];
30 | $scope.method = method;
31 | };
32 | }
33 |
34 | TraktLogin.prototype.appendMessage = function(type, content) {
35 | var $scope = this.$scope;
36 |
37 | $scope.messages.push({
38 | type: type,
39 | content: content
40 | });
41 | };
42 |
43 | TraktLogin.prototype.reset = function() {
44 | var $scope = this.$scope;
45 |
46 | // Reset state
47 | $scope.messages = [];
48 | $scope.method = 'pin';
49 | };
50 |
51 | TraktLogin.prototype.basicLogin = function() {
52 | var $scope = this.$scope;
53 |
54 | // Reset messages
55 | $scope.messages = [];
56 |
57 | // Fire callback
58 | $scope.basicAuthenticated({
59 | credentials: $scope.basic
60 | });
61 |
62 | return $q.resolve();
63 | };
64 |
65 | TraktLogin.prototype.pinLogin = function() {
66 | var $scope = this.$scope,
67 | self = this;
68 |
69 | // Reset messages
70 | $scope.messages = [];
71 |
72 | // Request token for pin code
73 | return tr.oauth.token($scope.pin.code).then(function(authorization) {
74 | // Request account details
75 | return tr['users/settings'].get(authorization.access_token).then(function(settings) {
76 | $scope.$apply(function() {
77 | // Fire callback
78 | $scope.pinAuthenticated({
79 | authorization: authorization,
80 | credentials: $scope.pin,
81 | settings: settings
82 | });
83 | });
84 | }, function(data, status) {
85 | $scope.$apply(function() {
86 | self.handleError(data, status, 'Unable to retrieve account details');
87 | });
88 |
89 | return $q.reject(data, status);
90 | });
91 | }, function(data, status) {
92 | $scope.$apply(function() {
93 | self.handleError(data, status, 'Unable to retrieve token');
94 | });
95 |
96 | return $q.reject(data, status);
97 | });
98 | };
99 |
100 | TraktLogin.prototype.handleError = function(data, status, fallback) {
101 | var content = this.getError(data, status, fallback);
102 |
103 | // Update messages
104 | this.appendMessage('error', content);
105 | };
106 |
107 | TraktLogin.prototype.getError = function(data, status, fallback) {
108 | if(Utils.isDefined(data)) {
109 | // Retrieve error message from `data`
110 | if(Utils.isDefined(data.error) && data.error === 'invalid_grant') {
111 | return 'Invalid authentication pin provided';
112 | }
113 |
114 | if(Utils.isDefined(data.error_description)) {
115 | return data.error_description;
116 | }
117 |
118 | if(Utils.isDefined(data.error)) {
119 | return data.error;
120 | }
121 | }
122 |
123 | if(Utils.isDefined(status)) {
124 | return 'HTTP Error: ' + status;
125 | }
126 |
127 | // Fallback to generic message
128 | return fallback;
129 | };
130 |
131 | return {
132 | restrict: 'E',
133 | scope: {
134 | buttonSize: '@coButtonSize',
135 |
136 | isCancelEnabled: '=coCancelEnabled',
137 | cancelled: '=coCancelled',
138 |
139 | basic: '=coBasic',
140 | basicAuthenticated: '&coBasicAuthenticated',
141 |
142 | pin: '=coPin',
143 | pinAuthenticated: '&coPinAuthenticated'
144 | },
145 | templateUrl: 'directives/trakt/login.html',
146 |
147 | controller: function($scope) {
148 | // Set parameter defaults
149 | if(typeof $scope.buttonSize === 'undefined') {
150 | $scope.buttonSize = 'small';
151 | }
152 |
153 | if(typeof $scope.basic === 'undefined') {
154 | $scope.basic = {
155 | username: null,
156 | password: null
157 | };
158 | }
159 |
160 | if(typeof $scope.pin === 'undefined') {
161 | $scope.pin = {
162 | code: null
163 | };
164 | }
165 |
166 | // Set initial scope values
167 | $scope.messages = [];
168 | $scope.method = 'pin';
169 |
170 | // Construct main controller
171 | var main = new TraktLogin($scope);
172 | }
173 | };
174 | });
175 |
--------------------------------------------------------------------------------
/app/scripts/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * @ngdoc overview
5 | * @name configurationApp
6 | * @description
7 | * # configurationApp
8 | *
9 | * Main module of the application.
10 | */
11 | angular
12 | .module('configurationApp', [
13 | 'ngAnimate',
14 | 'ngAria',
15 | 'ngCookies',
16 | 'ngMessages',
17 | 'ngRaven',
18 | 'ngResource',
19 | 'ngRoute',
20 | 'ngSanitize',
21 | 'ngTouch',
22 |
23 | 'angulartics',
24 | 'angulartics.google.analytics',
25 |
26 | 'angularSpinner',
27 | 'hc.marked',
28 | 'mm.foundation',
29 | 'selectize',
30 | 'xml'
31 | ])
32 | .config(function ($httpProvider, markedProvider, $ravenProvider, $routeProvider) {
33 | // Setup angular-raven
34 | // $ravenProvider.development(true);
35 |
36 | // Setup angular-xml
37 | $httpProvider.interceptors.push('xmlHttpInterceptor');
38 |
39 | // Setup angular-marked
40 | markedProvider.setRenderer({
41 | link: function(href, title, text) {
42 | return "" + text + " ";
43 | }
44 | });
45 |
46 | // Setup routes
47 | $routeProvider
48 | .when('/', {
49 | templateUrl: 'views/home.html',
50 | controller: 'HomeController'
51 | })
52 | .when('/configuration/server', {
53 | templateUrl: 'views/configuration/server.html',
54 | controller: 'ServerController'
55 | })
56 | .when('/configuration/accounts', {
57 | templateUrl: 'views/configuration/accounts.html',
58 | controller: 'AccountsController',
59 | reloadOnSearch: false
60 | })
61 | .when('/configuration/rules', {
62 | templateUrl: 'views/configuration/rules.html',
63 | controller: 'RulesController'
64 | })
65 | .when('/connect', {
66 | templateUrl: 'views/connect.html',
67 | controller: 'ConnectController'
68 | })
69 | .when('/login', {
70 | templateUrl: 'views/login.html',
71 | controller: 'LoginController'
72 | })
73 | .when('/logout', {
74 | templateUrl: 'views/logout.html',
75 | controller: 'LogoutController'
76 | })
77 | .otherwise({
78 | redirectTo: '/'
79 | });
80 | })
81 | .run(function(Authentication, RavenTags, $location, $rootScope) {
82 | var $m = window.tfpc.metadata;
83 |
84 | // Store metadata in root scope
85 | $rootScope.$m = $m;
86 |
87 | // Send version/revision with error reports
88 | RavenTags.update({
89 | application_version: $m.version,
90 | application_revision: $m.revision.label
91 | });
92 |
93 | // Setup plex.js
94 | plex.cloud.http.headers.setProduct('trakt (for Plex) - Configuration', '1.0.0');
95 | plex.cloud.http.xmlParser = 'x2js';
96 |
97 | if(typeof localStorage['plex.client.identifier'] === 'undefined' ||
98 | localStorage['plex.client.identifier'] === null) {
99 | localStorage['plex.client.identifier'] = plex.utils.random.string();
100 | }
101 |
102 | plex.cloud.client_identifier = localStorage['plex.client.identifier'];
103 |
104 | // Redirect handler
105 | $rootScope.$r = {
106 | path: null,
107 | search: null,
108 |
109 | redirect: function() {
110 | var path, search;
111 |
112 | // Get path
113 | if(typeof this.path !== 'undefined' && this.path !== null) {
114 | // Original
115 | path = this.path;
116 | } else {
117 | // Home
118 | path = '/';
119 | }
120 |
121 | // Get search query
122 | if(typeof this.search !== 'undefined' && this.search !== null) {
123 | // Original
124 | search = this.search;
125 | } else {
126 | // Home
127 | search = '';
128 | }
129 |
130 | // Ensure destination is valid
131 | if(this.path === $location.path()) {
132 | path = '/';
133 | search = '';
134 | }
135 |
136 | // Redirect
137 | console.log('redirecting to %s (%s)', path, search);
138 |
139 | $location.path(path);
140 | $location.search(search);
141 | }
142 | };
143 |
144 | $rootScope.$on('$routeChangeStart', function(event, next) {
145 | var controller = next.controller;
146 |
147 | if(controller === 'LoginController') {
148 | return;
149 | }
150 |
151 | if(!Authentication.authenticated()) {
152 | next.resolve = angular.extend(next.resolve || {}, {
153 | __authenticate__: function() {
154 | return Authentication.get().then(function() {
155 | console.log('authenticated');
156 | }, function() {
157 | $rootScope.$r.path = $location.path();
158 | $rootScope.$r.search = $location.search();
159 |
160 | $location.path('/login');
161 | $location.search('');
162 | });
163 | }
164 | });
165 | }
166 |
167 | if(controller === 'ConnectController' || controller === 'LogoutController') {
168 | return;
169 | }
170 |
171 | if($rootScope.$s === null || typeof $rootScope.$s === 'undefined') {
172 | $rootScope.$r.path = $location.path();
173 | $rootScope.$r.search = $location.search();
174 |
175 | $location.path('/connect');
176 | $location.search('');
177 | }
178 | });
179 | });
180 |
--------------------------------------------------------------------------------
/app/styles/configuration/accounts.scss:
--------------------------------------------------------------------------------
1 | .accounts {
2 | margin-top: 25px;
3 |
4 | .list {
5 | margin-top: 67px;
6 |
7 | .avatar {
8 | height: 32px;
9 |
10 | border-radius: 50%;
11 |
12 | margin-left: -6px;
13 | margin-right: 10px;
14 | }
15 |
16 | .state {
17 | float: right;
18 |
19 | font-size: 16px;
20 |
21 | margin-top: 4px;
22 | }
23 |
24 | .actions {
25 | display: table;
26 | width: 100%;
27 |
28 | margin-top: 10px;
29 |
30 | .actions-left {
31 | display: table-cell;
32 |
33 | width: 53px;
34 |
35 | vertical-align: top;
36 |
37 | button {
38 | margin: 0;
39 | }
40 | }
41 |
42 | .actions-right {
43 | display: table-cell;
44 |
45 | vertical-align: top;
46 |
47 | co-button-input {
48 | margin-left: 4px;
49 | margin-right: -1px;
50 | }
51 | }
52 |
53 | button {
54 | font-size: 17px;
55 | padding: 0.375rem 1.25rem;
56 | }
57 |
58 | .create {
59 | display: inline-block;
60 | }
61 | }
62 | }
63 |
64 | .side-nav {
65 | li a:hover,
66 | li a:focus {
67 | background: rgba(0, 0, 0, 0.04);
68 | }
69 |
70 | li.active a {
71 | background: rgba(0, 0, 0, 0.08);
72 | }
73 |
74 | li.active > a:first-child:not(.button),
75 | li a:first-child:not(.button):hover,
76 | li a:first-child:not(.button):focus {
77 | color: #008CBA;
78 | }
79 | }
80 |
81 | .authentication {
82 | .avatar {
83 | float: left;
84 | width: 85px;
85 | height: 85px;
86 |
87 | margin-right: 20px;
88 |
89 | border-radius: 50%;
90 | box-shadow: 0 0 9px 3px rgba(0, 0, 0, 0.15);
91 | }
92 |
93 | .account-edit {
94 | padding: 10px;
95 | }
96 |
97 | .account-view {
98 | padding: 10px;
99 |
100 | .details {
101 | position: relative;
102 |
103 | margin-left: 105px;
104 |
105 | height: 85px;
106 |
107 | a {
108 | margin: 0;
109 | }
110 |
111 | h4 {
112 | margin-top: 0;
113 | }
114 | }
115 |
116 | .actions {
117 | position: absolute;
118 | bottom: 0;
119 | right: 0;
120 |
121 | height: 32px;
122 |
123 | a {
124 | font-size: 15px;
125 | padding: 0.375rem 1.25rem;
126 | }
127 |
128 | button {
129 | font-size: 15px;
130 | padding: 0.375rem 1.25rem;
131 | }
132 | }
133 | }
134 |
135 | .tabs {
136 | dd > a {
137 | padding: 4px 12px;
138 |
139 | background-color: transparent;
140 | }
141 |
142 | dd > a:hover {
143 | background-color: rgba(0, 0, 0, 0.075);
144 | }
145 |
146 | dd.active > a {
147 | color: inherit;
148 | background-color: rgba(0, 0, 0, 0.05);
149 | }
150 | }
151 |
152 | .tabs-content {
153 | background-color: rgba(0, 0, 0, 0.05);
154 |
155 | .content {
156 | padding: 5px;
157 | }
158 |
159 | label[for="trakt.oauth.code"] a {
160 | float: right;
161 | }
162 |
163 | .accordion {
164 | dd > a {
165 | padding: 4px 7px;
166 |
167 | background: rgba(0, 0, 0, 0.06);
168 | }
169 |
170 | dd > a:hover {
171 | background: rgba(0, 0, 0, 0.09);
172 | }
173 |
174 | .content {
175 | padding: 10px;
176 | }
177 | }
178 |
179 | .messages {
180 | padding: 0 10px;
181 | }
182 |
183 | .messages small.message:first-child {
184 | margin-top: 10px !important;
185 | }
186 |
187 | .messages small.message:last-child {
188 | margin-bottom: 0 !important;
189 | }
190 | }
191 |
192 | dd .content > label + input {
193 | margin-top: 3px;
194 | }
195 |
196 | dd .content > input + label {
197 | margin-top: 9px;
198 | }
199 |
200 | dd .content > input {
201 | margin-bottom: 3px;
202 | }
203 | }
204 |
205 | //
206 | // Authentication state icon
207 | //
208 |
209 | .authentication dd > a:before,
210 | .list .state:before {
211 | // Font Awesome
212 | display: inline-block;
213 | font: normal normal normal 14px/1 FontAwesome;
214 | font-size: inherit;
215 | text-rendering: auto;
216 | -webkit-font-smoothing: antialiased;
217 | -moz-osx-font-smoothing: grayscale;
218 | transform: translate(0, 0);
219 |
220 | // Icon
221 | color: #757575;
222 | content: "\f056";
223 | }
224 |
225 | .authentication dd[data-state="error"] > a:before,
226 | .list .state[data-state="error"]:before {
227 | color: #BD2820;
228 | content: "\f06a";
229 | }
230 |
231 | .authentication dd[data-state="warning"] > a:before,
232 | .list .state[data-state="warning"]:before {
233 | color: #BDB820;
234 | content: "\f06a";
235 | }
236 |
237 | .authentication dd[data-state="valid"] > a:before,
238 | .list .state[data-state="valid"]:before {
239 | color: #4FA51C;
240 | content: "\f058";
241 | }
242 |
243 | .authentication dd > a:before {
244 | // Alignment
245 | margin-right: 8px;
246 | }
247 | }
248 |
249 | .accounts > .options {
250 | margin-top: 0;
251 | margin-bottom: 80px;
252 | }
253 |
254 | .accounts > .options .group:first-child .header {
255 | margin-top: 0;
256 | }
257 |
--------------------------------------------------------------------------------
/app/scripts/directives/plex/pin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coPlexPin', function($timeout) {
5 | var intervalMinimum = 2000,
6 | intervalMaximum = 10000;
7 |
8 | function PlexPin($scope) {
9 | this.$scope = $scope;
10 |
11 | this.checks = 0;
12 | this.interval = intervalMinimum;
13 | this.timer = null;
14 |
15 | // Bind scope functions
16 | var self = this;
17 |
18 | $scope.$on('activate', function() {
19 | // Create new pin
20 | self.create();
21 | });
22 |
23 | $scope.$on('reset', function() {
24 | self.reset();
25 | });
26 | }
27 |
28 | PlexPin.prototype.reset = function() {
29 | var $scope = this.$scope;
30 |
31 | // Cancel timer
32 | if(this.timer !== null) {
33 | $timeout.cancel(this.timer);
34 | this.timer = null;
35 | }
36 |
37 | // Reset handler
38 | this.checks = 0;
39 | this.interval = intervalMinimum;
40 |
41 | // Reset scope
42 | $scope.current = null;
43 | $scope.enabled = true;
44 | $scope.expires_at = null;
45 | $scope.state = null;
46 | };
47 |
48 | PlexPin.prototype.check = function() {
49 | console.debug('Checking pin status...');
50 |
51 | var $scope = this.$scope,
52 | self = this;
53 |
54 | // Ensure pin authentication has been enabled
55 | if($scope.enabled !== true) {
56 | console.debug('PIN authentication has been cancelled');
57 | self.reset();
58 | return;
59 | }
60 |
61 | // Ensure pin details are valid
62 | if(typeof $scope.current === 'undefined' || $scope.current === null || $scope.current.id === null) {
63 | console.warn('Invalid pin data', $scope.current);
64 | self.reset();
65 | return;
66 | }
67 |
68 | // Ensure pin hasn't expired
69 | if(new Date() > $scope.expires_at) {
70 | console.debug('Pin has expired');
71 | self.reset();
72 | return;
73 | }
74 |
75 | // Check if pin is authenticated
76 | this.checks += 1;
77 |
78 | plex.cloud["/pins"].get($scope.current.id).then(function(response) {
79 | var data = response.data;
80 |
81 | if(data.pin.auth_token._nil === 'true') {
82 | // PIN not authenticated yet, schedule next check
83 | self.schedule();
84 | return;
85 | }
86 |
87 | // PIN has been authenticated
88 | $scope.$apply(function() {
89 | if($scope.enabled === true) {
90 | // Fire callback
91 | $scope.onAuthenticated({
92 | token: data.pin.auth_token
93 | });
94 | }
95 |
96 | $scope.state = 'complete';
97 | });
98 | }, function(data, status) {
99 | // Update state
100 | $scope.$apply(function() {
101 | $scope.state = 'complete';
102 | });
103 |
104 | // Fire callback
105 | $scope.onExpired();
106 | });
107 | };
108 |
109 | PlexPin.prototype.create = function() {
110 | var $scope = this.$scope,
111 | self = this;
112 |
113 | $scope.state = 'create';
114 |
115 | // Ensure pin authentication has been enabled
116 | if($scope.enabled !== true) {
117 | console.debug('PIN authentication has been cancelled');
118 | return;
119 | }
120 |
121 | // Request new pin code
122 | console.debug('Creating new PIN...');
123 |
124 | plex.cloud.pins().then(function(response) {
125 | var data = response.data;
126 |
127 | $scope.$apply(function() {
128 | // Update pin details
129 | $scope.current = data.pin;
130 | $scope.expires_at = new Date(data.pin.expires_at);
131 |
132 | $scope.state = 'check';
133 | });
134 |
135 | // Schedule pin check
136 | self.schedule(8000);
137 | }, function() {
138 | $scope.$apply(function() {
139 | $scope.state = 'error';
140 |
141 | self.reset();
142 | });
143 | })
144 | };
145 |
146 | PlexPin.prototype.schedule = function(interval) {
147 | var self = this;
148 |
149 | // Cancel existing timer
150 | if(self.timer !== null) {
151 | $timeout.cancel(self.timer);
152 | self.timer = null;
153 | }
154 |
155 | // Increase interval
156 | this.interval = Math.round(intervalMinimum + ((this.checks / 3) * 1000));
157 |
158 | if(this.interval > intervalMaximum) {
159 | // Ensure interval doesn't exceed the maximum
160 | this.interval = intervalMaximum;
161 | }
162 |
163 | // Use default interval
164 | if(typeof interval === 'undefined') {
165 | interval = this.interval;
166 | }
167 |
168 | // Schedule pin check
169 | self.timer = $timeout(function() {
170 | self.check();
171 | }, interval);
172 |
173 | console.debug('Checking pin status in %sms', interval);
174 | };
175 |
176 | return {
177 | restrict: 'E',
178 | scope: {
179 | onAuthenticated: '=coAuthenticated',
180 | onExpired: '=coExpired'
181 | },
182 | templateUrl: 'directives/plex/pin.html',
183 |
184 | controller: function($scope) {
185 | // Set initial scope values
186 | $scope.current = null;
187 | $scope.enabled = true;
188 | $scope.expires_at = null;
189 | $scope.state = null;
190 |
191 | // Construct main controller
192 | var main = new PlexPin($scope);
193 |
194 | // Create initial pin
195 | main.create();
196 | },
197 |
198 | link: function(scope, element, attrs) {
199 | element.on('$destroy', function() {
200 | // Disable pin checks
201 | scope.enabled = false;
202 | });
203 | }
204 | };
205 | });
206 |
--------------------------------------------------------------------------------
/app/styles/directives/configuration/option.scss:
--------------------------------------------------------------------------------
1 | co-configuration-option {
2 | .controls {
3 | input[type="checkbox"] {
4 | margin: 0.4rem 0.4rem 0 0;
5 | }
6 | }
7 |
8 | .controls-inline {
9 | display: flex;
10 |
11 | margin-bottom: 8px;
12 |
13 | .title {
14 | flex: 1;
15 | }
16 | }
17 |
18 | .title {
19 | position: relative;
20 | height: 26px;
21 |
22 | label {
23 | padding-top: 2px;
24 | }
25 |
26 | a {
27 | display: none;
28 | height: 21px;
29 |
30 | position: absolute;
31 | top: 1px;
32 |
33 | margin: 0 0 0 6px;
34 |
35 | font-size: 16px;
36 | color: #CCC;
37 | }
38 |
39 | a:hover {
40 | color: #999;
41 | }
42 | }
43 |
44 | .description {
45 | display: none;
46 | width: 36%;
47 |
48 | position: fixed;
49 | top: 32%;
50 | left: 32%;
51 |
52 | padding: 1.0rem 1.4rem;
53 |
54 | background-color: #FFF;
55 | border-radius: 3px;
56 | border: solid 1px #666;
57 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
58 |
59 | font-size: 0.88rem;
60 | line-height: 1.6;
61 |
62 | visibility: hidden;
63 | z-index: 1005;
64 |
65 | p, li {
66 | font-size: inherit;
67 | line-height: inherit;
68 | }
69 |
70 | p {
71 | margin: 0;
72 | padding: 0 0 1.0rem 0;
73 | }
74 |
75 | p:last-child {
76 | margin: 0;
77 | }
78 |
79 | p + ul, p + ol {
80 | margin-top: -0.5rem;
81 | }
82 |
83 | .close {
84 | float: right;
85 | margin: -16px -16px 0 6px;
86 |
87 | font-size: 32px;
88 | font-weight: bold;
89 | line-height: 1;
90 |
91 | color: #AAA;
92 | cursor: pointer;
93 | }
94 |
95 | .close:hover {
96 | color: #777;
97 | }
98 |
99 | ul {
100 | margin: 0 0 0 2rem;
101 |
102 | font-size: inherit;
103 | line-height: inherit;
104 | }
105 |
106 | table {
107 | width: 100%;
108 | margin: 0;
109 |
110 | border: none;
111 | border-spacing: 0;
112 |
113 | thead {
114 | display: none;
115 | }
116 |
117 | tbody tr {
118 | background: none;
119 | }
120 |
121 | tbody td {
122 | padding: 0.3625rem 0.625rem;
123 |
124 | font-size: inherit;
125 | line-height: inherit;
126 |
127 | border-top: 1px solid #CCC;
128 | color: inherit;
129 | }
130 |
131 | tbody td:first-child {
132 | padding: 0.3625rem 1rem;
133 |
134 | border-right: 1px solid #CCC;
135 |
136 | white-space: nowrap;
137 | }
138 | }
139 | }
140 |
141 | .description-overlay {
142 | display: none;
143 |
144 | position: fixed;
145 | top: 0;
146 | left: 0;
147 | bottom: 0;
148 | right: 0;
149 |
150 | background: rgba(0, 0, 0, 0.45);
151 | z-index: 1004;
152 | }
153 |
154 | div.has-description {
155 | .title {
156 | label {
157 | display: inline-block;
158 | }
159 | }
160 |
161 | .description.opened {
162 | display: block;
163 | visibility: visible;
164 | }
165 |
166 | .description-overlay.opened {
167 | display: block;
168 | }
169 | }
170 | }
171 |
172 | co-configuration-option:hover {
173 | div.has-description {
174 | a {
175 | display: inline-block;
176 | }
177 | }
178 | }
179 |
180 | @media only screen and (max-width: 40em) {
181 | co-configuration-option {
182 | .description {
183 | width: 100%;
184 |
185 | top: 32%;
186 | left: 0;
187 |
188 | border: none;
189 | border-radius: 0;
190 | }
191 | }
192 | }
193 |
194 | @media only screen and (min-width: 40em) {
195 | co-configuration-option {
196 | .description {
197 | width: 52%;
198 |
199 | top: 32%;
200 | left: 24%;
201 | }
202 | }
203 | }
204 |
205 | @media only screen and (min-width: 64.0625em) {
206 | co-configuration-option {
207 | .controls {
208 | min-width: 50%;
209 | max-width: 50%;
210 | }
211 |
212 | .description {
213 | width: 36%;
214 |
215 | top: 32%;
216 | left: 32%;
217 | }
218 | }
219 | }
220 |
221 | @media only screen and (min-width: 96em) {
222 | co-configuration-option {
223 | .row {
224 | display: flex;
225 | }
226 |
227 | .controls {
228 | flex: 1;
229 | float: none;
230 | }
231 |
232 | .title a {
233 | display: none;
234 | }
235 |
236 | .description {
237 | display: none;
238 | flex: 2;
239 |
240 | position: absolute;
241 | min-height: 100%;
242 | width: 52.39%;
243 | top: auto;
244 | left: auto;
245 |
246 | padding: 0 10px 0 10px;
247 | margin: 0 0 0 100%;
248 |
249 | font-size: 0.78rem;
250 |
251 | box-shadow: none;
252 |
253 | border: none;
254 | border-radius: 0;
255 |
256 | background: none;
257 | color: #888;
258 |
259 | div {
260 | border: none;
261 | border-left: 2px solid #CCC;
262 | }
263 |
264 | p {
265 | padding: 0.5rem 0 0.5rem 1rem;
266 | }
267 |
268 | .close {
269 | display: none;
270 | visibility: hidden;
271 | }
272 | }
273 |
274 | .description-overlay {
275 | display: none;
276 | }
277 |
278 | div.has-description {
279 | .title {
280 | label {
281 | display: block;
282 | }
283 |
284 | a {
285 | display: none;
286 | }
287 | }
288 |
289 | .description.opened {
290 | display: none;
291 | visibility: hidden;
292 | }
293 |
294 | .description:hover, .description.visible {
295 | display: flex;
296 | visibility: visible;
297 | }
298 |
299 | .description-overlay.opened {
300 | display: none;
301 | }
302 | }
303 | }
304 | }
305 |
--------------------------------------------------------------------------------
/app/scripts/models/configuration/rules/client/collection.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('ClientRuleCollection', function(ClientRule, $q, $rootScope, $timeout) {
5 | var accountFunctions = [
6 | { $order: 1, value: '-', text: 'None' }
7 | ],
8 | attributeFunctions = [
9 | { $order: 1, value: '*', text: 'Any' }
10 | ];
11 |
12 | function ClientRuleCollection() {
13 | this.available = {
14 | keys: null,
15 | names: null,
16 | addresses: null
17 | };
18 |
19 | this.accounts = null;
20 | this.accountsById = null;
21 |
22 | this.original = null;
23 | this.rules = null;
24 | }
25 |
26 | ClientRuleCollection.prototype.create = function() {
27 | // Reset rule states to "view"
28 | this.reset();
29 |
30 | // Create new rule
31 | var rule = new ClientRule(
32 | this,
33 | {
34 | key: '*',
35 | name: '*',
36 | address: '*',
37 |
38 | account: {
39 | id: '-',
40 | name: 'None'
41 | },
42 | priority: this.rules.length + 1
43 | },
44 | 'edit'
45 | );
46 |
47 | this.rules.push(rule);
48 |
49 | // Focus new rule
50 | $timeout(function() {
51 | rule.focus();
52 | })
53 | };
54 |
55 | ClientRuleCollection.prototype.delete = function(rule) {
56 | var index = this.rules.indexOf(rule);
57 |
58 | if(index === -1) {
59 | return;
60 | }
61 |
62 | // Delete rule from collection
63 | this.rules.splice(index, 1);
64 |
65 | // Update rule priorities
66 | this.updatePriorities();
67 | };
68 |
69 | ClientRuleCollection.prototype.discard = function() {
70 | // Discard rule changes
71 | this.updateRules(this.original);
72 |
73 | // Update rule priorities
74 | this.updatePriorities();
75 |
76 | return $q.resolve();
77 | };
78 |
79 | ClientRuleCollection.prototype.refresh = function() {
80 | var self = this;
81 |
82 | return $q.all([
83 | // Retrieve seen clients
84 | $rootScope.$s.call('session.client.list').then(
85 | $.proxy(self.updateClients, self),
86 | function() {
87 | return $q.reject('Unable to retrieve clients');
88 | }
89 | ),
90 |
91 | // Retrieve client rules
92 | $rootScope.$s.call('session.client.rule.list', [], {full: true}).then(
93 | $.proxy(self.updateRules, self),
94 | function() {
95 | return $q.reject('Unable to retrieve client rules');
96 | }
97 | )
98 | ]);
99 | };
100 |
101 | ClientRuleCollection.prototype.reset = function() {
102 | // Reset rules back to view mode
103 | _.each(this.rules, function(rule) {
104 | rule.save();
105 | });
106 | };
107 |
108 | ClientRuleCollection.prototype.save = function() {
109 | var self = this,
110 | current = _.map(this.rules, function(rule) {
111 | return rule.current();
112 | });
113 |
114 | return $rootScope.$s.call('session.client.rule.update', [], {current: current, full: true}).then(
115 | $.proxy(self.updateRules, self),
116 | function() {
117 | return $q.reject('Unable to update client rules');
118 | }
119 | );
120 | };
121 |
122 | ClientRuleCollection.prototype.renderClient = function(item, escape) {
123 | if(item.type !== 'key') {
124 | return '' + escape(item.text) + '
';
125 | }
126 |
127 | return (
128 | '' +
129 | '
' + escape(item.name) + '
' +
130 | '' +
131 | '
'
132 | );
133 | };
134 |
135 | ClientRuleCollection.prototype.updateAccounts = function(accounts) {
136 | this.accounts = [].concat(accountFunctions, accounts);
137 |
138 | this.accountsById = _.indexBy(this.accounts, 'value');
139 | };
140 |
141 | ClientRuleCollection.prototype.updateClients = function(clients) {
142 | // Build collection of client keys
143 | this.available.keys = [].concat(attributeFunctions, _.map(clients, function (client) {
144 | return {
145 | $order: 10,
146 | type: 'key',
147 |
148 | value: client.key,
149 | text: client.key,
150 |
151 | // Extra metadata
152 | name: client.name,
153 | platform: client.platform,
154 | product: client.product
155 | };
156 | }));
157 |
158 | // Build collection of client names
159 | this.available.names = [].concat(attributeFunctions, _.map(clients, function (client) {
160 | return {
161 | $order: 10,
162 | type: 'name',
163 |
164 | value: client.name,
165 | text: client.name
166 | };
167 | }));
168 |
169 | // Build collection of client addresses
170 | this.available.addresses = [].concat(attributeFunctions, _.map(clients, function (client) {
171 | return {
172 | $order: 10,
173 | type: 'address',
174 |
175 | value: client.address,
176 | text: client.address
177 | };
178 | }));
179 | };
180 |
181 | ClientRuleCollection.prototype.updatePriorities = function() {
182 | // Update rule priorities
183 | for(var i = 0; i < this.rules.length; ++i) {
184 | this.rules[i].priority = i + 1;
185 | }
186 | };
187 |
188 | ClientRuleCollection.prototype.updateRules = function(rules) {
189 | var self = this;
190 |
191 | this.original = angular.copy(rules);
192 |
193 | // Parse rules
194 | this.rules = _.map(rules, function(rule) {
195 | return new ClientRule(self, rule);
196 | });
197 | };
198 |
199 | return ClientRuleCollection;
200 | });
201 |
--------------------------------------------------------------------------------
/app/scripts/directives/plex/login.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .directive('coPlexLogin', function(Utils, $q) {
5 | function PlexLogin($scope) {
6 | this.$scope = $scope;
7 |
8 | // Bind functions
9 | var self = this;
10 |
11 | $scope.$on('reset', function() {
12 | self.reset();
13 | });
14 |
15 | $scope.basicLogin = function() {
16 | return self.basicLogin($scope.credentials);
17 | };
18 |
19 | $scope.onHomeAuthenticated = function(id, pin) {
20 | self.onHomeAuthenticated(id, pin);
21 | };
22 |
23 | $scope.onPinAuthenticated = function(credentials) {
24 | self.onPinAuthenticated(credentials);
25 | };
26 |
27 | $scope.onPinExpired = function() {
28 | self.onPinExpired();
29 | };
30 |
31 | $scope.switch = function(method) {
32 | $scope.messages = [];
33 | $scope.method = method;
34 | };
35 | }
36 |
37 | PlexLogin.prototype.reset = function() {
38 | var $scope = this.$scope;
39 |
40 | // Reset scope values
41 | $scope.credentials = {
42 | username: null,
43 | password: null
44 | };
45 |
46 | $scope.messages = [];
47 | $scope.method = 'pin';
48 | };
49 |
50 | PlexLogin.prototype.appendMessage = function(type, content) {
51 | var $scope = this.$scope;
52 |
53 | $scope.messages.push({
54 | type: type,
55 | content: content
56 | });
57 | };
58 |
59 | PlexLogin.prototype.basicLogin = function(credentials) {
60 | var $scope = this.$scope,
61 | self = this;
62 |
63 | // Reset errors
64 | $scope.messages = [];
65 |
66 | if(!Utils.isDefined(credentials) ||
67 | !Utils.isDefined(credentials.username) ||
68 | !Utils.isDefined(credentials.password)) {
69 | self.appendMessage('error', 'Invalid basic login request');
70 | return $q.reject();
71 | }
72 |
73 | // Perform login
74 | return plex.cloud['/users'].login(
75 | credentials.username,
76 | credentials.password
77 | ).then(
78 | function(response) {
79 | $scope.$apply(function() {
80 | self.handleSuccess(response.data.user);
81 | });
82 | },
83 | function(response) {
84 | $scope.$apply(function() {
85 | self.handleError(response.data, response.statusCode);
86 | });
87 |
88 | return $q.reject();
89 | }
90 | );
91 | };
92 |
93 | PlexLogin.prototype.onHomeAuthenticated = function(id, pin) {
94 | var $scope = this.$scope,
95 | self = this;
96 |
97 | // Reset errors
98 | $scope.messages = [];
99 |
100 | if(!Utils.isDefined(id)) {
101 | self.appendMessage('error', 'Invalid user switch request');
102 | return;
103 | }
104 |
105 | if(Utils.isDefined(pin) && isNaN(pin)) {
106 | self.appendMessage('error', 'PIN contains invalid characters, only numbers are allowed');
107 | return;
108 | }
109 |
110 | // Retrieve account details
111 | plex.cloud['/api/home/users'].switch(id, pin).then(function(response) {
112 | $scope.$apply(function() {
113 | self.handleSuccess(response.data.user);
114 | });
115 | }, function(response) {
116 | $scope.$apply(function() {
117 | self.handleError(response.data, response.statusCode);
118 | });
119 | });
120 | };
121 |
122 | PlexLogin.prototype.onPinAuthenticated = function(credentials) {
123 | var $scope = this.$scope,
124 | self = this;
125 |
126 | // Reset errors
127 | $scope.messages = [];
128 |
129 | if(!Utils.isDefined(credentials) ||
130 | !Utils.isDefined(credentials.token)) {
131 | self.appendMessage('error', 'Invalid pin login request');
132 | return;
133 | }
134 |
135 | // Retrieve account details
136 | plex.cloud['/users'].account(
137 | credentials.token, {
138 | plex: {
139 | useToken: false
140 | }
141 | }
142 | ).then(
143 | function(response) {
144 | $scope.$apply(function() {
145 | self.handleSuccess(response.data.user);
146 | });
147 | },
148 | function(response) {
149 | $scope.$apply(function() {
150 | self.handleError(response.data, response.statusCode);
151 | });
152 | }
153 | );
154 | };
155 |
156 | PlexLogin.prototype.onPinExpired = function() {
157 | var $scope = this.$scope;
158 |
159 | this.appendMessage('error', 'Pin has expired');
160 | };
161 |
162 | PlexLogin.prototype.handleSuccess = function(user) {
163 | var $scope = this.$scope;
164 |
165 | // Pass to callback
166 | $scope.onAuthenticated(user._authenticationToken, user);
167 | };
168 |
169 | PlexLogin.prototype.handleError = function(data, status) {
170 | var $scope = this.$scope;
171 |
172 | // Login failed
173 | if(
174 | Utils.isDefined(data) &&
175 | Utils.isDefined(data.errors) &&
176 | Utils.isDefined(data.errors.error)
177 | ) {
178 | // "Error" elements found
179 | var errors = typeof data.errors.error === 'object' ? data.errors.error : [data.errors.error];
180 |
181 | for (var i = 0; i < errors.length; ++i) {
182 | this.appendMessage('error', errors[i]);
183 | }
184 | } else if(
185 | Utils.isDefined(data.Response) &&
186 | Utils.isDefined(data.Response._status)
187 | ) {
188 | // "Response" element found (home user switch)
189 | this.appendMessage('error', data.Response._status);
190 | } else {
191 | // Display HTTP error
192 | this.appendMessage('error', 'HTTP Error: ' + status);
193 | }
194 | };
195 |
196 | return {
197 | restrict: 'E',
198 | scope: {
199 | onAuthenticated: '=coAuthenticated',
200 |
201 | isCancelEnabled: '=coCancelEnabled',
202 | onCancelled: '=coCancelled',
203 |
204 | buttonSize: '@coButtonSize',
205 | modes: '=coModes'
206 | },
207 | templateUrl: 'directives/plex/login.html',
208 |
209 | compile: function(element, attrs) {
210 | if(typeof attrs.coButtonSize === 'undefined') {
211 | attrs.coButtonSize = 'small';
212 | }
213 | },
214 |
215 | controller: function($scope) {
216 | // Set initial scope values
217 | $scope.credentials = {
218 | username: null,
219 | password: null
220 | };
221 |
222 | $scope.messages = [];
223 | $scope.method = 'pin';
224 |
225 | // Construct main controller
226 | var main = new PlexLogin($scope);
227 | }
228 | };
229 | });
230 |
--------------------------------------------------------------------------------
/app/views/configuration/rules.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Account
17 | Client
18 | Name
19 | Address
20 |
21 |
22 |
23 |
24 |
25 |
29 |
34 |
35 |
36 |
37 |
48 |
49 |
50 |
51 | {{ rule.account.name }}
52 | deleted
53 |
54 |
55 |
56 |
57 |
72 |
73 |
74 |
75 | {{ attributeLabel(rule.key) }}
76 |
77 |
78 |
79 |
80 |
92 |
93 |
94 |
95 | {{ attributeLabel(rule.name) }}
96 |
97 |
98 |
99 |
100 |
112 |
113 |
114 |
115 | {{ attributeLabel(rule.address) }}
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
138 |
139 |
140 |
141 |
142 |
143 |
144 | Account
145 | Name
146 |
147 |
148 |
149 |
150 |
151 |
152 |
156 |
161 |
162 |
163 |
164 |
175 |
176 |
177 |
178 | {{ rule.account.name }}
179 | deleted
180 |
181 |
182 |
183 |
184 |
196 |
197 |
198 |
199 | {{ attributeLabel(rule.name) }}
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
--------------------------------------------------------------------------------
/app/scripts/models/plex/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('configurationApp')
4 | .factory('PlexServer', function(PlexConnection, PlexConnectionManager, Utils, VersionUtil, $location, $q, $rootScope) {
5 | var identifier = 'com.plexapp.plugins.trakttv',
6 | pluginVersionMinimum = '1.0.3.0',
7 | target = 'MessageKit:Api';
8 |
9 | function parseErrorResponse(response) {
10 | if(Utils.isDefined(response.reason)) {
11 | return response.reason;
12 | }
13 |
14 | if(Utils.isDefined(response.data) && Utils.isDefined(response.data.error)) {
15 | // Retrieve error from data
16 | var error = response.data.error;
17 |
18 | if(Utils.isDefined(error.message)) {
19 | return error.message;
20 | }
21 | }
22 |
23 | if(Utils.isDefined(response.statusCode)) {
24 | return 'HTTP Error: ' + response.statusCode;
25 | }
26 |
27 | return 'Unknown error';
28 | }
29 |
30 | function PlexServer() {
31 | this.name = null;
32 |
33 | this.identifier = null;
34 | this.plugin_version = null;
35 |
36 | this.token_channel = null;
37 | this.token_channel_expire = null;
38 |
39 | this.token_plex = null;
40 |
41 | this.client = null;
42 | this.connection_manager = null;
43 |
44 | this.status = null;
45 | }
46 |
47 | PlexServer.prototype.isAuthenticated = function() {
48 | return this.token_channel !== null;
49 | };
50 |
51 | PlexServer.prototype.authenticate = function() {
52 | var self = this;
53 |
54 | // Reset connection "error"
55 | self.status = null;
56 |
57 | // Authenticate with plugin
58 | return this.call('system.authenticate', [this.token_plex]).then(function(token) {
59 | if(token['X-Channel-Token'] === null || token['X-Channel-Token-Expire'] === null) {
60 | // Clear authentication details
61 | self.clearAuthentication();
62 |
63 | // Reject promise
64 | self.status = 'Unable to authenticate with plugin';
65 | return $q.reject(null);
66 | }
67 |
68 | // Retrieve authentication details
69 | self.token_channel = token['X-Channel-Token'];
70 | self.token_channel_expire = token['X-Channel-Token-Expire'];
71 |
72 | // Save server details
73 | self.save();
74 | }, function(response) {
75 | // Clear authentication details
76 | self.clearAuthentication();
77 |
78 | // Update status
79 | self.status = parseErrorResponse(response);
80 |
81 | return $q.reject();
82 | });
83 | };
84 |
85 | PlexServer.prototype.clearAuthentication = function() {
86 | // Reset authentication details
87 | this.token_channel = null;
88 | this.token_channel_expire = null;
89 |
90 | // Save server details
91 | this.save();
92 | };
93 |
94 | PlexServer.prototype.disconnect = function() {
95 | // Clear current authentication details
96 | this.clearAuthentication();
97 |
98 | // Clear current server
99 | $rootScope.$s = null;
100 |
101 | // Redirect to server connect view
102 | $location.path('/connect');
103 | $location.search('');
104 | };
105 |
106 | PlexServer.prototype.connect = function() {
107 | var self = this;
108 |
109 | // Reset connection "error"
110 | self.status = null;
111 |
112 | // Test connections
113 | return this.connection_manager.test().then(function(connection) {
114 | // Check server
115 | return self.check().then(function() {
116 | return connection;
117 | }, function(response) {
118 | // Server didn't pass validation
119 | self.status = parseErrorResponse(response);
120 |
121 | return $q.reject(self.status);
122 | });
123 |
124 | }, function(reason) {
125 | // Unable to connect to server
126 | self.status = reason;
127 |
128 | return $q.reject(self.status);
129 | });
130 | };
131 |
132 | PlexServer.prototype.check = function() {
133 | var self = this;
134 |
135 | return this.call('system.ping').then(function(pong) {
136 | // Store server version
137 | self.plugin_version = pong.version;
138 |
139 | // Check plugin meets version requirement
140 | if(VersionUtil.compare(self.plugin_version, pluginVersionMinimum) >= 0) {
141 | return true;
142 | }
143 |
144 | // Plugin update required
145 | return $q.reject({
146 | reason: 'Plugin needs to be updated to v' + pluginVersionMinimum + ' or later'
147 | });
148 | }, function(response) {
149 | // Unable to ping server
150 | return $q.reject(response);
151 | });
152 | };
153 |
154 | PlexServer.prototype.call = function(key, args, kwargs) {
155 | args = typeof args !== 'undefined' ? args : [];
156 |
157 | // insert `key` at the front of `args`
158 | args.splice(0, 0, key);
159 |
160 | // build headers
161 | var headers = {};
162 |
163 | if(this.token_channel !== null) {
164 | headers['X-Channel-Token'] = this.token_channel;
165 | }
166 |
167 | console.debug('[%s] Request "%s"', this.identifier, key, {
168 | args: args,
169 | kwargs: kwargs
170 | });
171 |
172 | // call api function
173 | var deferred = $q.defer(),
174 | self = this;
175 |
176 | this.client['/:/plugins/*/messaging'].callFunction(
177 | identifier, target, args, kwargs, {
178 | headers: headers
179 | }
180 | ).then(function(response) {
181 | var data = response.data;
182 |
183 | // Parse response
184 | if(typeof data === 'string') {
185 | data = JSON.parse(data);
186 | } else if(typeof data === 'object') {
187 | console.warn('Legacy response format returned');
188 | }
189 |
190 | // Return response
191 | console.debug('[%s] Response', self.identifier, data);
192 |
193 | if(data.result !== undefined) {
194 | deferred.resolve(data.result);
195 | return;
196 | }
197 |
198 | // Handle errors
199 | if(data.error !== undefined) {
200 | if(data.error.code == 'error.authentication.required') {
201 | // Authentication token invalid, redirect to the connect view
202 | self.disconnect();
203 | }
204 |
205 | // Reject request with error
206 | deferred.reject(data.error);
207 | } else {
208 | deferred.reject(null);
209 | }
210 | }, function(response) {
211 | deferred.reject(response);
212 | });
213 |
214 | return deferred.promise;
215 | };
216 |
217 | PlexServer.prototype.get = function(path, config) {
218 | config = typeof config !== 'undefined' ? config : {};
219 |
220 | config.method = 'GET';
221 |
222 | config.headers = typeof config.headers !== 'undefined' ? config.headers : {};
223 | config.headers['X-Plex-Token'] = this.token_plex;
224 |
225 | return this.current.request(path, config);
226 | };
227 |
228 | PlexServer.prototype._attributeKey = function(name) {
229 | return 'server.' + this.identifier + '.' + name;
230 | };
231 |
232 | PlexServer.prototype.load = function() {
233 | if(this.identifier === null || typeof this.identifier === 'undefined') {
234 | return;
235 | }
236 |
237 | var self = this;
238 |
239 | function loadAttribute(name) {
240 | var value = localStorage[self._attributeKey(name)];
241 |
242 | if(value === null || typeof value === 'undefined') {
243 | return;
244 | }
245 |
246 | self[name] = value;
247 | }
248 |
249 | loadAttribute('token_channel');
250 | loadAttribute('token_channel_expire');
251 | };
252 |
253 | PlexServer.prototype.save = function() {
254 | if(this.identifier === null || typeof this.identifier === 'undefined') {
255 | return;
256 | }
257 |
258 | var self = this;
259 |
260 | function saveAttribute(name) {
261 | localStorage[self._attributeKey(name)] = self[name];
262 | }
263 |
264 | saveAttribute('token_channel');
265 | saveAttribute('token_channel_expire');
266 | };
267 |
268 | PlexServer.fromElement = function(e) {
269 | var s = new PlexServer();
270 |
271 | // Set attributes
272 | s.name = e._name;
273 |
274 | s.identifier = e._clientIdentifier;
275 | s.token_plex = e._accessToken;
276 |
277 | if(typeof e.Connection.length === 'undefined') {
278 | e.Connection = [e.Connection];
279 | }
280 |
281 | // Build `Connection` objects
282 | var connections = _.map(e.Connection, function(e) {
283 | return PlexConnection.fromElement(e);
284 | });
285 |
286 | s.connection_manager = new PlexConnectionManager(s, connections);
287 |
288 | // Load attributes from storage
289 | s.load();
290 |
291 | return s;
292 | };
293 |
294 | return PlexServer;
295 | });
296 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Configuration - trakt (for Plex)
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
28 |
29 |
30 |
36 |
37 |
38 |
43 |
44 |
68 |
69 |
70 |
71 |
72 |
73 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
116 |
117 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
--------------------------------------------------------------------------------