Merge branch 'master' into zen-mode

This commit is contained in:
Kim Gardner
2018-01-26 15:31:22 -05:00
committed by GitHub
43 changed files with 1332 additions and 926 deletions
-18
View File
@@ -18,7 +18,6 @@ program
.command('token', 'work with the access tokens')
.command('users', 'work with the application auth')
.command('migration', 'provides utilities for migrating the database')
.command('verify', 'provides utilities for performing data verification')
.command(
'plugins',
'provides utilities for interacting with the plugin system'
@@ -41,20 +40,3 @@ if (!commands.includes(command)) {
}
process.exit(1);
}
// /**
// * When this process exists, check to see if we have a running command, if we do
// * check to see if it is still running. If it is, then kill it with a SIGINT
// * signal. This is for the use case where we want to kill the process that is
// * labeled with the PID written out by the parent process.
// */
// process.once('exit', () => {
// if (
// // program.runningCommand &&
// program.runningCommand.killed === false &&
// program.runningCommand.exitCode === null
// ) {
// program.runningCommand.kill('SIGINT');
// }
// });
+59 -27
View File
@@ -5,6 +5,7 @@
*/
const util = require('./util');
const _ = require('lodash');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
@@ -25,46 +26,60 @@ async function createMigration(name) {
}
}
async function runMigrations() {
async function runMigrations(options) {
const { yes, queryBatchSize, updateBatchSize } = options;
try {
let { backedUp } = await inquirer.prompt([
{
type: 'confirm',
name: 'backedUp',
message: 'Did you perform a database backup',
default: false,
},
]);
if (!yes) {
const { backedUp } = await inquirer.prompt([
{
type: 'confirm',
name: 'backedUp',
message: 'Did you perform a database backup',
default: false,
},
]);
if (!backedUp) {
throw new Error(
'Please backup your databases prior to migrations occuring'
);
if (!backedUp) {
throw new Error(
'Please backup your databases prior to migrations occuring'
);
}
}
// Get the migrations to run.
let migrations = await MigrationService.listPending();
const migrations = await MigrationService.listPending();
console.log('Now going to run the following migrations:\n');
for (let { filename } of migrations) {
for (const { filename } of migrations) {
console.log(`\tmigrations/${filename}`);
}
let { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with migrations',
default: false,
},
]);
if (!yes) {
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with migrations',
default: false,
},
]);
if (confirm) {
// Run the migrations.
await MigrationService.run(migrations);
if (confirm) {
// Run the migrations.
await MigrationService.run(migrations, {
queryBatchSize,
updateBatchSize,
});
} else {
console.warn('Skipping migrations');
}
} else {
console.warn('Skipping migrations');
// Run the migrations.
await MigrationService.run(migrations, {
queryBatchSize,
updateBatchSize,
});
}
util.shutdown();
@@ -83,8 +98,25 @@ program
.description('creates a new migration')
.action(createMigration);
// Bypasses issue that defaults + coercion doesn't work well together.
// Ref: https://github.com/tj/commander.js/issues/400#issuecomment-310860869
const parse10 = _.ary(_.partialRight(parseInt, 10), 1);
program
.command('run')
.option(
'-q, --query-batch-size <n>',
'change the size of queried documents that are batched at a time',
parse10,
10000
)
.option(
'-u, --update-batch-size <n>',
'change the size of documents that are batched before the update is sent',
parse10,
20000
)
.option('-y, --yes', 'will answer yes to all questions')
.description('runs all pending migrations')
.action(runMigrations);
+44 -42
View File
@@ -235,7 +235,7 @@ async function reconcileLocalPlugins({ skipRemote, dryRun }) {
if (output.status) {
throw new Error(
'Could not install local plugin dependencies, errors occured during install'
'Could not install local plugin dependencies, errors occurred during install'
);
}
@@ -253,59 +253,61 @@ async function reconcilePluginDeps({
dryRun,
upgradeRemote,
}) {
let startTime = new Date();
try {
let startTime = new Date();
// We don't need to do anything if we skip everything....
if (skipLocal && skipRemote) {
return;
}
// We don't need to do anything if we skip everything....
if (skipLocal && skipRemote) {
return;
}
// Traverse local plugins and install dependencies if enabled.
if (!skipLocal) {
await reconcileLocalPlugins({ skipRemote, dryRun });
}
// Traverse local plugins and install dependencies if enabled.
if (!skipLocal) {
await reconcileLocalPlugins({ skipRemote, dryRun });
}
// Locate any external plugins and install them.
if (!skipRemote) {
let results = [];
try {
results = await reconcileRemotePlugins({
// Locate any external plugins and install them.
if (!skipRemote) {
const results = await reconcileRemotePlugins({
skipLocal,
skipRemote,
dryRun,
upgradeRemote,
});
} catch (e) {
throw e;
let status;
if (dryRun) {
status = '[dry-run] success'.green;
} else {
status = 'success'.green;
}
let message;
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
message = 'Already up-to-date.';
} else if (results.upgradable.length === 0) {
message = `Fetched ${results.fetchable.length} new plugins.`;
} else if (results.fetchable.length === 0) {
message = `Upgraded ${results.upgradable.length} new plugins.`;
} else {
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
results.upgradable.length
} plugins.`;
}
console.log(`\n${status} ${message}`);
}
let status;
if (dryRun) {
status = '[dry-run] success'.green;
} else {
status = 'success'.green;
}
let endTime = new Date();
let message;
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
message = 'Already up-to-date.';
} else if (results.upgradable.length === 0) {
message = `Fetched ${results.fetchable.length} new plugins.`;
} else if (results.fetchable.length === 0) {
message = `Upgraded ${results.upgradable.length} new plugins.`;
} else {
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
results.upgradable.length
} plugins.`;
}
console.log(`\n${status} ${message}`);
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(
2
);
console.log(`✨ Done in ${totalTime}s.`);
} catch (err) {
console.error(err);
process.exit(1);
}
let endTime = new Date();
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2);
console.log(`✨ Done in ${totalTime}s.`);
}
async function createSeedPlugin() {
+8 -1
View File
@@ -13,6 +13,7 @@ const MODERATION_OPTIONS = require('../models/enum/moderation_options');
const SettingsService = require('../services/settings');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const MigrationService = require('../services/migration');
const errors = require('../errors');
// Register the shutdown criteria.
@@ -51,6 +52,12 @@ const performSetup = async () => {
if (program.defaults) {
await SettingsService.init();
// Get the migrations to run.
let migrations = await MigrationService.listPending();
// Perform all migrations.
await MigrationService.run(migrations);
console.log('Settings created.');
console.log('\nTalk is now installed!');
@@ -194,7 +201,7 @@ const performSetup = async () => {
);
};
// Start tthe setup process.
// Start the setup process.
performSetup()
.then(() => {
util.shutdown();
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const util = require('./util');
const program = require('commander');
const mongoose = require('../services/mongoose');
const databaseVerifications = require('./verifications/database');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
async function database({ fix = false, limit = Infinity, batch = 1000 }) {
try {
for (const verification of databaseVerifications) {
await verification({ fix, limit, batch });
}
} catch (err) {
console.error(
`Failed to process all the ${databaseVerifications.length} verifications`,
err
);
util.shutdown(1);
return;
}
util.shutdown();
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('db')
.description('verifies the database integrity')
.option('-f, --fix', 'fix the problems found with database inconsistencies')
.option(
'-l, --limit [size]',
'limit the amount of documents to process in a single pass, this will ensure only a maximum number of batch operations are issued [default: inf]',
parseInt
)
.option(
'-b, --batch [size]',
'batch size to process verifications and repairs of documents [default: 1000]',
parseInt
)
.action(database);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
+2 -1
View File
@@ -63,5 +63,6 @@ process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2'));
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
console.error(err);
process.exit(1);
});
-194
View File
@@ -1,194 +0,0 @@
const UserModel = require('../../../models/user');
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const { arrayJoinBy } = require('../../../graph/loaders/util');
const { get } = require('lodash');
const debug = require('debug')('talk:cli:verify');
const MODELS = [UserModel, CommentModel];
async function processBatch(Model, documents) {
// Get an array of all the document id's.
const documentIDs = documents.map(({ id }) => id);
// Store all the operations on this batch in this array that we'll return
// later.
const operations = [];
// Get the action summaries for this batch.
const totalActionSummaries = await ActionsService.getActionSummaries(
documentIDs
).then(arrayJoinBy(documentIDs, 'item_id'));
// Iterate over the documents.
for (let i = 0; i < documents.length; i++) {
const document = documents[i];
const actionSummaries = totalActionSummaries[i];
let ops = [];
for (const actionSummary of actionSummaries) {
if (actionSummary.group_id === null) {
continue;
}
// And we generate the group id.
const ACTION_TYPE = actionSummary.action_type.toLowerCase();
const GROUP_ID = actionSummary.group_id.toLowerCase();
if (GROUP_ID.length <= 0) {
continue;
}
// And we add a new batch operation if the action summary is associated
// with a group.
const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`;
// Check that the action summaries match the cached counts.
if (
get(document, ['action_counts', ACTION_COUNT_FIELD]) !==
actionSummary.count
) {
// Batch updates for those changes.
ops.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count,
});
}
}
// Group all the action summaries together from all the different group
// ids.
const groupedActionSummaries = actionSummaries.reduce(
(acc, actionSummary) => {
// action_type is already snake cased (as it would have had to be when it
// was inserted in the database).
const ACTION_TYPE = actionSummary.action_type.toLowerCase();
if (!(ACTION_TYPE in acc)) {
acc[ACTION_TYPE] = 0;
}
acc[ACTION_TYPE] += actionSummary.count;
return acc;
},
{}
);
for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) {
const count = groupedActionSummaries[ACTION_COUNT_FIELD];
// Check that the action summaries match the cached counts.
if (get(document, ['action_counts', ACTION_COUNT_FIELD]) !== count) {
// Batch updates for those changes.
ops.push({
[`action_counts.${ACTION_COUNT_FIELD}`]: count,
});
}
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (ops.length > 0) {
operations.push({
updateOne: {
filter: {
id: document.id,
},
update: {
$set: Object.assign({}, ...ops),
},
},
});
}
}
return operations;
}
module.exports = async ({ fix, batch }) => {
for (const Model of MODELS) {
const cursor = Model.collection
.find({})
.project({
id: 1,
action_counts: 1,
})
.sort({ created_at: 1 });
let operations = [];
let documents = [];
// While there are documents to process.
while (await cursor.hasNext()) {
// Load the document.
const document = await cursor.next();
// Push the document into the documents array.
documents.push(document);
// Check to see if the length of the documents array requires us to
// process it.
if (documents.length > batch) {
// Process this batch.
let batchOperations = await processBatch(Model, documents);
// Push the batch operations into the model operations.
operations.push(...batchOperations);
// Clear this batch contents.
documents = [];
}
}
// Check to see if there are any documents left over.
if (documents.length > 0) {
// Process this batch.
let batchOperations = await processBatch(Model, documents);
// Push the batch operations into the model operations.
operations.push(...batchOperations);
}
const OPERATIONS_LENGTH = operations.length;
console.log(
`action_counts.js: ${OPERATIONS_LENGTH} ${
Model.collection.name
} need their action counts fixed.`
);
// If fix was enabled, execute the batch writes.
if (OPERATIONS_LENGTH > 0) {
if (fix) {
debug(
`action_counts.js: fixing ${OPERATIONS_LENGTH} ${
Model.collection.name
}...`
);
while (operations.length) {
let result = await Model.collection.bulkWrite(
operations.splice(0, batch)
);
debug(
`action_counts.js: fixed batch of ${result.modifiedCount} ${
Model.collection.name
}.`
);
}
console.log(
`action_counts.js: applied all ${OPERATIONS_LENGTH} fixes to ${
Model.collection.name
}.`
);
} else {
console.warn(
'Skipping fixing, --fix was not enabled, pass --fix to fix these errors'
);
}
}
}
};
@@ -1,140 +0,0 @@
const CommentModel = require('../../../models/comment');
const { singleJoinBy } = require('../../../graph/loaders/util');
const debug = require('debug')('talk:cli:verify');
const getBatch = async (limit, offset) =>
CommentModel.find({})
.select({ id: 1, action_counts: 1, reply_count: 1 })
.limit(limit)
.skip(offset)
.sort('created_at');
module.exports = async ({ fix, limit, batch }) => {
let operations = [];
// Count how many comments there are to process.
const totalCount = await CommentModel.count();
let offset = 0;
let comments = [];
let commentIDs = [];
console.log(`Processing ${totalCount} comments in batches of ${limit}...`);
// Keep processing documents until there are is none left.
while (offset < totalCount) {
// Get a batch of comments.
comments = await getBatch(batch, offset);
commentIDs = comments.map(({ id }) => id);
// Get their reply counts.
let allReplyCounts = await CommentModel.aggregate([
{
$match: {
parent_id: {
$in: commentIDs,
},
status: {
$in: ['NONE', 'ACCEPTED'],
},
},
},
{
$group: {
_id: '$parent_id',
count: {
$sum: 1,
},
},
},
])
.then(singleJoinBy(commentIDs, '_id'))
.then(results => results.map(result => (result ? result.count : 0)));
// Loop over the comments, with their action summaries.
for (let i = 0; i < comments.length; i++) {
let comment = comments[i];
let replyCount = allReplyCounts[i];
// And check to see if the action summaries we just computed match what is
// currently set for the comments.
let commentOperations = [];
// If the reply count needs to be updated, then update it!
if (comment.reply_count !== replyCount) {
commentOperations.push({
reply_count: replyCount,
});
}
// If this comment has action summaries that should be updated, then
// perform an update!
if (commentOperations.length > 0) {
operations.push({
updateOne: {
filter: {
id: comment.id,
},
update: {
$set: Object.assign({}, ...commentOperations),
},
},
});
}
}
debug(`Processed batch of ${comments.length} comments.`);
if (operations.length >= limit) {
debug(
`Queued operations are ${
operations.length
}, reached limit of ${limit}, not processing any more.`
);
if (operations.length > limit) {
debug(
`${operations.length -
limit} operations have been truncated to enforce the limit`
);
}
break;
}
offset += batch;
}
const OPERATIONS_LENGTH = operations.length;
if (limit < Infinity && offset + comments.length < totalCount) {
console.log(
`Processed ${offset +
comments.length}/${totalCount} comments because we reached the update limit of ${limit}.`
);
} else {
console.log(`Processed all ${totalCount} comments.`);
}
console.log(`${OPERATIONS_LENGTH} documents need fixing.`);
// If fix was enabled, execute the batch writes.
if (OPERATIONS_LENGTH > 0) {
if (fix) {
debug(`Fixing ${OPERATIONS_LENGTH} documents...`);
while (operations.length) {
let batchOperations = operations.splice(0, batch);
let result = await CommentModel.collection.bulkWrite(batchOperations);
debug(`Fixed batch of ${result.modifiedCount} documents.`);
}
console.log(`Applied all ${OPERATIONS_LENGTH} fixes.`);
} else {
console.warn(
'Skipping fixing, --fix was not enabled, pass --fix to fix these errors'
);
}
}
};
-10
View File
@@ -1,10 +0,0 @@
// This will import all the verifications that should be run by the:
//
// cli verify database
//
// command. They exist in the form:
//
// async ({fix = false, batch = 1000}) => {}
//
// where their options are derived.
module.exports = [require('./comment_replies'), require('./action_counts')];
@@ -11,6 +11,7 @@ import {
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG,
SET_INDICATOR_TRACK,
} from '../constants/community';
import t from 'coral-framework/services/i18n';
@@ -68,3 +69,9 @@ export const showRejectUsernameDialog = user => ({
export const hideRejectUsernameDialog = () => ({
type: HIDE_REJECT_USERNAME_DIALOG,
});
// Enable or disable the activity indicator subscriptions.
export const setIndicatorTrack = track => ({
type: SET_INDICATOR_TRACK,
track,
});
+8 -2
View File
@@ -31,10 +31,16 @@ export const storySearchChange = value => ({
});
export const clearState = () => ({
type: actions.MODERATION_CLEAR_STATE,
type: actions.CLEAR_STATE,
});
export const selectCommentId = id => ({
type: actions.MODERATION_SELECT_COMMENT,
type: actions.SELECT_COMMENT,
id,
});
// Enable or disable the activity indicator subscriptions.
export const setIndicatorTrack = track => ({
type: actions.SET_INDICATOR_TRACK,
track,
});
+4 -2
View File
@@ -15,6 +15,7 @@ const CoralHeader = ({
showShortcuts = () => {},
auth,
root,
data,
}) => {
return (
<div className={styles.headerWrapper}>
@@ -31,7 +32,7 @@ const CoralHeader = ({
activeClassName={styles.active}
>
{t('configure.moderate')}
<ModerationIndicator root={root} />
<ModerationIndicator root={root} data={data} />
</IndexLink>
)}
<Link
@@ -50,7 +51,7 @@ const CoralHeader = ({
activeClassName={styles.active}
>
{t('configure.community')}
<CommunityIndicator root={root} />
<CommunityIndicator root={root} data={data} />
</Link>
{can(auth.user, 'UPDATE_CONFIG') && (
@@ -119,6 +120,7 @@ CoralHeader.propTypes = {
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
};
export default CoralHeader;
@@ -18,3 +18,5 @@ export const SHOW_REJECT_USERNAME_DIALOG = `${prefix}_SHOW_REJECT_USERNAME_DIALO
export const HIDE_REJECT_USERNAME_DIALOG = `${prefix}_HIDE_REJECT_USERNAME_DIALOG`;
export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`;
export const SET_INDICATOR_TRACK = `${prefix}_SET_INDICATOR_TRACK`;
+12 -9
View File
@@ -1,9 +1,12 @@
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
export const SINGLE_VIEW = 'SINGLE_VIEW';
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
export const SET_SORT_ORDER = 'MODERATION_SET_SORT_ORDER';
export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH';
export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH';
export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE';
export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE';
export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT';
const prefix = `MODERATION`;
export const TOGGLE_MODAL = `${prefix}_TOGGLE_MODAL`;
export const SINGLE_VIEW = `${prefix}_SINGLE_VIEW`;
export const HIDE_SHORTCUTS_NOTE = `${prefix}_HIDE_SHORTCUTS_NOTE`;
export const SET_SORT_ORDER = `${prefix}_SET_SORT_ORDER`;
export const SHOW_STORY_SEARCH = `${prefix}_SHOW_STORY_SEARCH`;
export const HIDE_STORY_SEARCH = `${prefix}_HIDE_STORY_SEARCH`;
export const STORY_SEARCH_CHANGE_VALUE = `${prefix}_STORY_SEARCH_CHANGE_VALUE`;
export const CLEAR_STATE = `${prefix}_CLEAR_STATE`;
export const SELECT_COMMENT = `${prefix}_SELECT_COMMENT`;
export const SET_INDICATOR_TRACK = `${prefix}_SET_INDICATOR_TRACK`;
@@ -25,6 +25,7 @@ export default withFragments({
id
role
}
created_at
}
${getSlotFragmentSpreads(slots, 'comment')}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { getDefinitionName } from 'coral-framework/utils';
export default withQuery(
gql`
query TalkAdmin_Header {
query TalkAdmin_Header($nullID: ID) {
...${getDefinitionName(ModerationIndicator.fragments.root)}
...${getDefinitionName(CommunityIndicator.fragments.root)}
}
@@ -16,7 +16,7 @@ export default withQuery(
`,
{
options: {
pollInterval: 10000,
variables: { nullID: null },
},
}
)(Header);
+12 -4
View File
@@ -173,13 +173,17 @@ export default {
},
updateQueries: {
TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => {
const decrement = {
flaggedUsernamesCount: { $apply: count => count - 1 },
};
// Remove from list after the mutation was "really" completed.
if (get(mutationResult, 'data.approveUsername.isOptimistic')) {
return prev;
return update(prev, decrement);
}
const updated = update(prev, {
flaggedUsernamesCount: { $apply: count => count - 1 },
...decrement,
flaggedUsers: {
nodes: { $apply: nodes => nodes.filter(node => node.id !== id) },
},
@@ -227,13 +231,17 @@ export default {
},
updateQueries: {
TalkAdmin_Community_FlaggedAccounts: (prev, { mutationResult }) => {
const decrement = {
flaggedUsernamesCount: { $apply: count => count - 1 },
};
// Remove from list after the mutation was "really" completed.
if (get(mutationResult, 'data.rejectUsername.isOptimistic')) {
return prev;
return update(prev, decrement);
}
const updated = update(prev, {
flaggedUsernamesCount: { $apply: count => count - 1 },
...decrement,
flaggedUsers: {
nodes: {
$apply: nodes => nodes.filter(node => node.id !== id),
@@ -9,6 +9,7 @@ import {
HIDE_BANUSER_DIALOG,
SHOW_REJECT_USERNAME_DIALOG,
HIDE_REJECT_USERNAME_DIALOG,
SET_INDICATOR_TRACK,
} from '../constants/community';
const initialState = {
@@ -24,6 +25,10 @@ const initialState = {
user: {},
banDialog: false,
rejectUsernameDialog: false,
// If true the activity indicator will track flagged account changes
// in order to determine the current queue count. Set this to false
// if the queue count is determined by other means.
indicatorTrack: true,
};
export default function community(state = initialState, action) {
@@ -91,6 +96,11 @@ export default function community(state = initialState, action) {
...state,
searchValue: action.value,
};
case SET_INDICATOR_TRACK:
return {
...state,
indicatorTrack: action.track,
};
default:
return state;
}
+12 -2
View File
@@ -8,14 +8,19 @@ const initialState = {
shortcutsNoteVisible: 'show',
sortOrder: 'DESC',
selectedCommentId: '',
// If true the activity indicator will turn on subscriptions
// in order to determine queue counts. Set this to false
// if the queue count is determined by other means.
indicatorTrack: true,
};
export default function moderation(state = initialState, action) {
switch (action.type) {
case actions.MODERATION_CLEAR_STATE:
case actions.CLEAR_STATE:
return {
...initialState,
shortcutsNoteVisible: state.shortcutsNoteVisible,
indicatorTrack: state.indicatorTrack,
};
case actions.TOGGLE_MODAL:
return {
@@ -52,11 +57,16 @@ export default function moderation(state = initialState, action) {
...state,
sortOrder: action.order,
};
case actions.MODERATION_SELECT_COMMENT:
case actions.SELECT_COMMENT:
return {
...state,
selectedCommentId: action.id,
};
case actions.SET_INDICATOR_TRACK:
return {
...state,
indicatorTrack: action.track,
};
default:
return state;
}
@@ -6,12 +6,15 @@ import withQuery from 'coral-framework/hocs/withQuery';
import { Spinner } from 'coral-ui';
import PropTypes from 'prop-types';
import { withApproveUsername } from 'coral-framework/graphql/mutations';
import { showRejectUsernameDialog } from '../../../actions/community';
import {
showRejectUsernameDialog,
setIndicatorTrack,
} from '../../../actions/community';
import { viewUserDetail } from '../../../actions/userDetail';
import { getDefinitionName } from 'coral-framework/utils';
import { appendNewNodes } from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import { handleFlaggedUsernameChange } from '../graphql';
import { handleFlaggedAccountsChange } from '../graphql';
import { notify } from 'coral-framework/actions/notification';
import { isFlaggedUserDangling } from '../utils';
import t from 'coral-framework/services/i18n';
@@ -31,10 +34,6 @@ function whoFlagged(user) {
class FlaggedAccountsContainer extends Component {
subscriptions = [];
constructor(props) {
super(props);
}
getCountWithoutDangling() {
return this.props.root.flaggedUsers.nodes.filter(
node => !isFlaggedUserDangling(node)
@@ -49,7 +48,7 @@ class FlaggedAccountsContainer extends Component {
prev,
{ subscriptionData: { data: { usernameFlagged: user } } }
) => {
return handleFlaggedUsernameChange(prev, user, () => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
'flagged_usernames.notify_flagged',
whoFlagged(user),
@@ -65,7 +64,7 @@ class FlaggedAccountsContainer extends Component {
prev,
{ subscriptionData: { data: { usernameApproved: user } } }
) => {
return handleFlaggedUsernameChange(prev, user, () => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
'flagged_usernames.notify_approved',
whoChangedTheStatus(user.state.status.username),
@@ -81,7 +80,7 @@ class FlaggedAccountsContainer extends Component {
prev,
{ subscriptionData: { data: { usernameRejected: user } } }
) => {
return handleFlaggedUsernameChange(prev, user, () => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
'flagged_usernames.notify_rejected',
whoChangedTheStatus(user.state.status.username),
@@ -101,7 +100,7 @@ class FlaggedAccountsContainer extends Component {
},
}
) => {
return handleFlaggedUsernameChange(prev, user, () => {
return handleFlaggedAccountsChange(prev, user, () => {
const msg = t(
'flagged_usernames.notify_changed',
previousUsername,
@@ -124,10 +123,14 @@ class FlaggedAccountsContainer extends Component {
}
componentWillMount() {
// Stop activity indicator tracking, as we'll handle it here.
this.props.setIndicatorTrack(false);
this.subscribeToUpdates();
}
componentWillUnmount() {
// Restart activity indicator tracking.
this.props.setIndicatorTrack(true);
this.unsubscribe();
}
@@ -196,6 +199,7 @@ FlaggedAccountsContainer.propTypes = {
approveUsername: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
setIndicatorTrack: PropTypes.func,
};
const LOAD_MORE_QUERY = gql`
@@ -287,6 +291,7 @@ const mapDispatchToProps = dispatch =>
showRejectUsernameDialog,
viewUserDetail,
notify,
setIndicatorTrack,
},
dispatch
);
@@ -1,14 +1,152 @@
import React, { Component } from 'react';
import { compose, gql } from 'react-apollo';
import Indicator from '../../../components/Indicator';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { branch, renderNothing } from 'recompose';
import { handleIndicatorChange } from '../graphql';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
const hideIfNoData = hasNoData => branch(hasNoData, renderNothing);
class IndicatorContainer extends Component {
subscriptions = [];
subscribeToUpdates() {
const parameters = [
{
document: USERNAME_FLAGGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameFlagged: user } } }
) => {
return handleIndicatorChange(prev, user);
},
},
{
document: USERNAME_APPROVED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameApproved: user } } }
) => {
return handleIndicatorChange(prev, user);
},
},
{
document: USERNAME_REJECTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameRejected: user } } }
) => {
return handleIndicatorChange(prev, user);
},
},
{
document: USERNAME_CHANGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { usernameChanged: { user } } } }
) => {
return handleIndicatorChange(prev, user);
},
},
];
this.subscriptions = parameters.map(param =>
this.props.data.subscribeToMore(param)
);
}
unsubscribe() {
this.subscriptions.forEach(unsubscribe => unsubscribe());
this.subscriptions = [];
}
componentWillMount() {
if (this.props.track) {
this.subscribeToUpdates();
}
}
componentWillUnmount() {
this.unsubscribe();
}
componentWillReceiveProps(nextProps) {
if (!this.props.track && nextProps.track) {
this.subscribeToUpdates();
}
if (this.props.track && !nextProps.track) {
this.unsubscribe();
}
}
render() {
if (!this.props.root || !this.props.root.flaggedUsernamesCount) {
return null;
}
return <Indicator />;
}
}
IndicatorContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
track: PropTypes.bool,
};
const fields = `
state {
status {
username {
status
}
}
}
`;
const USERNAME_FLAGGED_SUBSCRIPTION = gql`
subscription TalkAdmin_CommunityIndicator_UsernameFlagged {
usernameFlagged {
${fields}
}
}
`;
const USERNAME_APPROVED_SUBSCRIPTION = gql`
subscription TalkAdmin_ComunityIndicator_UsernameApproved {
usernameApproved {
${fields}
}
}
`;
const USERNAME_REJECTED_SUBSCRIPTION = gql`
subscription TalkAdmin_CommunityIndicator_UsernameRejected {
usernameRejected {
${fields}
}
}
`;
const USERNAME_CHANGED_SUBSCRIPTION = gql`
subscription TalkAdmin_ComunityIndicator_UsernameChanged {
usernameChanged {
previousUsername
user {
${fields}
}
}
}
`;
const mapStateToProps = state => ({
track: state.community.indicatorTrack,
});
const enhance = compose(
connect(mapStateToProps),
withFragments({
root: gql`
fragment TalkAdmin_Community_Indicator_root on RootQuery {
fragment TalkAdmin_CommunityIndicator_root on RootQuery {
flaggedUsernamesCount: userCount(
query: {
action_type: FLAG
@@ -17,8 +155,7 @@ const enhance = compose(
)
}
`,
}),
hideIfNoData(props => !props.root.flaggedUsernamesCount)
})
);
export default enhance(Indicator);
export default enhance(IndicatorContainer);
@@ -49,13 +49,13 @@ function decrementFlaggedUserCount(root) {
}
/**
* Assimilate flagged user changes into current store.
* Assimilate flagged acount changes into current store.
* @param {Object} root current state of the store
* @param {Object} user user that was changed
* @param {function} notify callback to show notification
* @return {Object} next state of the store
*/
export function handleFlaggedUsernameChange(root, user, notify) {
export function handleFlaggedAccountsChange(root, user, notify) {
if (user.state.status.username.status !== 'SET') {
// Check if change came from current user, if so ignore it.
const lastChange =
@@ -87,7 +87,7 @@ export function handleFlaggedUsernameChange(root, user, notify) {
break;
case 'APPROVED':
case 'REJECTED':
return root;
return decrementFlaggedUserCount(root);
default:
}
}
@@ -105,3 +105,21 @@ export function handleFlaggedUsernameChange(root, user, notify) {
}
}
}
/**
* Track indicator status
* @param {Object} root current state of the store
* @param {Object} user user that was changed
* @return {Object} next state of the store
*/
export function handleIndicatorChange(root, user) {
switch (user.state.status.username.status) {
case 'SET':
case 'CHANGED':
return incrementFlaggedUserCount(root);
case 'APPROVED':
case 'REJECTED':
return decrementFlaggedUserCount(root);
default:
}
}
@@ -1,25 +1,207 @@
import React, { Component } from 'react';
import { compose, gql } from 'react-apollo';
import Indicator from '../../../components/Indicator';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { branch, renderNothing } from 'recompose';
import { handleIndicatorChange, subscriptionFields } from '../graphql';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import withQueueConfig from '../hoc/withQueueConfig';
import baseQueueConfig from '../queueConfig';
import Slot from 'coral-framework/components/Slot';
const hideIfNoData = hasNoData => branch(hasNoData, renderNothing);
class IndicatorContainer extends Component {
subscriptions = [];
handleCommentChange = (root, comment) => {
return handleIndicatorChange(root, comment, this.props.queueConfig);
};
subscribeToUpdates() {
const parameters = [
{
document: COMMENT_ADDED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentAdded: comment } } }
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_FLAGGED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentFlagged: comment } } }
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_EDITED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentEdited: comment } } }
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_ACCEPTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentAccepted: comment } } }
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_REJECTED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentRejected: comment } } }
) => {
return this.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_RESET_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentReset: comment } } }
) => {
return this.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = parameters.map(param =>
this.props.data.subscribeToMore(param)
);
}
unsubscribe() {
this.subscriptions.forEach(unsubscribe => unsubscribe());
this.subscriptions = [];
}
componentWillMount() {
if (this.props.track) {
this.subscribeToUpdates();
}
}
componentWillUnmount() {
this.unsubscribe();
}
componentWillReceiveProps(nextProps) {
if (!this.props.track && nextProps.track) {
this.subscribeToUpdates();
}
if (this.props.track && !nextProps.track) {
this.unsubscribe();
}
}
render() {
if (
!this.props.root ||
(!this.props.root.premodCount && !this.props.root.reportedCount)
) {
return null;
}
return (
<span>
<Indicator />
<Slot
data={this.props.data}
handleCommentChange={this.handleCommentChange}
fill="adminModerationIndicator"
/>
</span>
);
}
}
IndicatorContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
track: PropTypes.bool,
queueConfig: PropTypes.object,
};
const COMMENT_ADDED_SUBSCRIPTION = gql`
subscription TalkAdmin_ModerationIndicator_CommentAdded {
commentAdded(statuses: null) {
${subscriptionFields}
}
}
`;
const COMMENT_FLAGGED_SUBSCRIPTION = gql`
subscription TalkAdmin_ModerationIndicator_CommentFlagged {
commentFlagged {
${subscriptionFields}
}
}
`;
const COMMENT_EDITED_SUBSCRIPTION = gql`
subscription TalkAdmin_ModerationIndicator_CommentEdited {
commentEdited {
${subscriptionFields}
}
}
`;
const COMMENT_ACCEPTED_SUBSCRIPTION = gql`
subscription TalkAdmin_ModerationIndicator_CommentAccepted {
commentAccepted {
${subscriptionFields}
}
}
`;
const COMMENT_REJECTED_SUBSCRIPTION = gql`
subscription TalkAdmin_ModerationIndicator_CommentRejected {
commentRejected {
${subscriptionFields}
}
}
`;
const COMMENT_RESET_SUBSCRIPTION = gql`
subscription TalkAdmin_ModerationIndicator_CommentReset {
commentReset {
${subscriptionFields}
}
}
`;
const mapStateToProps = state => ({
track: state.moderation.indicatorTrack,
});
const enhance = compose(
connect(mapStateToProps),
withFragments({
root: gql`
fragment TalkAdmin_Moderation_Indicator_root on RootQuery {
premodCount: commentCount(query: { statuses: [PREMOD] })
premodCount: commentCount(
query: { statuses: [PREMOD], asset_id: $nullID }
)
reportedCount: commentCount(
query: {
statuses: [NONE, PREMOD, SYSTEM_WITHHELD]
action_type: FLAG
asset_id: $nullID
}
)
}
`,
}),
hideIfNoData(props => !props.root.premodCount && !props.root.reportedCount)
withQueueConfig(baseQueueConfig)
);
export default enhance(Indicator);
export default enhance(IndicatorContainer);
@@ -15,6 +15,7 @@ import {
handleCommentChange,
commentBelongToQueue,
cleanUpQueue,
subscriptionFields,
} from '../graphql';
import { viewUserDetail } from '../../../actions/userDetail';
@@ -27,6 +28,7 @@ import {
storySearchChange,
clearState,
selectCommentId,
setIndicatorTrack,
} from 'actions/moderation';
import withQueueConfig from '../hoc/withQueueConfig';
import { notify } from 'coral-framework/actions/notification';
@@ -198,21 +200,42 @@ class ModerationContainer extends Component {
}
componentWillMount() {
if (!this.props.data.variables.asset_id) {
// Stop activity indicator tracking, as we'll handle it here.
this.props.setIndicatorTrack(false);
}
this.props.clearState();
this.subscribeToUpdates();
}
componentWillUnmount() {
if (!this.props.data.variables.asset_id) {
// Restart activity indicator tracking.
this.props.setIndicatorTrack(true);
}
this.unsubscribe();
}
componentWillReceiveProps(nextProps) {
const currentAssetId = this.props.data.variables.asset_id;
const nextAssetId = nextProps.data.variables.asset_id;
// Resubscribe when we change between assets.
if (
this.props.data.variables.asset_id !== nextProps.data.variables.asset_id
) {
if (currentAssetId !== nextAssetId) {
this.resubscribe(nextProps.data.variables);
}
// We are only subscribing to a specific asset_id, so activity indicator
// needs to do its own tracking.
if (!currentAssetId && nextAssetId) {
this.props.setIndicatorTrack(true);
}
// We are subscribing to all comment changes, and as such there is no
// need for the activity indicator to do the same.
if (currentAssetId && !nextAssetId) {
this.props.setIndicatorTrack(false);
}
}
cleanUpQueue = queue => {
@@ -316,10 +339,12 @@ class ModerationContainer extends Component {
);
}
}
const COMMENT_ADDED_SUBSCRIPTION = gql`
subscription CommentAdded($asset_id: ID){
commentAdded(asset_id: $asset_id, statuses: null){
...${getDefinitionName(Comment.fragments.comment)}
${subscriptionFields}
}
}
${Comment.fragments.comment}
@@ -329,6 +354,7 @@ const COMMENT_EDITED_SUBSCRIPTION = gql`
subscription CommentEdited($asset_id: ID){
commentEdited(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
${subscriptionFields}
}
}
${Comment.fragments.comment}
@@ -338,6 +364,7 @@ const COMMENT_FLAGGED_SUBSCRIPTION = gql`
subscription CommentFlagged($asset_id: ID){
commentFlagged(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
${subscriptionFields}
}
}
${Comment.fragments.comment}
@@ -347,14 +374,7 @@ const COMMENT_ACCEPTED_SUBSCRIPTION = gql`
subscription CommentAccepted($asset_id: ID){
commentAccepted(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
${subscriptionFields}
}
}
${Comment.fragments.comment}
@@ -364,14 +384,7 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
subscription CommentRejected($asset_id: ID){
commentRejected(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
${subscriptionFields}
}
}
${Comment.fragments.comment}
@@ -381,14 +394,7 @@ const COMMENT_RESET_SUBSCRIPTION = gql`
subscription CommentReset($asset_id: ID){
commentReset(asset_id: $asset_id){
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
${subscriptionFields}
}
}
${Comment.fragments.comment}
@@ -525,6 +531,7 @@ const mapDispatchToProps = dispatch => ({
clearState,
notify,
selectCommentId,
setIndicatorTrack,
},
dispatch
),
@@ -91,6 +91,76 @@ function getCommentQueues(comment, queueConfig) {
return queues;
}
function getOlderDate(a, b) {
if (a) {
a = new Date(a);
}
if (b) {
b = new Date(b);
}
if (!b) {
return a;
}
if (!a) {
return b;
}
return a < b ? b : a;
}
function determineLatestChange(comment) {
let lc = null;
comment.status_history.forEach(item => {
lc = getOlderDate(lc, item.created_at);
});
comment.actions.forEach(item => {
lc = getOlderDate(lc, item.created_at);
});
return lc;
}
function reconstructPreviousCommentState(comment) {
const history = comment.status_history;
const actions = comment.actions;
const lastChangeDate = determineLatestChange(comment);
const previousComment = {
...comment,
status_history: history.filter(
item => new Date(item.created_at) < lastChangeDate
),
actions: actions.filter(item => new Date(item.created_at) < lastChangeDate),
};
// Comment did not exist previously.
if (!previousComment.status_history.length) {
return null;
}
previousComment.status =
previousComment.status_history[
previousComment.status_history.length - 1
].type;
return previousComment;
}
/**
* getPreviousCommentQueues determines queues that this comment previously belonged to.
*/
function getPreviousCommentQueues(comment, queueConfig) {
const previousCommentState = reconstructPreviousCommentState(comment);
if (!previousCommentState) {
return [];
}
return getCommentQueues(previousCommentState, queueConfig);
}
/**
* Return whether or not the comment belongs to the queue.
*/
@@ -203,17 +273,7 @@ export function handleCommentChange(
let next = root;
// Queues that this comment previously belonged to.
const prevQueues =
comment.status_history.length <= 1
? []
: getCommentQueues(
{
...comment,
status:
comment.status_history[comment.status_history.length - 2].type,
},
queueConfig
);
const prevQueues = getPreviousCommentQueues(comment, queueConfig);
// Queues that this comment needs to be placed.
const nextQueues = getCommentQueues(comment, queueConfig);
@@ -291,3 +351,49 @@ export function handleCommentChange(
});
return next;
}
const indicatorQueues = ['premod', 'reported'];
/**
* Track indicator status
* @param {Object} root current state of the store
* @param {Object} comment comment that was changed
* @return {Object} next state of the store
*/
export function handleIndicatorChange(root, comment, queueConfig) {
let next = root;
// Queues that this comment previously belonged to.
const prevQueues = getPreviousCommentQueues(comment, queueConfig);
// Queues that this comment needs to be placed.
const nextQueues = getCommentQueues(comment, queueConfig);
for (const queue of indicatorQueues) {
if (prevQueues.indexOf(queue) === -1 && nextQueues.indexOf(queue) >= 0) {
next = increaseCommentCount(next, queue);
}
if (prevQueues.indexOf(queue) >= 0 && nextQueues.indexOf(queue) === -1) {
next = decreaseCommentCount(next, queue);
}
}
return next;
}
export const subscriptionFields = `
status
actions {
__typename
created_at
}
status_history {
type
assigned_by {
id
}
created_at
}
updated_at
created_at
`;
-22
View File
@@ -40,28 +40,6 @@ documents rather than performing a nice table alter. If the process crashes
during the migration, simply re-run it. The migration operations are designed
to act atomically, and be idempotent to documents already updated.
## Database Verifications
In `v3.*`, we introduced the concept of "verifying the database". Some of our
operations update cached values that live along side the original document to
improve performance. Running the cli command for verifying the database's cache
ensures that all the cached values are up to date.
Running the following will start the database verification process:
```bash
./bin/cli verify db --fix
```
You can notice the `--fix` option, without it, the tool should instead perform
a dry run of the operations it intends to perform.
{: .code-aside}
This process, like the migration process, should take some time to complete on
large databases.
Once you have updated your databases, that's all you have to do! Talk should now
function even better and faster with all the new features we poured into v4.0.0!
## Template Change
In `v4.0.0`, we introduced extensive support for compressing our javascript
+6 -2
View File
@@ -50,8 +50,12 @@ const decorateWithPermissionCheck = (typeResolver, protect) => {
*/
const decorateUserField = (typeResolver, field) => {
// The default resolver for the user decorator is loading the user by id.
let fieldResolver = (obj, args, ctx) =>
ctx.loaders.Users.getByID.load(obj[field]);
let fieldResolver = (obj, args, ctx) => {
if (!obj[field]) {
return null;
}
return ctx.loaders.Users.getByID.load(obj[field]);
};
// The resolver can be overridden however. This decorator will simply wrap the
// field with a permission check.
+3
View File
@@ -502,6 +502,9 @@ type Comment {
# The time when the comment was created
created_at: Date!
# The time when the comment was updated.
updated_at: Date
# describes how the comment can be edited
editing: EditInfo
+59 -74
View File
@@ -1,65 +1,38 @@
const CommentModel = require('../models/comment');
module.exports = {
async up() {
// Find all comments that have tags.
let comments = await CommentModel.aggregate([
{
$match: {
tags: {
$exists: true,
$ne: [],
},
},
},
{
$project: {
id: true,
tags: true,
},
},
]);
// OLD
//
// [
// {
// name: 'OFF_TOPIC',
// assigned_by: '',
// created_at: new Date()
// }
// ]
// If no comments were found, nothing needs to be done!
if (comments.length <= 0) {
return;
}
const updates = [];
// Loop over the comments retrieved, updating the tag structure.
for (let { id, tags } of comments) {
// OLD
//
// [
// {
// name: 'OFF_TOPIC',
// assigned_by: '',
// created_at: new Date()
// }
// ]
// NEW
//
// [
// {
// tag: {
// name: 'OFF_TOPIC',
// permissions: {
// public: true,
// self: false,
// roles: []
// },
// models: ['COMMENTS'],
// created_at: new Date()
// },
// assigned_by: '',
// created_at: new Date()
// }
// ]
// Remap the tag structure.
tags = tags.map(({ name, assigned_by, created_at }) => ({
// NEW
//
// [
// {
// tag: {
// name: 'OFF_TOPIC',
// permissions: {
// public: true,
// self: false,
// roles: []
// },
// models: ['COMMENTS'],
// created_at: new Date()
// },
// assigned_by: '',
// created_at: new Date()
// }
// ]
const transformTags = ({ id, tags }) => ({
query: { id },
update: {
$set: {
tags: tags.map(({ name, assigned_by, created_at }) => ({
tag: {
name,
permissions: {
@@ -72,22 +45,34 @@ module.exports = {
},
assigned_by,
created_at,
}));
})),
},
},
});
updates.push({ query: { id }, update: { $set: { tags } } });
}
module.exports = {
async up({ transformSingleWithCursor }) {
// Find all comments that have tags.
const cursor = CommentModel.collection.aggregate(
[
{
$match: {
tags: {
$exists: true,
$ne: [],
},
},
},
{
$project: {
id: true,
tags: true,
},
},
],
{ allowDiskUse: true }
);
if (updates.length > 0) {
// Create a new batch operation.
let batch = CommentModel.collection.initializeUnorderedBulkOp();
for (const { query, update } of updates) {
// Execute the batch operation.
batch.find(query).updateOne(update);
}
// Execute the batch update operation.
await batch.execute();
}
await transformSingleWithCursor(cursor, transformTags, CommentModel);
},
};
+2 -10
View File
@@ -12,7 +12,7 @@ const mapping = {
};
module.exports = {
async up() {
async up({ processManyUpdates }) {
const updates = [];
for (const item_type in mapping) {
const mappings = mapping[item_type];
@@ -44,15 +44,7 @@ module.exports = {
}
if (updates.length > 0) {
// Setup the batch operation.
const batch = ActionModel.collection.initializeUnorderedBulkOp();
for (const { query, update } of updates) {
batch.find(query).update(update);
}
// Execute the batch update operation.
await batch.execute();
await processManyUpdates(ActionModel, updates);
}
},
};
+164 -184
View File
@@ -1,60 +1,132 @@
const UserModel = require('../models/user');
const merge = require('lodash/merge');
const getUserBatch = async () => {
let query = {
status: {
$in: ['ACTIVE', 'BANNED', 'PENDING', 'APPROVED'],
const transformUser = user => {
const created_at = Date.now();
const { id, status, canEditName, suspension, disabled } = user;
let update = {
$unset: {
canEditName: '',
suspension: '',
disabled: '',
},
$set: {
status: {
// The username status is specific to each case.
username: {
history: [],
},
// The user is not banned by default.
banned: {
status: false,
history: [],
},
// The user is not suspended by default.
suspension: {
until: null,
history: [],
},
},
updated_at: created_at,
},
};
// Find all the users that need migrating.
return UserModel.collection.find(query);
};
module.exports = {
async up() {
const created_at = Date.now();
// Get the first batch of users.
let cursor = await getUserBatch();
const updates = [];
while (await cursor.hasNext()) {
const user = await cursor.next();
const { id, status, canEditName, suspension, disabled } = user;
let update = {
$unset: {
canEditName: '',
suspension: '',
disabled: '',
if (disabled) {
update = merge(update, {
$set: {
status: {
banned: {
status: true,
history: [
{
status: true,
created_at,
},
],
},
},
$set: {
status: {
// The username status is specific to each case.
username: {
history: [],
},
},
});
}
// The user is not banned by default.
banned: {
status: false,
history: [],
},
// If the user has an "until" property of their suspension, then we need
// to reflect that in the new status object.
if (suspension && suspension.until !== null) {
update = merge(update, {
$set: {
status: {
suspension: {
until: suspension.until,
history: [
{
until: suspension.until,
created_at,
},
],
},
},
},
});
}
// The user is not suspended by default.
suspension: {
until: null,
history: [],
switch (status) {
case 'ACTIVE':
if (canEditName) {
update = merge(update, {
$set: {
status: {
username: {
status: 'UNSET',
history: [
{
status: 'UNSET',
created_at,
},
],
},
},
},
updated_at: created_at,
},
};
if (disabled) {
});
} else {
update = merge(update, {
$set: {
status: {
username: {
status: 'SET',
history: [
{
status: 'SET',
created_at,
},
],
},
},
},
});
}
break;
case 'BANNED':
if (canEditName) {
update = merge(update, {
$set: {
status: {
username: {
status: 'REJECTED',
history: [
{
status: 'REJECTED',
created_at,
},
],
},
},
},
});
} else {
update = merge(update, {
$set: {
status: {
@@ -67,22 +139,11 @@ module.exports = {
},
],
},
},
},
});
}
// If the user has an "until" property of their suspension, then we need
// to reflect that in the new status object.
if (suspension && suspension.until !== null) {
update = merge(update, {
$set: {
status: {
suspension: {
until: suspension.until,
username: {
status: 'SET',
history: [
{
until: suspension.until,
status: 'SET',
created_at,
},
],
@@ -91,138 +152,57 @@ module.exports = {
},
});
}
switch (status) {
case 'ACTIVE':
if (canEditName) {
update = merge(update, {
$set: {
status: {
username: {
status: 'UNSET',
history: [
{
status: 'UNSET',
created_at,
},
],
},
},
},
});
} else {
update = merge(update, {
$set: {
status: {
username: {
status: 'SET',
history: [
{
status: 'SET',
created_at,
},
],
},
},
},
});
}
break;
case 'BANNED':
if (canEditName) {
update = merge(update, {
$set: {
status: {
username: {
status: 'REJECTED',
history: [
{
status: 'REJECTED',
created_at,
},
],
},
},
},
});
} else {
update = merge(update, {
$set: {
status: {
banned: {
status: true,
history: [
{
status: true,
created_at,
},
],
},
username: {
status: 'SET',
history: [
{
status: 'SET',
created_at,
},
],
},
},
},
});
}
break;
case 'PENDING':
update = merge(update, {
$set: {
status: {
username: {
break;
case 'PENDING':
update = merge(update, {
$set: {
status: {
username: {
status: 'CHANGED',
history: [
{
status: 'CHANGED',
history: [
{
status: 'CHANGED',
created_at,
},
],
created_at,
},
},
],
},
});
break;
case 'APPROVED':
update = merge(update, {
$set: {
status: {
username: {
},
},
});
break;
case 'APPROVED':
update = merge(update, {
$set: {
status: {
username: {
status: 'APPROVED',
history: [
{
status: 'APPROVED',
history: [
{
status: 'APPROVED',
created_at,
},
],
created_at,
},
},
],
},
});
break;
default:
throw new Error(`${status} is an invalid status`);
}
},
},
});
break;
default:
throw new Error(`${status} is an invalid status`);
}
updates.push({ query: { id }, update });
}
return { query: { id }, update };
};
if (updates.length > 0) {
// Create a new batch operation.
let bulk = UserModel.collection.initializeUnorderedBulkOp();
module.exports = {
async up({ transformSingleWithCursor }) {
// Get the first batch of users.
const cursor = UserModel.collection.find({
status: {
$in: ['ACTIVE', 'BANNED', 'PENDING', 'APPROVED'],
},
});
for (const { query, update } of updates) {
bulk.find(query).updateOne(update);
}
// Execute the bulk update operation.
await bulk.execute();
}
await transformSingleWithCursor(cursor, transformUser, UserModel);
},
};
+8 -21
View File
@@ -13,18 +13,16 @@ const findNewRole = roles => {
};
module.exports = {
async up() {
const cursor = await UserModel.collection.find({
async up({ transformSingleWithCursor }) {
const cursor = UserModel.collection.find({
roles: {
$exists: true,
},
});
const updates = [];
while (await cursor.hasNext()) {
const user = await cursor.next();
updates.push({
await transformSingleWithCursor(
cursor,
user => ({
query: {
id: user.id,
},
@@ -38,19 +36,8 @@ module.exports = {
roles: '',
},
},
});
}
if (updates.length > 0) {
// Create a new batch operation.
const bulk = UserModel.collection.initializeUnorderedBulkOp();
for (const { query, update } of updates) {
bulk.find(query).updateOne(update);
}
// Execute the bulk update operation.
await bulk.execute();
}
}),
UserModel
);
},
};
+124
View File
@@ -0,0 +1,124 @@
const ActionModel = require('../models/action');
const UserModel = require('../models/user');
const CommentModel = require('../models/comment');
module.exports = {
async up({ transformSingleWithCursor }) {
const models = [
{ Model: CommentModel, item_type: 'COMMENTS' },
{ Model: UserModel, item_type: 'USERS' },
];
for (const { Model, item_type } of models) {
let cursor = ActionModel.collection.aggregate(
[
{
$match: {
group_id: { $ne: null },
item_type,
},
},
{
$group: {
// group unique documents by these properties, we are leveraging the
// fact that each uuid is completely unique.
_id: {
item_id: '$item_id',
action_type: '$action_type',
group_id: '$group_id',
},
// and sum up all actions matching the above grouping criteria
count: {
$sum: 1,
},
},
},
{
$project: {
// suppress the _id field
_id: false,
// map the fields from the _id grouping down a level
item_id: '$_id.item_id',
action_type: { $toLower: '$_id.action_type' },
group_id: { $toLower: '$_id.group_id' },
// map the field directly
count: '$count',
},
},
],
{ allowDiskUse: true }
);
// Transform those documents.
await transformSingleWithCursor(
cursor,
({ item_id, action_type, group_id, count }) => ({
query: { id: item_id },
update: {
$set: {
[`action_counts.${action_type}_${group_id}`]: count,
},
},
}),
Model
);
// Secondly, we'll collect the group group id's (all the actions for a
// specific action type) to update counts of.
cursor = ActionModel.collection.aggregate(
[
{
$match: {
item_type,
},
},
{
$group: {
// group unique documents by these properties, we are leveraging the
// fact that each uuid is completely unique.
_id: {
item_id: '$item_id',
action_type: '$action_type',
},
// and sum up all actions matching the above grouping criteria
count: {
$sum: 1,
},
},
},
{
$project: {
// suppress the _id field
_id: false,
// map the fields from the _id grouping down a level
item_id: '$_id.item_id',
action_type: { $toLower: '$_id.action_type' },
// map the field directly
count: '$count',
},
},
],
{ allowDiskUse: true }
);
// Transform those documents.
await transformSingleWithCursor(
cursor,
({ item_id, action_type, count }) => ({
query: { id: item_id },
update: {
$set: {
[`action_counts.${action_type}`]: count,
},
},
}),
Model
);
}
},
};
+33
View File
@@ -0,0 +1,33 @@
const CommentModel = require('../models/comment');
const transformComments = ({ _id: parent_id, reply_count }) => ({
query: { id: parent_id, reply_count: { $ne: reply_count } },
update: { $set: { reply_count } },
});
module.exports = {
async up({ transformSingleWithCursor }) {
const cursor = CommentModel.collection.aggregate(
[
{
$match: {
parent_id: { $ne: null },
status: { $in: ['NONE', 'ACCEPTED'] },
},
},
{
$group: {
_id: '$parent_id',
reply_count: {
$sum: 1,
},
},
},
],
{ allowDiskUse: true }
);
// Transform those documents.
await transformSingleWithCursor(cursor, transformComments, CommentModel);
},
};
+6 -2
View File
@@ -29,7 +29,7 @@
},
"talk": {
"migration": {
"minVersion": 1511801783
"minVersion": 1516920160
}
},
"repository": {
@@ -222,6 +222,10 @@
},
"pre-commit": {
"silent": false,
"run": ["lint", "test:client", "test:server"]
"run": [
"lint",
"test:client",
"test:server"
]
}
}
@@ -0,0 +1,63 @@
import React from 'react';
import { gql } from 'react-apollo';
import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql';
class ModIndicatorSubscription extends React.Component {
subscriptions = null;
componentWillMount() {
const configs = [
{
document: COMMENT_FEATURED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentFeatured: { comment } } } }
) => {
return this.props.handleCommentChange(prev, comment);
},
},
{
document: COMMENT_UNFEATURED_SUBSCRIPTION,
updateQuery: (
prev,
{ subscriptionData: { data: { commentUnfeatured: { comment } } } }
) => {
return this.props.handleCommentChange(prev, comment);
},
},
];
this.subscriptions = configs.map(config =>
this.props.data.subscribeToMore(config)
);
}
componentWillUnmount() {
this.subscriptions.forEach(unsubscribe => unsubscribe());
}
render() {
return null;
}
}
const COMMENT_FEATURED_SUBSCRIPTION = gql`
subscription TalkFeaturedComments_Indicator_CommentFeatured {
commentFeatured {
comment {
${subscriptionFields}
}
}
}
`;
const COMMENT_UNFEATURED_SUBSCRIPTION = gql`
subscription TalkFeaturedComments_Indicator_CommentUnfeatured {
commentUnfeatured {
comment {
${subscriptionFields}
}
}
}
`;
export default ModIndicatorSubscription;
@@ -5,6 +5,7 @@ import Comment from 'coral-admin/src/routes/Moderation/containers/Comment';
import { getDefinitionName } from 'coral-framework/utils';
import truncate from 'lodash/truncate';
import t from 'coral-framework/services/i18n';
import { subscriptionFields } from 'coral-admin/src/routes/Moderation/graphql';
function prepareNotificationText(text) {
return truncate(text, { length: 50 }).replace('\n', ' ');
@@ -79,14 +80,7 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql`
commentFeatured(asset_id: $assetId) {
comment {
...${getDefinitionName(Comment.fragments.comment)}
status_history {
type
created_at
assigned_by {
id
username
}
}
${subscriptionFields}
}
user {
id
@@ -102,6 +96,7 @@ const COMMENT_UNFEATURED_SUBSCRIPTION = gql`
commentUnfeatured(asset_id: $assetId){
comment {
...${getDefinitionName(Comment.fragments.comment)}
${subscriptionFields}
}
user {
id
@@ -6,6 +6,7 @@ import update from 'immutability-helper';
import ModTag from './containers/ModTag';
import ModActionButton from './containers/ModActionButton';
import ModSubscription from './containers/ModSubscription';
import ModIndicatorSubscription from './containers/ModIndicatorSubscription';
import FeaturedDialog from './containers/FeaturedDialog';
import { gql } from 'react-apollo';
import reducer from './reducer';
@@ -23,6 +24,7 @@ export default {
moderationActions: [ModActionButton],
adminModeration: [ModSubscription, FeaturedDialog],
adminCommentInfoBar: [ModTag],
adminModerationIndicator: [ModIndicatorSubscription],
},
mutations: {
IgnoreUser: ({ variables }) => ({
@@ -36,7 +36,7 @@ module.exports = {
commentFeatured: (options, args) => ({
commentFeatured: {
filter: ({ comment }, { user }) => {
if (args.asset_id === null) {
if (!args.asset_id) {
return check(user, ['ADMIN', 'MODERATOR']);
}
return comment.asset_id === args.asset_id;
@@ -46,7 +46,7 @@ module.exports = {
commentUnfeatured: (options, args) => ({
commentUnfeatured: {
filter: ({ comment }, { user }) => {
if (args.asset_id === null) {
if (!args.asset_id) {
return check(user, ['ADMIN', 'MODERATOR']);
}
return comment.asset_id === args.asset_id;
+107
View File
@@ -0,0 +1,107 @@
const debug = require('debug')('talk:services:migration');
/**
* processUpdates processes batches of updates on the given model.
*
* @param {Object} model mongoose model that should perform the operations on
* @param {Array<Object>} updates array of updates to execute
*/
const processUpdates = async (model, updates) => {
// Create a new batch operation.
const bulk = model.collection.initializeUnorderedBulkOp();
for (const { query, update } of updates) {
bulk.find(query).updateOne(update);
}
// Execute the bulk update operation.
await bulk.execute();
};
const debugProcessStatistics = (count, totalCount) => {
if (totalCount > 0) {
debug(
`processed ${(count / totalCount * 100).toFixed(
2
)}% (${count}/${totalCount}) updates`
);
} else {
debug(`processed ${count} updates`);
}
};
const transformSingleWithCursor = ({
queryBatchSize,
updateBatchSize,
}) => async (query, process, Model) => {
debug('starting transform');
// We'll manage the updates that we store inside this object.
let updates = [];
// Count the elements in the transformation.
let totalCount = 0;
try {
totalCount = await query.count();
} catch (err) {}
// First we'll collect all the individual actions with specific group id's.
const cursor = await query.batchSize(queryBatchSize);
let count = 0;
while (await cursor.hasNext()) {
const result = await cursor.next();
const transformed = await process(result);
if (transformed) {
updates.push(transformed);
}
if (updates.length > updateBatchSize) {
// Process the updates.
await processUpdates(Model, updates);
count += updates.length;
debugProcessStatistics(count, totalCount);
// Clear the updates array.
updates = [];
}
}
if (updates.length > 0) {
// Process the updates.
await processUpdates(Model, updates);
count += updates.length;
debugProcessStatistics(count, totalCount);
// Clear the updates array.
updates = [];
}
debug('finished transform');
};
/**
* processManyUpdates processes batches of updates on many models with the given
* model.
*
* @param {Object} model mongoose model that should perform the operations on
* @param {Array<Object>} updates array of updates to execute
*/
const processManyUpdates = async (model, updates) => {
// Create a new batch operation.
const bulk = model.collection.initializeUnorderedBulkOp();
for (const { query, update } of updates) {
bulk.find(query).update(update);
}
// Execute the bulk update operation.
await bulk.execute();
};
module.exports = ctx => ({
processManyUpdates,
processUpdates,
transformSingleWithCursor: transformSingleWithCursor(ctx),
});
@@ -1,17 +1,20 @@
const MigrationModel = require('../models/migration');
const MigrationModel = require('../../models/migration');
const fs = require('fs');
const ms = require('ms');
const path = require('path');
const Joi = require('joi');
const debug = require('debug')('talk:services:migration');
const sc = require('snake-case');
const { talk: { migration: { minVersion } } } = require('../package.json');
const helpers = require('./helpers');
const { stripIndent } = require('common-tags');
const { talk: { migration: { minVersion } } } = require('../../package.json');
const migrationTemplate = `module.exports = {
async up() {
}
};
const migrationTemplate = stripIndent`
module.exports = {
async up({ queryBatchSize, updateBatchSize }) {
}
};
`;
class MigrationService {
@@ -44,7 +47,7 @@ class MigrationService {
static async listPending() {
// Get all the migration files.
let migrationFiles = fs.readdirSync(
path.join(__dirname, '..', 'migrations')
path.join(__dirname, '..', '..', 'migrations')
);
// Ensure that all migrations follow this format.
@@ -61,6 +64,7 @@ class MigrationService {
// Parse the migrations from the file listing.
let migrations = migrationFiles
.filter(filename => versionRe.test(filename))
.map(filename => {
// Parse the version from the filename.
let matches = filename.match(versionRe);
@@ -75,7 +79,7 @@ class MigrationService {
}
// Read the migration from the filesystem.
let migration = require(`../migrations/${filename}`);
let migration = require(`../../migrations/${filename}`);
Joi.assert(
migration,
migrationSchema,
@@ -109,17 +113,26 @@ class MigrationService {
*
* @param {Array} migrations a list of migrations returned by `listPending`
*/
static async run(migrations) {
static async run(
migrations,
{ queryBatchSize = 10000, updateBatchSize = 20000 } = {}
) {
if (migrations.length === 0) {
console.log('No migrations to run!');
return;
}
// Create the context helpers.
const ctx = helpers({ queryBatchSize, updateBatchSize });
for (let { filename, version, migration } of migrations) {
try {
const startTime = new Date();
console.log(`Starting migration ${filename}`);
await migration.up();
console.log(`Finished migration ${filename}`);
await migration.up(ctx);
const endTime = new Date();
const totalTime = endTime.getTime() - startTime.getTime();
console.log(`Finished migration ${filename} in ${ms(totalTime)}`);
} catch (e) {
console.error(`Migration ${filename} failed`);
throw e;
@@ -1,10 +1,14 @@
const migration = require('../../../migrations/1510174676_user_status');
const UserModel = require('../../../models/user');
const helpers = require('../../../services/migration/helpers');
const chai = require('chai');
chai.use(require('chai-datetime'));
const { expect } = chai;
const performMigration = () =>
migration.up(helpers({ queryBatchSize: 100, updateBatchSize: 100 }));
describe('migration.1510174676_user_status', () => {
describe('active user', () => {
beforeEach(async () => {
@@ -24,7 +28,7 @@ describe('migration.1510174676_user_status', () => {
expect(user).to.have.property('canEditName', false);
// Perform the migration.
await migration.up();
await performMigration();
user = await UserModel.collection.findOne({ id: '123' });
@@ -54,7 +58,7 @@ describe('migration.1510174676_user_status', () => {
expect(user).to.have.property('canEditName', true);
// Perform the migration.
await migration.up();
await performMigration();
user = await UserModel.collection.findOne({ id: '123' });
@@ -85,7 +89,7 @@ describe('migration.1510174676_user_status', () => {
expect(user.canEditName).to.equal(true);
// Perform the migration.
await migration.up();
await performMigration();
user = await UserModel.collection.findOne({ id: '123' });
@@ -117,7 +121,7 @@ describe('migration.1510174676_user_status', () => {
expect(user.canEditName).to.equal(false);
// Perform the migration.
await migration.up();
await performMigration();
user = await UserModel.collection.findOne({ id: '123' });
@@ -153,7 +157,7 @@ describe('migration.1510174676_user_status', () => {
const until = user.suspension.until;
// Perform the migration.
await migration.up();
await performMigration();
user = await UserModel.collection.findOne({ id: '123' });
@@ -187,7 +191,7 @@ describe('migration.1510174676_user_status', () => {
expect(user.status).to.equal('BANNED');
// Perform the migration.
await migration.up();
await performMigration();
user = await UserModel.collection.findOne({ id: '123' });
+15 -9
View File
@@ -55,8 +55,8 @@
to-fast-properties "^2.0.0"
"@coralproject/eslint-config-talk@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@coralproject/eslint-config-talk/-/eslint-config-talk-0.1.0.tgz#3ddc5f6fb4362a1cd05a5fea56cdb3095afc8cc3"
version "0.1.1"
resolved "https://registry.yarnpkg.com/@coralproject/eslint-config-talk/-/eslint-config-talk-0.1.1.tgz#71991b4937a3ffe657128d7f1170da4b5fb75c9e"
dependencies:
babel-eslint "^8.0.1"
eslint-config-prettier "^2.9.0"
@@ -2507,7 +2507,13 @@ dns-prefetch-control@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2"
doctrine@^2.0.0, doctrine@^2.0.2:
doctrine@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
dependencies:
esutils "^2.0.2"
doctrine@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.2.tgz#68f96ce8efc56cc42651f1faadb4f175273b0075"
dependencies:
@@ -2746,7 +2752,7 @@ error-ex@^1.2.0, error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
es-abstract@^1.4.3, es-abstract@^1.7.0:
es-abstract@^1.4.3:
version "1.9.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.9.0.tgz#690829a07cae36b222e7fd9b75c0d0573eb25227"
dependencies:
@@ -2756,7 +2762,7 @@ es-abstract@^1.4.3, es-abstract@^1.7.0:
is-callable "^1.1.3"
is-regex "^1.0.4"
es-abstract@^1.6.1:
es-abstract@^1.6.1, es-abstract@^1.7.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.10.0.tgz#1ecb36c197842a00d8ee4c2dfd8646bb97d60864"
dependencies:
@@ -2873,8 +2879,8 @@ eslint-config-prettier@^2.9.0:
get-stdin "^5.0.1"
eslint-plugin-jest@^21.6.1:
version "21.6.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-21.6.1.tgz#adca015bbdb8d23b210438ff9e1cee1dd9ec35df"
version "21.7.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-21.7.0.tgz#651f1c6ce999af3ac59ab8bf8a376d742fd0fc23"
eslint-plugin-mocha@^4.11.0:
version "4.11.0"
@@ -2883,8 +2889,8 @@ eslint-plugin-mocha@^4.11.0:
ramda "^0.24.1"
eslint-plugin-prettier@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-2.4.0.tgz#85cab0775c6d5e3344ef01e78d960f166fb93aae"
version "2.5.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-2.5.0.tgz#39a91dd7528eaf19cd42c0ee3f2c1f684606a05f"
dependencies:
fast-diff "^1.1.1"
jest-docblock "^21.0.0"