2023-03-22 11:08:35 +03:00
|
|
|
const nql = require('@tryghost/nql');
|
|
|
|
const logging = require('@tryghost/logging');
|
|
|
|
|
|
|
|
class PostsExporter {
|
|
|
|
#models;
|
|
|
|
#getPostUrl;
|
|
|
|
#settingsCache;
|
|
|
|
#settingsHelpers;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {Object} dependencies
|
|
|
|
* @param {Object} dependencies.models
|
|
|
|
* @param {Object} dependencies.models.Post
|
|
|
|
* @param {Object} dependencies.models.Newsletter
|
|
|
|
* @param {Object} dependencies.models.Label
|
2023-03-28 13:03:17 +03:00
|
|
|
* @param {Object} dependencies.models.Product
|
2023-03-22 11:08:35 +03:00
|
|
|
* @param {Object} dependencies.getPostUrl
|
|
|
|
* @param {Object} dependencies.settingsCache
|
|
|
|
* @param {Object} dependencies.settingsHelpers
|
|
|
|
*/
|
|
|
|
constructor({models, getPostUrl, settingsCache, settingsHelpers}) {
|
|
|
|
this.#models = models;
|
|
|
|
this.#getPostUrl = getPostUrl;
|
|
|
|
this.#settingsCache = settingsCache;
|
|
|
|
this.#settingsHelpers = settingsHelpers;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {object} options
|
|
|
|
* @param {string} [options.filter]
|
|
|
|
* @param {string} [options.order]
|
|
|
|
* @param {string|number} [options.limit]
|
|
|
|
*/
|
|
|
|
async export({filter, order, limit}) {
|
|
|
|
const posts = await this.#models.Post.findPage({
|
2023-04-05 11:14:11 +03:00
|
|
|
filter: filter ?? 'status:published,status:sent',
|
2023-03-22 11:08:35 +03:00
|
|
|
order,
|
|
|
|
limit,
|
|
|
|
withRelated: [
|
|
|
|
'tiers',
|
|
|
|
'tags',
|
|
|
|
'authors',
|
|
|
|
'count.signups',
|
|
|
|
'count.paid_conversions',
|
|
|
|
'count.clicks',
|
|
|
|
'count.positive_feedback',
|
|
|
|
'count.negative_feedback',
|
|
|
|
'email'
|
|
|
|
]
|
|
|
|
});
|
|
|
|
|
|
|
|
const newsletters = (await this.#models.Newsletter.findAll()).models;
|
|
|
|
const labels = (await this.#models.Label.findAll()).models;
|
2023-03-28 13:03:17 +03:00
|
|
|
const tiers = (await this.#models.Product.findAll()).models;
|
2023-03-22 11:08:35 +03:00
|
|
|
|
|
|
|
const membersEnabled = this.#settingsHelpers.isMembersEnabled();
|
|
|
|
const membersTrackSources = membersEnabled && this.#settingsCache.get('members_track_sources');
|
|
|
|
const paidMembersEnabled = membersEnabled && this.#settingsHelpers.arePaidMembersEnabled();
|
|
|
|
const trackOpens = this.#settingsCache.get('email_track_opens');
|
|
|
|
const trackClicks = this.#settingsCache.get('email_track_clicks');
|
|
|
|
const hasNewslettersWithFeedback = !!newsletters.find(newsletter => newsletter.get('feedback_enabled'));
|
|
|
|
|
|
|
|
const mapped = posts.data.map((post) => {
|
|
|
|
let email = post.related('email');
|
2023-03-28 13:26:57 +03:00
|
|
|
|
|
|
|
// Weird bookshelf thing fix
|
|
|
|
if (!email.id) {
|
|
|
|
email = null;
|
|
|
|
}
|
|
|
|
|
2023-03-22 11:08:35 +03:00
|
|
|
let published = true;
|
|
|
|
if (post.get('status') === 'draft' || post.get('status') === 'scheduled') {
|
|
|
|
// Manually clear it to avoid including information for a post that was reverted to draft
|
|
|
|
email = null;
|
|
|
|
published = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const feedbackEnabled = email && email.get('feedback_enabled') && hasNewslettersWithFeedback;
|
|
|
|
const showEmailClickAnalytics = trackClicks && email && email.get('track_clicks');
|
|
|
|
|
|
|
|
return {
|
2023-04-03 15:50:43 +03:00
|
|
|
id: post.get('id'),
|
2023-03-22 11:08:35 +03:00
|
|
|
title: post.get('title'),
|
|
|
|
url: this.#getPostUrl(post),
|
|
|
|
author: post.related('authors').map(author => author.get('name')).join(', '),
|
|
|
|
status: this.mapPostStatus(post.get('status'), !!email),
|
|
|
|
created_at: post.get('created_at'),
|
|
|
|
updated_at: post.get('updated_at'),
|
|
|
|
published_at: published ? post.get('published_at') : null,
|
|
|
|
featured: post.get('featured'),
|
|
|
|
tags: post.related('tags').map(tag => tag.get('name')).join(', '),
|
|
|
|
post_access: this.postAccessToString(post),
|
2023-03-28 13:03:17 +03:00
|
|
|
email_recipients: email ? this.humanReadableEmailRecipientFilter(email?.get('recipient_filter'), labels, tiers) : null,
|
2023-03-28 12:42:30 +03:00
|
|
|
newsletter_name: newsletters.length > 1 && post.get('newsletter_id') && email ? newsletters.find(newsletter => newsletter.get('id') === post.get('newsletter_id'))?.get('name') : null,
|
2023-03-22 11:08:35 +03:00
|
|
|
sends: email?.get('email_count') ?? null,
|
|
|
|
opens: trackOpens ? (email?.get('opened_count') ?? null) : null,
|
|
|
|
clicks: showEmailClickAnalytics ? (post.get('count__clicks') ?? 0) : null,
|
|
|
|
free_signups: membersTrackSources && published ? (post.get('count__signups') ?? 0) : null,
|
2023-03-28 12:43:37 +03:00
|
|
|
paid_conversions: membersTrackSources && paidMembersEnabled && published ? (post.get('count__paid_conversions') ?? 0) : null,
|
2023-03-28 12:44:52 +03:00
|
|
|
feedback_more_like_this: feedbackEnabled ? (post.get('count__positive_feedback') ?? 0) : null,
|
|
|
|
feedback_less_like_this: feedbackEnabled ? (post.get('count__negative_feedback') ?? 0) : null
|
2023-03-22 11:08:35 +03:00
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
if (mapped.length) {
|
|
|
|
// Limit the amount of removeable columns so the structure is consistent depending on global settings
|
|
|
|
const removeableColumns = [];
|
|
|
|
|
|
|
|
if (newsletters.length <= 1) {
|
2023-03-28 12:42:30 +03:00
|
|
|
removeableColumns.push('newsletter_name');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!membersEnabled) {
|
2023-03-28 12:44:52 +03:00
|
|
|
removeableColumns.push('email_recipients', 'sends', 'opens', 'clicks', 'feedback_more_like_this', 'feedback_less_like_this');
|
2023-03-22 11:08:35 +03:00
|
|
|
} else if (!hasNewslettersWithFeedback) {
|
2023-03-28 12:44:52 +03:00
|
|
|
removeableColumns.push('feedback_more_like_this', 'feedback_less_like_this');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
2023-03-27 11:17:03 +03:00
|
|
|
if (membersEnabled && !trackClicks) {
|
2023-03-22 11:08:35 +03:00
|
|
|
removeableColumns.push('clicks');
|
|
|
|
}
|
|
|
|
|
2023-03-27 11:17:03 +03:00
|
|
|
if (membersEnabled && !trackOpens) {
|
2023-03-22 11:08:35 +03:00
|
|
|
removeableColumns.push('opens');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!membersTrackSources || !membersEnabled) {
|
2023-03-28 12:43:37 +03:00
|
|
|
removeableColumns.push('free_signups', 'paid_conversions');
|
2023-03-22 11:08:35 +03:00
|
|
|
} else if (!paidMembersEnabled) {
|
2023-03-28 12:43:37 +03:00
|
|
|
removeableColumns.push('paid_conversions');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
2023-03-27 11:17:03 +03:00
|
|
|
for (const columnToRemove of removeableColumns) {
|
2023-03-22 11:08:35 +03:00
|
|
|
for (const row of mapped) {
|
|
|
|
delete row[columnToRemove];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return mapped;
|
|
|
|
}
|
|
|
|
|
|
|
|
mapPostStatus(status, hasEmail) {
|
|
|
|
if (status === 'draft') {
|
|
|
|
return 'draft';
|
|
|
|
}
|
|
|
|
|
|
|
|
if (status === 'scheduled') {
|
|
|
|
return 'scheduled';
|
|
|
|
}
|
|
|
|
|
|
|
|
if (status === 'sent') {
|
|
|
|
return 'emailed only';
|
|
|
|
}
|
|
|
|
|
|
|
|
if (status === 'published') {
|
|
|
|
if (hasEmail) {
|
|
|
|
return 'published and emailed';
|
|
|
|
}
|
|
|
|
return 'published only';
|
|
|
|
}
|
|
|
|
return status;
|
|
|
|
}
|
|
|
|
|
|
|
|
postAccessToString(post) {
|
|
|
|
const visibility = post.get('visibility');
|
|
|
|
if (visibility === 'public') {
|
|
|
|
return 'Public';
|
|
|
|
}
|
|
|
|
|
|
|
|
if (visibility === 'members') {
|
2023-03-28 13:08:00 +03:00
|
|
|
return 'Members-only';
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (visibility === 'paid') {
|
2023-03-28 13:08:00 +03:00
|
|
|
return 'Paid members-only';
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (visibility === 'tiers') {
|
|
|
|
const tiers = post.related('tiers');
|
|
|
|
if (tiers.length === 0) {
|
2023-03-28 13:08:00 +03:00
|
|
|
return 'Specific tiers: none';
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
2023-03-28 13:08:00 +03:00
|
|
|
return 'Specific tiers: ' + tiers.map(tier => tier.get('name')).join(', ');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
return visibility;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @private Convert an email filter to a human readable string
|
|
|
|
* @param {string} recipientFilter
|
|
|
|
* @param {*} allLabels
|
2023-03-28 13:03:17 +03:00
|
|
|
* @param {*} allTiers
|
2023-03-22 11:08:35 +03:00
|
|
|
* @returns
|
|
|
|
*/
|
2023-03-28 13:03:17 +03:00
|
|
|
humanReadableEmailRecipientFilter(recipientFilter, allLabels, allTiers) {
|
2023-03-22 11:08:35 +03:00
|
|
|
// Examples: "label:test"; "label:test,label:batch1"; "status:-free,label:test", "all"
|
|
|
|
if (recipientFilter === 'all') {
|
2023-03-28 13:03:17 +03:00
|
|
|
return 'All subscribers';
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const parsed = nql(recipientFilter).parse();
|
2023-03-28 13:03:17 +03:00
|
|
|
const strings = this.filterToString(parsed, allLabels, allTiers);
|
2023-03-22 11:08:35 +03:00
|
|
|
return strings.join(', ');
|
|
|
|
} catch (e) {
|
|
|
|
logging.error(e);
|
|
|
|
return recipientFilter;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @private Convert an email filter to a human readable string
|
|
|
|
* @param {*} filter Parsed NQL filter
|
|
|
|
* @param {*} allLabels All available member labels
|
|
|
|
* @returns
|
|
|
|
*/
|
2023-03-28 13:03:17 +03:00
|
|
|
filterToString(filter, allLabels, allTiers) {
|
2023-03-22 11:08:35 +03:00
|
|
|
const strings = [];
|
|
|
|
if (filter.$and) {
|
|
|
|
// Not supported
|
|
|
|
} else if (filter.$or) {
|
|
|
|
for (const subfilter of filter.$or) {
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push(...this.filterToString(subfilter, allLabels, allTiers));
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
} else if (filter.yg) {
|
|
|
|
// Single filter grouped in brackets
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push(...this.filterToString(filter.yg, allLabels, allTiers));
|
2023-03-22 11:08:35 +03:00
|
|
|
} else {
|
|
|
|
for (const key of Object.keys(filter)) {
|
|
|
|
if (key === 'label') {
|
|
|
|
if (typeof filter.label === 'string') {
|
|
|
|
const labelSlug = filter.label;
|
|
|
|
const label = allLabels.find(l => l.get('slug') === labelSlug);
|
|
|
|
if (label) {
|
|
|
|
strings.push(label.get('name'));
|
|
|
|
} else {
|
|
|
|
strings.push(labelSlug);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-03-28 13:03:17 +03:00
|
|
|
if (key === 'tier') {
|
|
|
|
if (typeof filter.tier === 'string') {
|
|
|
|
const tierSlug = filter.tier;
|
|
|
|
const tier = allTiers.find(l => l.get('slug') === tierSlug);
|
|
|
|
if (tier) {
|
|
|
|
strings.push(tier.get('name'));
|
|
|
|
} else {
|
|
|
|
strings.push(tierSlug);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-03-22 11:08:35 +03:00
|
|
|
if (key === 'status') {
|
|
|
|
if (typeof filter.status === 'string') {
|
|
|
|
if (filter.status === 'free') {
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push('Free subscribers');
|
2023-03-22 11:08:35 +03:00
|
|
|
} else if (filter.status === 'paid') {
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push('Paid subscribers');
|
2023-03-22 11:08:35 +03:00
|
|
|
} else if (filter.status === 'comped') {
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push('Complimentary subscribers');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (filter.status.$ne === 'free') {
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push('Paid subscribers');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (filter.status.$ne === 'paid') {
|
2023-03-28 13:03:17 +03:00
|
|
|
strings.push('Free subscribers');
|
2023-03-22 11:08:35 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return strings;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = PostsExporter;
|