/
githubmirror
/
wp-calypso
Обзор
Документация
Войти
/
githubmirror
/
wp-calypso
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
client/state/stats/lists/utils.js
1 270 строк
34 KB
Dognose
Stats: page the video details chart through the full history (#112969)
28 июл 2026, 20:09
Не верифицирован
28 июл 2026, 20:09
4f8293f
Код
Авторство
О чём код?
import { camelCase, capitalize, sortBy } from '@automattic/js-utils'; import { translate, getLocaleSlug } from 'i18n-calypso'; import moment from 'moment'; import { PUBLICIZE_SERVICES_LABEL_ICON } from './constants'; /** * Reads the value at a path (array of keys) in an object, or `undefined` if any * segment along the way is missing. * @param {Object} object Object to read from * @param {Array} path Array of keys to walk */ const getFromPath = ( object, path ) => path.reduce( ( value, key ) => value?.[ key ], object ); /** @type ( key: string ) => string */ function getArchiveKeyLabel( key ) { const archiveKeyLabelMap = { author: translate( 'Authors' ), cat: translate( 'Categories' ), err: translate( 'Error' ), // This category is dedicated to the homepage set to Latest posts under the Archive tab. home: translate( 'Homepage (Latest posts)' ), search: translate( 'Searches' ), tag: translate( 'Tags' ), tax: translate( 'Taxonomies' ), date: translate( 'Dates' ), multiple: translate( 'Aggregated' ), other: translate( 'Others' ), }; return archiveKeyLabelMap[ key ] ?? capitalize( key ); } /** @type ( str: string ) => string */ function decodeUriEncoding( str ) { try { return decodeURIComponent( str ); } catch ( _ ) { return str; } } /** * Returns a string of the moment format for the period. Supports store stats * isoWeek and shortened formats. * @param {string} period Stats query * @param {string} date Stats date * @returns {Object} Period range */ export function getPeriodFormat( period, date ) { const strDate = date.toString(); switch ( period ) { case 'week': return strDate.length === 8 && strDate.substr( 4, 2 ) === '-W' ? 'YYYY-[W]WW' : 'YYYY-MM-DD'; case 'month': return strDate.length === 7 && strDate.substr( 4, 1 ) === '-' ? 'YYYY-MM' : 'YYYY-MM-DD'; case 'year': return strDate.length === 4 ? 'YYYY' : 'YYYY-MM-DD'; case 'day': default: return 'YYYY-MM-DD'; } } /** * Returns an object with the startOf and endOf dates * for the given stats period and date * @param {string} period Stats query * @param {string} date Stats date * @returns {Object} Period range */ export function rangeOfPeriod( period, date ) { const format = getPeriodFormat( period, date ); const momentDate = moment( date, format ).locale( 'en' ); const startOf = momentDate.clone().startOf( period ); const endOf = momentDate.clone().endOf( period ); if ( 'week' === period ) { if ( '0' === momentDate.format( 'd' ) ) { startOf.subtract( 6, 'd' ); endOf.subtract( 6, 'd' ); } else { startOf.add( 1, 'd' ); endOf.add( 1, 'd' ); } } return { startOf: startOf.format( 'YYYY-MM-DD' ), endOf: endOf.format( 'YYYY-MM-DD' ), }; } /** * Returns true if is auto refreshing astats is allowed * for the give stats query * It's allowed for queries without dates and for periods including today * @param {string} query Stats query * @returns {boolean} AutoRefresh allowed or not */ export function isAutoRefreshAllowedForQuery( query ) { if ( ! query || ! query.date || ( ! query.unit && ! query.period ) ) { return true; } const range = rangeOfPeriod( query.period || query.unit, query.date ); const today = moment(); return today >= moment( range.startOf ) && today < moment( range.endOf ).add( 1, 'day' ); } /** * Parse the avatar URL * @param {string} avatarUrl Raw avatar URL * @returns {string} Parsed URL */ export function parseAvatar( avatarUrl ) { if ( ! avatarUrl ) { return null; } const [ avatarBaseUrl ] = avatarUrl.split( '?' ); return avatarBaseUrl + '?d=mm'; } /** * Builds data into escaped array for CSV export * @param {Object} data Normalized stats data object * @param {string} parent Label of parent * @param {(value: unknown[], data?: Object) => unknown[]} modifierFn Modifies the export row. * @returns {Array} CSV Row */ export function buildExportArray( data, parent = null, modifierFn = null ) { if ( ! data || ! data.label || ! data.value ) { return []; } const label = parent ? parent + ' > ' + String( data.label ) : String( data.label ); // eslint-disable-next-line const escapedLabel = label.replace( /\"/, '""' ); let exportData = [ [ '"' + escapedLabel + '"', data.value ] ]; // Includes the URL for content data, but not for "Countries" data where it doesn't exist. if ( data.actions && data.actions.length ) { exportData = [ [ '"' + escapedLabel + '"', data.value, data.actions[ 0 ].data ] ]; } if ( modifierFn ) { exportData = [ modifierFn( exportData[ 0 ], data ) ]; } if ( data.children ) { const childData = data.children.map( ( child ) => { return buildExportArray( child, label, modifierFn ); } ); exportData = exportData.concat( childData.flat() ); } return exportData; } /** * Returns a serialized stats query, used as the key in the * `state.stats.lists.items` and `state.stats.lists.requesting` state objects. * @param {Object} query Stats query * @returns {string} Serialized stats query */ export function getSerializedStatsQuery( query = {} ) { return JSON.stringify( sortBy( Object.entries( query ), ( pair ) => pair[ 0 ] ) ); } /** * Return delta data in a format used by 'extensions/woocommerce/app/store-stats`. The fields array is matched to * the data in a single object. * @param {Object} payload - response * @returns {Array} - Array of data objects */ export function parseOrderDeltas( payload ) { if ( ! payload || ! payload.deltas || ! payload.delta_fields || Object.keys( payload.deltas ).length === 0 ) { return []; } return payload.deltas.map( ( row ) => { // will be renamed to deltas const notPeriodKeys = Object.keys( row ).filter( ( key ) => key !== 'period' ); const newRow = { period: parseUnitPeriods( payload.unit, row.period ).format( 'YYYY-MM-DD' ) }; notPeriodKeys.forEach( ( key ) => { newRow[ key ] = row[ key ].reduce( ( acc, curr, i ) => { acc[ payload.delta_fields[ i ] ] = curr; return acc; }, {} ); } ); return newRow; } ); } export const chartLabelformats = { // This specifies an hour for the stats x-axis label. hour: 'HH:mm', // This specifies a day for the stats x-axis label. day: 'MMM D', // This specifies a week for the stats x-axis label. week: 'MMM D', month: 'MMM', year: 'YYYY', }; /** * Create the correct property and value for a label to be used in a chart * @param {string} unit - day, week, month, year * @param {Object} date - moment object * @param {Object} localizedDate - moment object * @returns {Object} chart labels */ export function getChartLabels( unit, date, localizedDate ) { const validDate = moment.isMoment( date ) && date.isValid(); const validLocalizedDate = moment.isMoment( localizedDate ) && localizedDate.isValid(); if ( validDate && validLocalizedDate && unit ) { const dayOfWeek = date.toDate().getDay(); const isWeekend = 'day' === unit && ( 6 === dayOfWeek || 0 === dayOfWeek ); const labelName = `label${ capitalize( unit ) }`; return { [ labelName ]: localizedDate.format( chartLabelformats[ unit ] ), classNames: isWeekend ? [ 'is-weekend' ] : [], }; } return {}; } /** * Return data in a format used by 'components/chart`. The fields array is matched to * the data in a single object. * @param {Object} payload - response * @returns {Array} - Array of data objects */ export function parseOrdersChartData( payload ) { if ( ! payload || ! payload.data ) { return []; } return payload.data.map( function ( record ) { // Initialize data const dataRecord = {}; // Fill Field Values record.forEach( function ( value, i ) { dataRecord[ payload.fields[ i ] ] = value; } ); if ( dataRecord.period ) { const date = parseUnitPeriods( payload.unit, dataRecord.period ).locale( 'en' ); const localizedDate = parseUnitPeriods( payload.unit, dataRecord.period ); Object.assign( dataRecord, getChartLabels( payload.unit, date, localizedDate ) ); } dataRecord.period = parseUnitPeriods( payload.unit, dataRecord.period ).format( 'YYYY-MM-DD' ); return dataRecord; } ); } /** * Return data in a format used by 'components/chart`. The fields array is matched to * the data in a single object. * @param {Object} payload - response * @param {Array} nullAttributes - properties on data objects to be initialized with * a null value * @returns {Array} - Array of data objects */ export function parseChartData( payload, nullAttributes = [] ) { if ( ! payload || ! payload.data ) { return []; } return payload.data.map( function ( record ) { // Initialize data const dataRecord = nullAttributes.reduce( ( memo, attribute ) => { memo[ attribute ] = null; return memo; }, {} ); // Fill Field Values record.forEach( function ( value, i ) { // Remove W from weeks if ( 'period' === payload.fields[ i ] ) { value = value.replace( /W/g, '-' ); } dataRecord[ payload.fields[ i ] ] = value; } ); if ( dataRecord.period ) { const date = moment( dataRecord.period, 'YYYY-MM-DD' ).locale( 'en' ); const localeSlug = getLocaleSlug(); // The period could be a full time format. const localizedDate = moment( dataRecord.period, 'YYYY-MM-DD HH:mm:ss' ).locale( localeSlug ); Object.assign( dataRecord, getChartLabels( payload.unit, date, localizedDate ) ); } return dataRecord; } ); } /** * Return moment date object for the day or last day of the period. * @param {string} unit - day, week, month or year * @param {string} period - period in shortened store sting format, eg '2017-W26' * @returns {Object} - moment date object */ export function parseUnitPeriods( unit, period ) { let splitYearWeek; const localeSlug = getLocaleSlug(); switch ( unit ) { case 'week': splitYearWeek = period.split( '-W' ); return moment() .locale( localeSlug ) .isoWeekYear( splitYearWeek[ 0 ] ) .isoWeek( splitYearWeek[ 1 ] ) .endOf( 'isoWeek' ); case 'month': return moment( period, 'YYYY-MM' ).locale( localeSlug ).endOf( 'month' ); case 'year': return moment( period, 'YYYY' ).locale( localeSlug ).endOf( 'year' ); case 'day': default: return moment( period, 'YYYY-MM-DD' ).locale( localeSlug ); } } export const normalizers = { /** * Returns a normalized payload from `/sites/{ site }/stats` * @param {Object} data Stats data * @returns {Object | null} Normalized stats data */ stats( data ) { if ( ! data || ! data.stats ) { return null; } return Object.fromEntries( Object.entries( data.stats ).map( ( [ key, value ] ) => [ camelCase( key ), value ] ) ); }, /** * Returns a normalized payload from `/sites/{ site }/stats/insights` * @param {Object} data Stats query * @returns {Object | null} Normalized stats data */ statsInsights: ( data ) => { if ( ! data || typeof data.highest_day_of_week !== 'number' ) { return {}; } const { highest_hour, highest_day_percent, highest_day_of_week, highest_hour_percent, hourly_views, years, } = data; // Adjust Day of Week from 0 = Monday to 0 = Sunday (for Moment) let dayOfWeek = highest_day_of_week + 1; if ( dayOfWeek > 6 ) { dayOfWeek = 0; } const localeSlug = getLocaleSlug(); return { day: moment().locale( localeSlug ).day( dayOfWeek ).format( 'dddd' ), percent: Math.round( highest_day_percent ), hour: moment().locale( localeSlug ).hour( highest_hour ).startOf( 'hour' ).format( 'LT' ), hourPercent: Math.round( highest_hour_percent ), hourlyViews: hourly_views, years, }; }, /** * Returns a normalized payload from `/sites/{ site }/stats/top-posts` * @param {Object} data Stats data * @param {Object} query Stats query * @param {number} siteId Site ID * @param {Object} site Site object * @returns {Object | null} Normalized stats data */ statsTopPosts: ( data, query, siteId, site ) => { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf, endOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary', 'postviews' ] : [ 'days', startOf, 'postviews' ]; const viewData = getFromPath( data, dataPath ) ?? []; return viewData.map( ( item ) => { // To avoid showing a detail page for the Homepage set as latest posts. const detailPage = site && item.href ? `/stats/post/${ item.id }/${ site.slug }` : null; let inPeriod = false; // Archive and home pages do not have dates if ( item.date ) { const postDate = moment( item.date ); // TODO: might be nice to update moment and use isSameOrAfter and isSameOrBefore if ( ( postDate.isAfter( startOf, 'day' ) || postDate.isSame( startOf, 'day' ) ) && ( postDate.isBefore( endOf, 'day' ) || postDate.isSame( endOf, 'day' ) ) ) { inPeriod = true; } } return { id: item.id, label: item.title, value: item.views, page: detailPage, public: item.public, actions: [ { type: 'link', data: item.href, }, ], labelIcon: null, children: null, className: inPeriod ? 'published' : null, }; } ); }, /** * Returns a normalized payload from `/sites/{ site }/stats/archives` * @param {Object} data Stats data * @param {Object} query Stats query * @returns {Array} Normalized stats data */ statsArchives: ( data, query ) => { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary' ] : [ 'days', startOf ]; const archivesData = getFromPath( data, dataPath ) ?? {}; // Add null check for archivesData // TODO: ensure API always returns an object here. Now it return an empty array when there's no items. if ( ! archivesData || typeof archivesData !== 'object' || Array.isArray( archivesData ) ) { return []; } const archives = Object.keys( archivesData ).reduce( ( accumulatedArchives, archiveKey ) => { const archiveItems = archivesData[ archiveKey ]; // Add null check for archiveItems if ( ! archiveItems ) { return accumulatedArchives; } // Taxonomy items are grouped by taxonomy term. if ( 'tax' === archiveKey ) { let totalTaxViews = 0; const taxItems = Object.keys( archiveItems ).map( ( taxKey ) => { const taxItem = archiveItems[ taxKey ]; const hasSubItems = Array.isArray( taxItem ) && taxItem.length > 0; let itemViews = 0; if ( hasSubItems ) { const children = taxItem.map( ( item ) => { itemViews += item.views; totalTaxViews += item.views; return { label: decodeUriEncoding( item.value ), value: item.views, link: item.href, }; } ); return { label: taxKey, value: itemViews, children, }; } return { label: taxKey, value: itemViews, }; } ); accumulatedArchives.push( { label: getArchiveKeyLabel( archiveKey ), value: totalTaxViews, children: taxItems, } ); } else { const hasItems = Array.isArray( archiveItems ) && archiveItems.length > 0; if ( hasItems ) { let totalViews = 0; const children = archiveItems .filter( ( i ) => !! i.value ) .map( ( item ) => { totalViews += item.views; return { label: [ 'home' ].includes( archiveKey ) ? item.href : decodeUriEncoding( item.value ), value: item.views, link: item.href, }; } ); accumulatedArchives.push( { label: getArchiveKeyLabel( archiveKey ), value: totalViews, // Show the Homepage without children if there are no other pages under it. children: 'home' === archiveKey && children.length < 2 ? null : children, } ); } } return accumulatedArchives; }, [] ); return archives.sort( ( a, b ) => b.value - a.value ); }, /** * Returns a normalized payload from `/sites/{ site }/stats/country-views` * @param {Object} data Stats data * @param {Object} query Stats query * @returns {Object | null} Normalized stats data */ statsCountryViews: ( data, query = {} ) => { // parsing a country-views response requires a period and date if ( ! data || ! query.period || ! query.date ) { return null; } const { startOf } = rangeOfPeriod( query.period, query.date ); const countryInfo = data?.[ 'country-info' ] ?? {}; // the API response object shape depends on if this is a summary request or not const dataPath = query.summarize ? [ 'summary', 'views' ] : [ 'days', startOf, 'views' ]; // filter out country views that have no legitimate country data associated with them const countryData = ( getFromPath( data, dataPath ) ?? [] ).filter( ( viewData ) => { // Ignore the unknown location of sources from the legacy stats geoviews table. if ( [ 'A1', 'A2', 'ZZ' ].includes( viewData.country_code ) ) { return false; } // TODO: Investigate ignored countries that have `false` as the country_full data. if ( countryInfo[ viewData.country_code ] && ! countryInfo[ viewData.country_code ].country_full ) { return false; } return countryInfo[ viewData.country_code ]; } ); return countryData.map( ( viewData ) => { const country = countryInfo[ viewData.country_code ]; // ’ in country names causes google's geo viz to break return { label: viewData.location || country.country_full.replace( /’/, "'" ), countryCode: viewData.country_code, countryFull: country.country_full, value: viewData.views, region: country.map_region, ...( viewData.coordinates && { coordinates: viewData.coordinates } ), }; } ); }, /** * Returns a normalized statsPublicize array, ready for use in stats-module * @param {Object} data Stats data * @returns {Array} Parsed publicize data array */ statsPublicize( data = {} ) { if ( ! data || ! data.services ) { return []; } return data.services.map( ( service ) => { const { label, icon } = PUBLICIZE_SERVICES_LABEL_ICON[ service.service ]; return { label, icon, value: service.followers }; } ); }, /** * Returns a normalized data for the `stats/video-plays` API request with `complete_stats` query param set to `1` * This returns more enriched data about the video plays, including impressions, watch time, and retention rate. * @param {Object} data Stats data * @param {Object} query Stats query * @returns {Array} Parsed data array */ statsVideoPlaysCompleteStats: ( data, query = {} ) => { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const videoPlaysData = getFromPath( data, query.summarize ? [ 'days', 'summary', 'data' ] : [ 'days', startOf, 'data' ] ) ?? []; const normalizedData = videoPlaysData.map( ( item ) => { return { post_id: item.post_id, label: item.title, views: item.views, impressions: item.impressions, watch_time: item.watch_time, retention_rate: item.retention_rate, }; } ); return [ [ 'post_id', 'title', 'views', 'impressions', 'watch_time', 'retention_rate' ], ...normalizedData, ]; }, /** * Returns a normalized statsVideoPlays array, ready for use in stats-module * @param {Object} data Stats data * @param {Object} query Stats query * @param {number} siteId Site ID * @param {Object} site Site object * @returns {Array} Normalized stats data */ statsVideoPlays( data, query = {}, siteId, site ) { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const isCompleteStats = query.complete_stats; if ( isCompleteStats ) { return normalizers.statsVideoPlaysCompleteStats( data, query, siteId, site ); } const videoPlaysData = getFromPath( data, query.summarize ? [ 'days', 'summary', 'plays' ] : [ 'days', startOf, 'plays' ] ) ?? []; return videoPlaysData.map( ( item ) => { const detailPage = site ? `/stats/${ query.period }/videodetails/${ site.slug }?post=${ item.post_id }` : null; return { label: item.title, page: detailPage, value: item.plays, actions: [ { type: 'link', data: item.url, }, ], }; } ); }, /** * Returns a normalized statsFollowers object * @param {Object} data Stats data * @returns {?Object} Normalized stats data */ statsFollowers( data ) { if ( ! data ) { return null; } const { total_wpcom, total_email, total } = data; const subscriberData = data?.subscribers ?? []; const subscribers = subscriberData.map( ( item ) => { return { label: item.label, iconClassName: 'avatar-user', icon: parseAvatar( item.avatar ), link: item.url, value: { type: 'relative-date', value: item.date_subscribed, }, actions: [ { type: 'follow', data: item.follow_data ? item.follow_data.params : false, }, ], }; } ); return { total_wpcom, total_email, total, subscribers }; }, statsUTM( originalData ) { if ( ! Array.isArray( originalData ) ) { return []; } const newData = []; // Flatten the data into a shallow array. originalData.forEach( ( row ) => { newData.push( row ); const children = row?.children; if ( children ) { const newChildren = children.map( ( child ) => { return { ...child, context: row.label }; } ); newData.push( ...newChildren ); } } ); return newData.map( ( row ) => { // Label should include parent context if present. // ie: "parent label > child label" -- including surrounding quotes. let label = row?.context ? `${ row.context } > ${ row.label }` : row.label; label = label.replace( /"/g, '""' ); // Escape double quotes return { label, value: row.value, }; } ); }, statsCommentFollowers( data ) { if ( ! data ) { return null; } const page = data.page || 0; const pages = data.pages || 0; const total = data.total || 0; let posts = []; if ( data.posts ) { posts = data.posts.map( ( item ) => { if ( 0 === item.id ) { return { label: 'All Posts', value: item.followers, }; } return { label: item.title, link: item.url, labelIcon: 'external', value: item.followers, }; } ); } return { page, pages, total, posts }; }, statsComments( data, query, siteId, site ) { if ( ! data ) { return null; } const adminUrl = site?.options?.admin_url ?? null; let authors = []; if ( data.authors ) { authors = data.authors.map( ( author ) => { return { label: author.name, value: author.comments, iconClassName: 'avatar-user', icon: parseAvatar( author.gravatar ), link: adminUrl + 'edit-comments.php' + author.link, className: 'module-content-list-item-large', actions: [ { type: 'follow', data: author.follow_data ? author.follow_data.params : false, }, ], }; } ); } let posts = []; if ( data.posts ) { posts = data.posts.map( ( post ) => { return { label: post.name, value: post.comments, page: site ? '/stats/post/' + post.id + '/' + site.slug : null, actions: [ { type: 'link', data: post.link, }, ], }; } ); } return { authors, posts }; }, /** * Returns a normalized statsVideo array, ready for use in stats-module * @param {Object} payload Stats response payload * @returns {Array} Parsed data array */ statsVideo( payload ) { if ( ! payload ) { return null; } let data = []; // When the requested window has no rows at all, the endpoint returns a single // `{ date, p }` object instead of the usual `[ date, value ]` tuples. if ( Array.isArray( payload.data ) ) { data = payload.data .filter( ( item ) => Array.isArray( item ) ) .map( ( item ) => { return { period: item[ 0 ], value: item[ 1 ] }; } ); } // `fields` names the tuple columns after the leading period (one metric // for a single statType, four for statType=all). Rows keyed by metric // name let consumers pick series without caring about column order. let metrics = null; let rows = null; if ( Array.isArray( payload.fields ) && payload.fields.length >= 2 && Array.isArray( payload.data ) ) { metrics = payload.fields.slice( 1 ); rows = payload.data .filter( ( item ) => Array.isArray( item ) ) .map( ( item ) => { const row = { period: item[ 0 ] }; metrics.forEach( ( metric, index ) => { row[ metric ] = Number( item[ index + 1 ] ) || 0; } ); return row; } ); } let pages = []; if ( payload.pages ) { pages = payload.pages.map( ( item ) => { return { label: item, link: item, }; } ); } // The endpoint also returns the video's attachment post, which carries // the title and upload date. return { pages, data, post: payload.post ?? null, metrics, rows, // Range queries also return canonical totals over the window, keyed // by metric name. total: payload.total ?? null, }; }, /** * Returns a normalized statsTopAuthors array, ready for use in stats-module * @param {Object} data Stats data * @param {Object} query Stats query * @param {number} siteId Site ID * @param {Object} site Site Object * @returns {Array} Normalized stats data */ statsTopAuthors( data, query = {}, siteId, site ) { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary', 'authors' ] : [ 'days', startOf, 'authors' ]; const authorsData = getFromPath( data, dataPath ) ?? []; return authorsData.map( ( item ) => { const record = { label: item.name || translate( 'Untracked Authors' ), iconClassName: 'avatar-user', icon: parseAvatar( item.avatar ), children: null, value: item.views, className: 'module-content-list-item-large', }; if ( item.posts && item.posts.length > 0 ) { record.children = item.posts.map( ( child ) => { return { label: child.title, value: child.views, page: site ? '/stats/post/' + child.id + '/' + site.slug : null, actions: [ { type: 'link', data: child.url, }, ], children: null, }; } ); } return record; } ); }, /** * Returns a normalized statsTags array, ready for use in stats-module * @param {Object} data Stats data * @returns {Array} Parsed data array */ statsTags( data ) { if ( ! data || ! data.tags ) { return []; } const getTagTypeIcon = ( type ) => { return type === 'category' ? 'folder' : type; }; return data.tags.map( ( item ) => { let children; const hasChildren = item.tags.length > 1; const labels = item.tags.map( ( tagItem ) => { return { label: tagItem.name, labelIcon: getTagTypeIcon( tagItem.type ), link: hasChildren ? null : tagItem.link, }; } ); if ( hasChildren ) { children = item.tags.map( ( tagItem ) => { return { label: tagItem.name, labelIcon: getTagTypeIcon( tagItem.type ), value: null, children: null, link: tagItem.link, }; } ); } return { label: labels, link: labels.length > 1 ? null : labels[ 0 ].link, value: item.views, children: children, }; } ); }, /* * Returns a normalized statsClicks array, ready for use in stats-module * * @param {object} data Stats data * @param {object} query Stats query * @returns {Array} Parsed data array */ statsClicks( data, query ) { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary', 'clicks' ] : [ 'days', startOf, 'clicks' ]; const statsData = getFromPath( data, dataPath ) ?? []; const output = statsData.map( ( item ) => { const hasChildren = item.children && item.children.length > 0; const newRecord = { label: item.name, value: item.views, children: null, link: item.url, icon: item.icon, labelIcon: hasChildren ? null : 'external', }; if ( item.children ) { newRecord.children = item.children.map( ( child ) => { return { // Remove the parent name from the child name. // If the child name is the same as the parent name, use a slash instead. label: child.name?.replace( item.name, '' ) || '/', value: child.views, children: null, link: child.url, labelIcon: 'external', }; } ); } return newRecord; } ); return output; }, /* * Returns a normalized statsReferrers array, ready for use in stats-module * * @param {object} data Stats data * @param {object} query Stats query * @param {Int} siteId Site ID * @returns {Array} Parsed data array */ statsReferrers( data, query, siteId ) { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary', 'groups' ] : [ 'days', startOf, 'groups' ]; let statsData = getFromPath( data, dataPath ) ?? []; const parseItem = ( item ) => { let children; if ( item.children && item.children.length > 0 ) { children = item.children.map( ( child ) => { const parsed = parseItem( child ); // Remove the parent name from the child name. // If the child name is the same as the parent name, use a slash instead. parsed.label = child.name?.replace( item.name, '' ) || '/'; return parsed; } ); } const record = { label: item.name, value: item.views, link: item.url, labelIcon: children ? null : 'external', children, }; if ( item.icon ) { record.icon = item.icon; } return record; }; // If there's only one item in a group, then we expand the children to the parent level. statsData = statsData.map( ( item ) => { if ( item.results?.length === 1 ) { return { ...item.results[ 0 ], group: item.results[ 0 ].name, total: item.results[ 0 ].views, }; } return item; } ); return statsData.map( ( item ) => { let actions = []; if ( ( item.url && -1 !== item.url.indexOf( item.name ) ) || ( ! item.url && item.name === item.group && -1 !== item.name.indexOf( '.' ) ) ) { actions = [ { type: 'spam', data: { siteID: siteId, domain: item.name, }, }, ]; } return { ...parseItem( { ...item, children: item.results, views: item.total } ), actions, actionMenu: actions.length, }; } ); }, statsVisits( payload ) { return parseChartData( payload, [ 'visits', 'likes', 'visitors', 'comments', 'posts' ] ); }, statsOrders( payload ) { return { data: parseOrdersChartData( payload ), deltas: parseOrderDeltas( payload ), }; }, statsTopSellers( payload ) { if ( ! payload || ! payload.data ) { return []; } return payload.data; }, statsTopCategories( payload ) { if ( ! payload || ! payload.data ) { return []; } return payload.data; }, statsTopCoupons( payload ) { if ( ! payload || ! payload.data ) { return []; } return payload.data; }, statsTopEarners( payload ) { if ( ! payload || ! payload.data ) { return []; } return payload.data; }, statsAds( payload ) { if ( ! payload || ! payload.data ) { return []; } return parseChartData( payload, [ 'impressions', 'revenue', 'cpm' ] ); }, /* * Returns a normalized statsSearchTerms array, ready for use in stats-module * * @param {object} data Stats data * @param {object} query Stats query * @returns {Array} Parsed data array */ statsSearchTerms( data, query ) { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary' ] : [ 'days', startOf ]; const searchTerms = getFromPath( data, dataPath.concat( [ 'search_terms' ] ) ) ?? []; return searchTerms.map( ( day ) => { return { label: day.term, className: 'user-selectable', value: day.views, }; } ); }, /* * Returns a normalized statsFileDownloads array, ready for use in stats-module * * @param {object} data Stats data * @param {object} query Stats query * @returns {Array} Parsed data array */ statsFileDownloads( data, query ) { if ( ! data || ! query.period || ! query.date ) { return []; } const { startOf } = rangeOfPeriod( query.period, query.date ); const dataPath = query.summarize ? [ 'summary', 'files' ] : [ 'days', startOf, 'files' ]; const statsData = getFromPath( data, dataPath ) ?? []; return statsData.map( ( item ) => { return { label: item.relative_url, shortLabel: item.filename, page: null, value: item.downloads, link: item.download_url, linkTitle: item.relative_url, labelIcon: 'external', }; } ); }, /** * Returns a normalized statsEmailsSummary array, ready for use in stats-module * @param {Object} data Stats data * @param {Object} query Stats query * @param {number} siteId Site ID * @param {Object} site Site object * @returns {Array} Normalized stats data */ statsEmailsSummary( data, query, siteId, site ) { if ( ! data ) { return []; } const emailsData = data?.posts ?? []; return emailsData.map( ( { id, href, date, title, type, opens, clicks, opens_rate, clicks_rate, unique_opens, unique_clicks, total_sends, } ) => { const detailPage = site ? `/stats/email/opens/day/${ id }/${ site.slug }` : null; return { id, href, date, label: title, type, value: clicks_rate || '0', opens: opens || '0', clicks: clicks || '0', opens_rate: opens_rate || '0', clicks_rate: clicks_rate || '0', unique_opens: unique_opens || '0', unique_clicks: unique_clicks || '0', total_sends: total_sends || '0', page: detailPage, actions: [ { type: 'link', data: href, }, ], }; } ); }, };