Page MenuHomeWickedGov Phorge

bucketer.js
No OneTemporary

Size
9 KB
Referenced Files
None
Subscribers
None

bucketer.js

/**
* Storage, retrieval and other processing of buckets. Provides
* cn.internal.bucketer.
*
* Bucket assignments are stored using the kvStore, in LocalStorage or a cookie.
* To maximize concision for users that fall back to cookies, the value consists
* of '*'-separated campaigns, each of which is made up of '!'-separated fields.
* The format is:
*
* NAME!START!END!VALUE[*NAME!START!END!VALUE..]
*
* - Start is stored as a second offset from UNIX timestamp 1400000000
* (March 2014).
* - End is stored as second offset from start.
*
* For example:
*
* 'WikiConference_USA!39942400!3729600!2'
*
* ...would be deserialized to:
*
* { WikiConference_USA: { start: 1439942400, end: 1443672000, val: 2 } }
*
*/
( function () {
var bucketer,
// Bucket objects by campaign; properties are campaign names.
// Retrieved from kvStore (which uses LocalStorage or a fallback cookie)
// or from a legacy cookie.
buckets = null,
// The campaign we're working with.
campaign = null,
kvStore = mw.centralNotice.kvStore,
multiStorageOption,
// Name of the legacy cookie for CentralNotice buckets. Its value is
// a compact serialization of buckets in the same format as is
// currently used here.
LEGACY_COOKIE = 'CN',
STORAGE_KEY = 'buckets';
/**
* Escape '*' and '!' in a campaign name to make it safe for serialization.
*
* @param name
*/
function escapeCampaignName( name ) {
return name.replace( /[*!]/g, function ( match ) {
return '&#' + match.charCodeAt( 0 );
} );
}
/**
* Decode any escaped '*' and '!' characters in a serialized campaign name.
*
* @param name
*/
function decodeCampaignName( name ) {
return name.replace( /&#(33|42)/, function ( match, $1 ) {
return String.fromCharCode( $1 );
} );
}
function parseSerializedBuckets( serialized ) {
var parsedBuckets = {};
serialized.split( '*' ).forEach( function ( strBucket ) {
var parts = strBucket.split( '!' ),
key = decodeCampaignName( parts[ 0 ] ),
start = parseInt( parts[ 1 ], 10 ) + 14e8,
end = start + parseInt( parts[ 2 ], 10 ),
val = parseInt( parts[ 3 ], 10 );
if ( key && start && end && !isNaN( val ) ) {
parsedBuckets[ key ] = {
start: start,
end: end,
val: val
};
}
} );
return parsedBuckets;
}
/**
* Check legacy bucket cookie, and try to migrate. If a legacy cookie is
* found, load buckets from there.
*
* @return {boolean} true if a legacy cookie was migrated, false if not
*/
function possiblyLoadAndMigrateLegacyBuckets() {
var cookieVal = $.cookie( LEGACY_COOKIE );
if ( cookieVal ) {
// We need to deserialize and store again to determine ttl
buckets = parseSerializedBuckets( cookieVal );
storeBuckets();
$.removeCookie( LEGACY_COOKIE, { path: '/' } );
return true;
}
return false;
}
/**
* Attempt to get buckets from the storage. If no stored buckets are
* found, set buckets to an empty object.
*/
function loadBuckets() {
var val = kvStore.getItem(
STORAGE_KEY,
kvStore.contexts.GLOBAL,
multiStorageOption
);
buckets = ( val ? parseSerializedBuckets( val ) : {} );
}
/**
* Store buckets using the kvStore. The storage item will be set to
* expire after the all the buckets it contains do.
*
* Though compact serialization is no longer needed for the majority
* of users, who'll get LocalStorage, it's useful for those who fall
* back to cookies, and it seems preferable to keep things consistent.
*/
function storeBuckets() {
var expires = Math.ceil( Date.now() / 1000 ),
// eslint-disable-next-line no-jquery/no-map-util
serialized = $.map( buckets, function ( opts, key ) {
var parts = [
escapeCampaignName( key ),
Math.floor( opts.start - 14e8 ),
Math.ceil( opts.end - opts.start ),
opts.val
];
if ( opts.end > expires ) {
expires = Math.ceil( opts.end );
}
return parts.join( '!' );
} ).join( '*' );
kvStore.setItem(
STORAGE_KEY,
serialized,
kvStore.contexts.GLOBAL,
// Convert expires to ttl in days
Math.ceil( ( expires - ( Date.now() / 1000 ) ) / 86400 ),
multiStorageOption
);
}
/**
* Get a random bucket (integer greater or equal to 0 and less than
* wgNoticeNumberOfControllerBuckets).
*
* @return {number}
*/
function getRandomBucket() {
return Math.floor(
Math.random() * mw.config.get( 'wgNoticeNumberOfControllerBuckets' )
);
}
/**
* Do all things bucket:
* - Get buckets from the kvStore or legacy cookie, if available.
* - If necessary, generate a random bucket for the campaign.
* - Ensure the stored end date for this campaign is up-to-date.
* - Go through all the buckets, purging expired buckets.
* - Store the updated bucket data using the kvStore.
*
* This should be called before a bucket is requested but after
* setCampaign() has been called.
*/
function retrieveProcessAndGet() {
var campaignName = campaign.name,
campaignStartDate,
bucket, bucketEndDate, retrievedBucketEndDate, val,
extension = mw.config.get( 'wgCentralNoticePerCampaignBucketExtension' ),
now = new Date(),
bucketsModified = false;
campaignStartDate = new Date();
campaignStartDate.setTime( campaign.start * 1000 );
// Buckets should end the time indicated by extension after
// the campaign's end
bucketEndDate = new Date();
bucketEndDate.setTime( campaign.end * 1000 );
bucketEndDate.setUTCDate( bucketEndDate.getUTCDate() + extension );
// Check if and how we can store buckets. Allow cookie fallback in all
// cases in which localStorage isn't available.
// If we have no storage options (cookies and localStorage disabled),
// loading and storing buckets will be no-ops, and a new bucket will be
// chosen every time.
multiStorageOption = kvStore.getMultiStorageOption( true );
// In all cases, check for a legacy cookie and try to migrate if one
// was found. Otherwise, load normally.
if ( !possiblyLoadAndMigrateLegacyBuckets() ) {
loadBuckets();
}
bucket = buckets[ campaignName ];
// If we have a valid bucket, just check and possibly update its
// expiry.
// Note that buckets that are expired but that were retrieved from
// storage (because they didn't have the chance to get purged) are
// not considered valid. In that case, for consistency, we choose a
// new random bucket, just as if no bucket had been found.
if ( bucket && bucketEndDate > now ) {
retrievedBucketEndDate = new Date();
retrievedBucketEndDate.setTime( bucket.end * 1000 );
if ( retrievedBucketEndDate.getTime() !== bucketEndDate.getTime() ) {
bucket.end = bucketEndDate.getTime() / 1000;
bucketsModified = true;
}
} else {
// We always use wgNoticeNumberOfControllerBuckets, and
// not the campaign's number of buckets, to determine
// how many possible buckets to randomly choose from. If
// the campaign actually has less buckets than that,
// the value is mapped down as necessary. This lets
// campaigns modify the number of buckets they use.
val = getRandomBucket();
bucket = {
val: val,
start: campaignStartDate.getTime() / 1000,
end: bucketEndDate.getTime() / 1000
};
buckets[ campaignName ] = bucket;
bucketsModified = true;
}
// Purge any expired buckets
for ( campaignName in buckets ) {
bucketEndDate = new Date();
bucketEndDate.setTime( buckets[ campaignName ].end * 1000 );
if ( bucketEndDate < now ) {
delete buckets[ campaignName ];
bucketsModified = true;
}
}
// Store the buckets if there were changes
if ( bucketsModified ) {
storeBuckets();
}
}
/**
* Bucketer object (intended for access from within this RL module).
*/
bucketer = mw.centralNotice.internal.bucketer = {
/**
* @param {Object} c A campaign object. Note: we don't check that the
* object is valid.
*/
setCampaign: function ( c ) {
campaign = c;
},
/**
* This should only be called once. setCampaign() must have been called
* first.
*/
process: function () {
retrieveProcessAndGet();
},
/**
* Get the bucket for this user for the campaign sent in setCampaign().
* setCampaign() and process() must have been called first. (Normally
* they're called by mw.centralNotice.chooseAndMaybeDisplay(). Calls
* to this method from mixin hooks don't have to worry about this.)
*
* @return {number}
*/
getBucket: function () {
return buckets[ campaign.name ].val;
},
/**
* Return the bucket pared down to fit within the number of buckets in the
* campaign. Note: setCampaign() must have been called first.
*
* @return {number}
*/
getReducedBucket: function () {
return bucketer.getBucket() % campaign.bucket_count;
},
/**
* Store this bucket value for this user for the campaign sent in
* setCampaign().
* setCampaign() and process() must have been called first. (Normally
* they're called by mw.centralNotice.chooseAndMaybeDisplay(). Calls
* to this method from mixin hooks don't have to worry about this.)
*
* @param {number} val The numeric bucket value to store
*/
setBucket: function ( val ) {
buckets[ campaign.name ].val = val;
storeBuckets();
}
};
}() );

File Metadata

Mime Type
text/plain
Expires
Tue, Aug 18, 16:02 (3 w, 5 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
9d/0a/c1a72a9248018a6a9f2c79478482
Default Alt Text
bucketer.js (9 KB)

Event Timeline