diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 37e09590d..08dadd8ee 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -51,3 +51,16 @@ export const toggleSelectCommentInUserDetail = (id, active) => { id }; }; + +export const toggleStorySearch = (active) => ({ + type: active ? actions.SHOW_STORY_SEARCH : actions.HIDE_STORY_SEARCH +}); + +export const storySearchChange = (value) => ({ + type: actions.STORY_SEARCH_CHANGE_VALUE, + value +}); + +export const clearState = () => ({ + type: actions.MODERATION_CLEAR_STATE +}); diff --git a/client/coral-admin/src/components/ui/Layout.css b/client/coral-admin/src/components/ui/Layout.css index 792440d97..a3cc7b7b6 100644 --- a/client/coral-admin/src/components/ui/Layout.css +++ b/client/coral-admin/src/components/ui/Layout.css @@ -1,6 +1,5 @@ .layout { max-width: 1280px; margin: 0 auto; - overflow: hidden; background-color: #FAFAFA; } diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 11432e570..10433048e 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -10,7 +10,7 @@ const Layout = ({ toggleShortcutModal, restricted = false, ...props}) => ( - +
set.add(action.id)); case actions.UNSELECT_USER_DETAIL_COMMENT: return state.update('userDetailSelectedIds', (set) => set.delete(action.id)); + case actions.SHOW_STORY_SEARCH: + return state.set('storySearchVisible', true); + case actions.HIDE_STORY_SEARCH: + return state.set('storySearchVisible', false); + case actions.STORY_SEARCH_CHANGE_VALUE: + return state.set('storySearchString', action.value); case actions.SET_SORT_ORDER: return state.set('sortOrder', action.order); default : diff --git a/client/coral-admin/src/routes/Community/components/styles.css b/client/coral-admin/src/routes/Community/components/styles.css index 8e4b716af..8581c4c7d 100644 --- a/client/coral-admin/src/routes/Community/components/styles.css +++ b/client/coral-admin/src/routes/Community/components/styles.css @@ -91,6 +91,7 @@ span { .moderateAsset { a { + text-align: center; -webkit-box-flex: 1; -ms-flex: 1; flex: 1; @@ -103,21 +104,9 @@ span { opacity: 1; &:hover { - opacity: .8; + opacity: .8; cursor: pointer; } - - &:first-child { - text-align: left; - } - - &:nth-child(2) { - text-align: center; - } - - &:last-child { - text-align: right; - } } } } diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 1c1aa7690..8c81ae18b 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -10,6 +10,8 @@ import ModerationHeader from './ModerationHeader'; import NotFoundAsset from './NotFoundAsset'; import ModerationKeysModal from '../../../components/ModerationKeysModal'; import UserDetail from '../containers/UserDetail'; +import StorySearch from '../containers/StorySearch'; +import {Spinner} from 'coral-ui'; export default class Moderation extends Component { state = { @@ -32,6 +34,15 @@ export default class Moderation extends Component { this.toggleModal(false); } + closeSearch = () => { + const {toggleStorySearch} = this.props; + toggleStorySearch(false); + } + + openSearch = () => { + this.props.toggleStorySearch(true); + } + moderate = (accept) => () => { const {acceptComment, rejectComment} = this.props; const {selectedIndex} = this.state; @@ -92,17 +103,22 @@ export default class Moderation extends Component { } render () { - const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props; - const providedAssetId = this.props.params.id; - let asset; + const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props; + const providedAssetId = this.props.params.id; + const {asset} = root; if (providedAssetId) { - asset = assets.find((asset) => asset.id === this.props.params.id); + if (asset === null) { - if (!asset) { + // Not found. return ; } + if (asset === undefined || asset.id !== providedAssetId) { + + // Still loading. + return ; + } } const comments = root[activeTab]; @@ -127,7 +143,12 @@ export default class Moderation extends Component { return (
- + )} +
); } diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationHeader.js b/client/coral-admin/src/routes/Moderation/components/ModerationHeader.js index c6909226b..9cc0bfed9 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationHeader.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationHeader.js @@ -1,30 +1,35 @@ -import React from 'react'; -import {Link} from 'react-router'; +import React, {PropTypes} from 'react'; import {Icon} from 'coral-ui'; import styles from './styles.css'; import t from 'coral-framework/services/i18n'; -const ModerationHeader = (props) => ( +const ModerationHeader = ({asset, searchVisible, openSearch, closeSearch}) => { + + const trigger = searchVisible ? closeSearch : openSearch; + const searchTriggerIcon = ; + + const title = asset + ? {asset.title} {searchTriggerIcon} + : {t('modqueue.all_streams')} {searchTriggerIcon}; + + return (
- { - props.asset ? -
- {t('modqueue.all_streams')} - - {props.asset.title} - - - Select Stream -
- : -
- - {t('modqueue.all_streams')} - {t('modqueue.select_stream')} -
- } +
+ {title} +
-); + ); +}; + +ModerationHeader.propTypes = { + asset: PropTypes.shape({ + title: PropTypes.string, + id: PropTypes.string + }), + openSearch: PropTypes.func.isRequired, + closeSearch: PropTypes.func.isRequired +}; + export default ModerationHeader; diff --git a/client/coral-admin/src/routes/Moderation/components/Story.js b/client/coral-admin/src/routes/Moderation/components/Story.js new file mode 100644 index 000000000..6c3a4b02f --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/Story.js @@ -0,0 +1,30 @@ +import React, {PropTypes} from 'react'; +import styles from './StorySearch.css'; + +const formatDate = (date) => { + const d = new Date(date); + return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; +}; + +const Story = ({author, title, createdAt, open, id, goToStory}) => { + return ( +
  • goToStory(id)}> + {title} +
    + By {author} + {formatDate(createdAt)} + {open ? 'Open' : 'Closed'} +
    +
  • + ); +}; + +Story.propTypes = { + id: PropTypes.string.isRequired, + author: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + open: PropTypes.bool.isRequired +}; + +export default Story; diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.css b/client/coral-admin/src/routes/Moderation/components/StorySearch.css new file mode 100644 index 000000000..f0503f6b9 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.css @@ -0,0 +1,145 @@ +.overlay { + position: absolute; + top: 58px; + left: 0; + right: 0; + bottom: 0; + width: 100%; + z-index: 99; + opacity: 0; +} + +.container { + position: absolute; + background-color: white; + top: 106px; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + width: 50%; + box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15); + z-index: 100; + max-width: 820px; +} + +.positionShim { + left: -50%; +} + +.headInput { + background-color: #efefef; + padding: 10px 27px; + box-sizing: border-box; +} + +.searchInput { + width: calc(100% - 100px); + padding: 8px; + height: 100%; + font-size: 16px; + margin-right: 5px; + position: relative; + top: 3px; + box-sizing: border-box; + border-radius: 3px; + border: solid 1px #dfdfdf; + max-height: 45px; + max-width: 600px; + font-weight: 300; +} + +.cta { + letter-spacing: 1px; + font-weight: bold; + font-size: 15px; + margin: 0; + height: 50px; + box-sizing: border-box; + font-size: 15px; + font-weight: 500; + padding: 12px 30px; + letter-spacing: 0.25px; +} + +/*.storyList { + border-top: 1px solid #ddd; +}*/ + +.story { + padding: 7px 50px; + border-bottom: 1px solid #ddd; + cursor: pointer; + display: block; + text-decoration: none; + height: 50px; + box-sizing: border-box; + transition: background-color 400ms; + + &:hover { + background-color: #efefef; + } + + &:last-child { + border-bottom: none; + } +} + +.title, .meta { + margin: 0; + color: black; + font-size: 15px; +} + +.author, .createdAt, .status { + font-size: 17px; + display: inline-block; + font-size: .8em; +} + +.createdAt { + text-align: center; +} + +.author { + display: inline-block; + width: 200px; + color: #aaa; +} + +.createdAt { + display: inline-block; + width: 200px; + color: #aaa; +} + +.searchButton { + width: 90px; + height: 35px; +} + +.searchResults { + padding: 7px 27px; + background: #F5F5F5; +} + +.searchResults i { + font-size: 16px; + vertical-align: middle; +} + +.accessStories { + font-size: 18px; +} + +.headlineRecent { + font-size: 15px; + font-weight: 500; + letter-spacing: 0.25px; + vertical-align: middle; + margin-left: 8px; +} + +.noResults { + padding: 10px 24px 15px 49px; +} diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.js b/client/coral-admin/src/routes/Moderation/components/StorySearch.js new file mode 100644 index 000000000..f42ceaadd --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './StorySearch.css'; +import {Button, Spinner, Icon} from 'coral-ui'; +import Story from './Story'; + +const StorySearch = (props) => { + + const { + root: { + assets = [] + }, + data: {loading} + } = props; + + if (!props.moderation.storySearchVisible) { + return null; + } + + return ( +
    +
    +
    +
    + + +
    +
    +

    Moderate comments on All Stories

    +
    + + { + props.moderation.storySearchString ? ( +
    + + Search Results +
    + ) : ( +
    + + Most Recent Stories +
    + ) + } + + { + loading + ? + : assets.map((story, i) => { + const storyOpen = story.closedAt === null || new Date(story.closedAt) > new Date(); + return ; + }) + } + + {assets.length === 0 &&
    No results
    } +
    +
    +
    +
    +
    +
    + ); +}; + +StorySearch.propTypes = { + search: PropTypes.func.isRequired, + goToStory: PropTypes.func.isRequired, + closeSearch: PropTypes.func.isRequired, + moderation: PropTypes.object.isRequired, + handleSearchChange: PropTypes.func.isRequired +}; + +export default StorySearch; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index bd4ad31d8..8b4fe971d 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -116,29 +116,20 @@ span { transition: background-color 200ms; opacity: 1; - &:first-child { - text-align: left; + &:hover { + cursor: pointer; + background-color: #212121; + } + span { + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 344px; + display: inline-block; + vertical-align: top; } - &:nth-child(2) { - &:hover { - cursor: pointer; - background-color: #212121; - } - span { - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 344px; - display: inline-block; - vertical-align: top; - } - } - - &:last-child { - text-align: right; - } } } } @@ -534,3 +525,8 @@ span { line-height: 1px; font-weight: 300; } + +.searchTrigger { + position: relative; + top: .3em; +} diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 021ac3ceb..f9aa4c2c5 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -2,7 +2,6 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {compose, gql} from 'react-apollo'; -import isEqual from 'lodash/isEqual'; import withQuery from 'coral-framework/hocs/withQuery'; import {getDefinitionName} from 'coral-framework/utils'; import * as notification from 'coral-admin/src/services/notification'; @@ -14,7 +13,6 @@ import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-fr import {handleCommentChange} from '../../../graphql/utils'; import {fetchSettings} from 'actions/settings'; -import {updateAssets} from 'actions/assets'; import { toggleModal, singleView, @@ -23,9 +21,12 @@ import { showSuspendUserDialog, hideSuspendUserDialog, hideShortcutsNote, + toggleStorySearch, viewUserDetail, hideUserDetail, setSortOrder, + storySearchChange, + clearState } from 'actions/moderation'; import {Spinner} from 'coral-ui'; @@ -123,6 +124,7 @@ class ModerationContainer extends Component { } componentWillMount() { + this.props.clearState(); this.props.fetchSettings(); this.subscribeToUpdates(); } @@ -132,11 +134,6 @@ class ModerationContainer extends Component { } componentWillReceiveProps(nextProps) { - const {updateAssets} = this.props; - if(!isEqual(nextProps.root.assets, this.props.root.assets)) { - updateAssets(nextProps.root.assets); - } - // Resubscribe when we change between assets. if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) { this.resubscribe(); @@ -325,7 +322,7 @@ const commentConnectionFragment = gql` `; const withModQueueQuery = withQuery(gql` - query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER) { + query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) { all: comments(query: { statuses: [NONE, PREMOD, ACCEPTED, REJECTED], asset_id: $asset_id, @@ -362,7 +359,7 @@ const withModQueueQuery = withQuery(gql` }) { ...CoralAdmin_Moderation_CommentConnection } - assets: assets { + asset(id: $asset_id) @skip(if: $allAssets) { id title url @@ -398,6 +395,7 @@ const withModQueueQuery = withQuery(gql` variables: { asset_id: id, sort: sortOrder, + allAssets: id === null } }; }, @@ -441,23 +439,24 @@ const mapStateToProps = (state) => ({ moderation: state.moderation.toJS(), settings: state.settings.toJS(), auth: state.auth.toJS(), - assets: state.assets.get('assets') }); const mapDispatchToProps = (dispatch) => ({ ...bindActionCreators({ toggleModal, singleView, - updateAssets, fetchSettings, showBanUserDialog, hideBanUserDialog, hideShortcutsNote, + toggleStorySearch, showSuspendUserDialog, hideSuspendUserDialog, viewUserDetail, hideUserDetail, setSortOrder, + storySearchChange, + clearState }, dispatch), }); diff --git a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js new file mode 100644 index 000000000..cf47ca662 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js @@ -0,0 +1,87 @@ +import React from 'react'; +import {compose, gql} from 'react-apollo'; +import StorySearch from '../components/StorySearch'; +import {withRouter} from 'react-router'; +import withQuery from 'coral-framework/hocs/withQuery'; + +class StorySearchContainer extends React.Component { + constructor(props) { + super(props); + + this.state = { + searchValue: props.moderation.storySearchString + }; + } + + handleSearchChange = (e) => { + const {value} = e.target; + this.setState({ + searchValue: value + }); + } + + handleEsc = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + this.props.closeSearch(); + } + } + + handleEnter = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.search(); + } + } + + search = () => { + const {searchValue} = this.state; + this.props.storySearchChange(searchValue); + } + + goToStory = (id) => { + const {router, closeSearch} = this.props; + router.push(`/admin/moderate/${id}`); + closeSearch(); + } + + render () { + return ( + + ); + } +} + +export const withAssetSearchQuery = withQuery(gql` + query SearchStories($value: String = "") { + assets(query: {value: $value, limit: 10}) { + id + title + url + created_at + closedAt + author + } + } +`, { + options: ({moderation: {storySearchString = ''}}) => { + return { + variables: { + value: storySearchString + } + }; + } +}); + +export default compose( + withRouter, + withAssetSearchQuery +)(StorySearchContainer); diff --git a/client/coral-ui/components/Button.css b/client/coral-ui/components/Button.css index 5100d44a4..671c55bad 100644 --- a/client/coral-ui/components/Button.css +++ b/client/coral-ui/components/Button.css @@ -84,6 +84,16 @@ background: #4f5c67; } +.type--blue { + color: white; + background: #083b97; +} + +.type--blue:hover { + color: white; + background: #083b97; +} + .type--darkGrey { color: white; background: #616161; diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index 11f77df60..020eafbcb 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -19,6 +19,16 @@ const genAssetsByID = (context, ids) => AssetModel.find({ } }).then(util.singleJoinBy(ids, 'id')); +/** + * [getAssetsByQuery description] + * @param {Object} context the context of the request + * @param {Object} query the query + * @return {Promise} resolves the assets + */ +const getAssetsByQuery = (context, query) => { + return AssetsService.search(query); +}; + /** * This endpoint find or creates an asset at the given url when it is loaded. * @param {Object} context the context of the request @@ -65,6 +75,7 @@ module.exports = (context) => ({ // this operation create a new asset if one isn't found. getByURL: (url) => findOrCreateAssetByURL(context, url), + search: (query) => getAssetsByQuery(context, query), getByID: new DataLoader((ids) => genAssetsByID(context, ids)), getForMetrics: () => getAssetsForMetrics(context), getAll: new util.SingletonResolver(() => AssetModel.find({})) diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index e2cd58828..9889b62f2 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -6,12 +6,12 @@ const { } = require('../../perms/constants'); const RootQuery = { - assets(_, args, {loaders: {Assets}, user}) { + assets(_, {query}, {loaders: {Assets}, user}) { if (user == null || !user.can(SEARCH_ASSETS)) { return null; } - return Assets.getAll.load(); + return Assets.search(query); }, asset(_, query, {loaders: {Assets}}) { if (query.id) { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 9cd147605..09b50cb0e 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -104,7 +104,15 @@ input UsersQuery { sort: SORT_ORDER = REVERSE_CHRONOLOGICAL } +# AssetsQuery allows teh ability to query assets by specific fields +input AssetsQuery { + # a search string to match against titles, authors, urls, etc. + value: String = "" + + # Limit the number of results to be returned + limit: Int = 10 +} ################################################################################ ## Tags ################################################################################ @@ -635,7 +643,7 @@ type RootQuery { comment(id: ID!): Comment # All assets. Requires the `ADMIN` role. - assets: [Asset] + assets(query: AssetsQuery): [Asset] # Find or create an asset by url, or just find with the ID. asset(id: ID, url: String): Asset diff --git a/package.json b/package.json index 70a0da6d7..a0f42dd2b 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "passport": "^0.3.2", "passport-jwt": "^2.2.1", "passport-local": "^1.0.0", - "prop-types": "^15.5.8", + "prop-types": "^15.5.10", "react-apollo": "^1.1.0", "react-recaptcha": "^2.2.6", "react-toastify": "^1.5.0", diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js index 3bb3ef778..f02b49f7e 100644 --- a/routes/api/assets/index.js +++ b/routes/api/assets/index.js @@ -48,13 +48,13 @@ router.get('/', (req, res, next) => { Promise.all([ // Find the actuall assets. - FilterOpenAssets(AssetsService.search(search), filter) + FilterOpenAssets(AssetsService.search({value: search}), filter) .sort({[field]: (sort === 'asc') ? 1 : -1}) .skip(parseInt(skip)) .limit(parseInt(limit)), // Get the count of actual assets. - FilterOpenAssets(AssetsService.search(search), filter) + FilterOpenAssets(AssetsService.search({value: search}), filter) .count() ]) .then(([result, count]) => { diff --git a/services/assets.js b/services/assets.js index b81513b72..74bc1aa3a 100644 --- a/services/assets.js +++ b/services/assets.js @@ -104,13 +104,12 @@ module.exports = class AssetsService { } /** - * Finds assets matching keywords on the model. If `value` is an empty string, - * then it will not even perform a text search query. + * Finds assets matching keywords on the model. * @param {String} value string to search by. * @return {Promise} */ - static search(value = '', skip = null, limit = null) { - if (value.length === 0) { + static search({value, skip, limit} = {}) { + if (!value) { return AssetsService.all(skip, limit); } else { return AssetModel diff --git a/test/server/routes/api/assets/index.js b/test/server/routes/api/assets/index.js index 80708688b..63d3acb75 100644 --- a/test/server/routes/api/assets/index.js +++ b/test/server/routes/api/assets/index.js @@ -43,7 +43,7 @@ describe('/api/v1/assets', () => { .set(passport.inject({roles: ['ADMIN']})) .then((res) => { const body = res.body; - + expect(body).to.have.property('count', 2); expect(body).to.have.property('result'); diff --git a/views/admin.ejs b/views/admin.ejs index 946284622..4ef4112da 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -29,6 +29,11 @@ background-color: #FAFAFA; font-family: 'Roboto', sans-serif; } + + #root > div { + height: 100%; + } + /* putting this here until I can get webpack to behave */ .react-tagsinput { background-color: #fff; diff --git a/yarn.lock b/yarn.lock index 1633c27d1..a6a435dfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5064,7 +5064,7 @@ longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -6579,6 +6579,13 @@ prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, dependencies: fbjs "^0.8.9" +prop-types@^15.5.10: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + protocols@^1.1.0, protocols@^1.4.0: version "1.4.5" resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.5.tgz#21de1f441c4ef7094408ed9f1c94f7a114b87557"