Merge branch 'master' into comment-link

This commit is contained in:
David Erwin
2017-06-13 17:35:19 -04:00
committed by GitHub
18 changed files with 434 additions and 126 deletions
+108 -48
View File
@@ -8,9 +8,12 @@ const program = require('./commander');
const parseDuration = require('ms');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('./util');
const inquirer = require('inquirer');
// Register the shutdown criteria.
util.onshutdown([
@@ -20,65 +23,112 @@ util.onshutdown([
/**
* Lists all the assets registered in the database.
*/
function listAssets() {
AssetModel
.find({})
.sort({'created_at': 1})
.then((asset) => {
let table = new Table({
head: [
'ID',
'Title',
'URL'
]
});
async function listAssets() {
try {
let assets = await AssetModel.find({}).sort({'created_at': 1});
asset.forEach((asset) => {
table.push([
asset.id,
asset.title ? asset.title : '',
asset.url ? asset.url : ''
]);
});
console.log(table.toString());
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
let table = new Table({
head: [
'ID',
'Title',
'URL'
]
});
assets.forEach((asset) => {
table.push([
asset.id,
asset.title ? asset.title : '',
asset.url ? asset.url : ''
]);
});
console.log(table.toString());
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
function refreshAssets(ageString) {
const now = new Date().getTime();
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
async function refreshAssets(ageString) {
try {
const now = new Date().getTime();
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
AssetModel.find({
$or: [
{
scraped: {
$lte: age
let assets = await AssetModel.find({
$or: [
{
scraped: {
$lte: age
}
},
{
scraped: null
}
},
{
scraped: null
}
]
})
]
});
// Queue all the assets for scraping.
.then((assets) => Promise.all(assets.map(scraper.create)))
// Queue all the assets for scraping.
await Promise.all(assets.map(scraper.create));
.then(() => {
console.log('Assets were queued to be scraped');
util.shutdown();
})
.catch((err) => {
console.error(err);
} catch (e) {
console.error(e);
util.shutdown(1);
});
}
}
async function updateURL(assetID, assetURL) {
try {
await AssetsService.updateURL(assetID, assetURL);
console.log(`Asset ${assetID} was updated to have url ${assetURL}.`);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function merge(srcID, dstID) {
try {
// Grab the assets...
let [srcAsset, dstAsset] = await AssetsService.findByIDs([srcID, dstID]);
if (!srcAsset || !dstAsset) {
throw new Error('Not all assets indicated by id exist, cannot merge');
}
// Count the affected resources...
let srcCommentCount = await CommentModel.find({asset_id: srcID}).count();
console.log(`Now going to update ${srcCommentCount} comments and delete the source Asset[${srcID}].`);
let {confirm} = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: 'Proceed with merge',
default: false
}
]);
if (confirm) {
// Perform the merge!
await AssetsService.merge(srcID, dstID);
} else {
console.warn('Aborting merge');
}
util.shutdown(0);
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
//==============================================================================
@@ -95,6 +145,16 @@ program
.description('queues the assets that exceed the age requested')
.action(refreshAssets);
program
.command('update-url <assetID> <url>')
.description('update the URL of an asset')
.action(updateURL);
program
.command('merge <srcID> <dstID>')
.description('merges two assets together by moving comments from src to dst and deleting the src asset')
.action(merge);
program.parse(process.argv);
// If there is no command listed, output help.
@@ -77,7 +77,7 @@
letter-spacing: .8;
&:hover {
background-color: #232323;
background-color: #404040;
}
&.active {
@@ -175,7 +175,7 @@
right: 0;
height: 100%;
top: 0;
padding: 40px 18px;
padding: 50px 18px;
box-sizing: border-box;
}
@@ -310,24 +310,33 @@
.flaggedByCount {
display: block;
text-align: left;
margin-top: 5px;
}
.flaggedByCount i {
font-size: 14px;
margin-right: 10px;
}
.flaggedBy {
display: inline;
padding: 3px;
font-size: 16px;
font-size: 14px;
margin-left: 5px;
}
.flaggedByLabel {
font-weight: bold;
font-weight: 600;
font-size: 14px;
}
.flaggedReasons {
padding-top: 15px;
margin-left: 24px;
margin-top: 10px;
}
.flaggedByReason {
p.flaggedByReason {
font-size: 1tpx;
margin: 0px;
line-height: 1.4;
}
@@ -52,6 +52,10 @@ span {
}
}
.approve {
margin-top: 10px;
}
.notFound {
position: relative;
margin: 20px auto;
@@ -193,7 +197,7 @@ span {
top: 0;
padding: 40px 18px;
box-sizing: border-box;
}
}
.itemHeader {
display: flex;
@@ -255,6 +259,8 @@ span {
}
.empty {
color: #444;
margin-top: 50px;
@@ -17,8 +17,9 @@
.heading {
margin: 0;
padding-left: 10px;
font-size: 1.5rem;
font-weight: bold;
font-size: 1.3rem;
font-weight: 600;
color: #2c2c2c;
}
.widgetTable {
@@ -32,7 +33,8 @@
}
.widgetHead p {
color: rgb(35, 102, 223);
color: #2c2c2c;
font-weight: 500;
padding: 10px;
text-align: left;
text-transform: capitalize;
@@ -47,11 +49,11 @@
}
.rowLinkify {
cursor: pointer;
border-bottom: 1px solid lightgrey;
color: #555;
height: var(--row-height);
padding: 10px;
transition: background-color 200ms;
}
.rowLinkify:last-child {
@@ -60,6 +62,7 @@
.rowLinkify:hover {
background-color: #f8f8f8;
pointer: default;
}
.linkToAsset {
@@ -68,16 +71,17 @@
}
.linkToModerate {
background-color: #e0e0e0;
background-color: #BDBDBD;
padding: 10px 14px;
text-decoration: none;
color: black;
float: right;
margin-left: 15px;
transition: background-color 200ms;
}
.linkToModerate:hover {
background-color: #ccc;
background-color: #9E9E9E;
}
.lede {
@@ -89,14 +93,10 @@
color: #555;
text-decoration: none;
font-size: 1.2em;
font-weight: normal;
font-weight: 500;
margin: 0;
}
.assetTitle:hover {
text-decoration: underline;
}
.widgetCount {
color: #555;
font-size: 1.3em;
@@ -12,4 +12,5 @@
right: 0;
margin-top: -2px;
font-size: 12px;
color: white;
}
@@ -15,7 +15,7 @@
display: flex;
.stat {
margin: 0 4px 12px;
margin: 0 4px 10px 0px;
}
.stat:last-child {
@@ -49,7 +49,7 @@
li {
display: inline-block;
margin: 0 10px;
margin-right: 10px;
cursor: pointer;
padding: 0 10px;
}
@@ -18,14 +18,20 @@
.tab {
flex: 1;
color: #EEEEEE;
color: #C0C0C0;
text-transform: capitalize;
font-weight: 100;
font-size: 15px;
font-size: 14px;
letter-spacing: 1px;
transition: border-bottom 200ms;
padding: 0px 5px;
margin-right: 30px;
transition: color 200ms;
padding: 0px 10px;
margin-right: 20px;
&:hover {
color: white;
border-bottom: solid 2px #F36451;
box-sizing: border-box;
}
}
.active {
@@ -33,6 +39,10 @@
box-sizing: border-box;
border-bottom: solid 4px #F36451;
font-weight: 400;
&:hover {
border-bottom: solid 4px #F36451;
font-weight: 400;
}
}
.active > span {
@@ -103,19 +113,18 @@ span {
font-weight: 400;
font-size: 15px;
letter-spacing: 1px;
transition: opacity 200ms;
transition: background-color 200ms;
opacity: 1;
&:hover {
opacity: .8;
cursor: pointer;
}
&:first-child {
text-align: left;
}
&:nth-child(2) {
&:hover {
cursor: pointer;
background-color: #212121;
}
span {
text-align: center;
text-overflow: ellipsis;
@@ -245,7 +254,6 @@ span {
padding: 5px;
color: #262626;
font-size: 14px;
margin-left: 15px;
line-height: 1px;
font-weight: 300;
}
@@ -421,17 +429,21 @@ span {
.tabIcon {
position: relative;
top: 2px;
top: 3px;
font-size: 16px;
}
.username {
color: blue;
text-decoration: underline;
color: #393B44;
text-decoration: none;
cursor: pointer;
font-weight: 600;
padding: 2px 5px;
border-radius: 2px;
margin-left: -5px;
transition: background-color 200ms ease;
&:hover {
background-color: rgba(255, 0, 0, .1);
background-color: #E0E0E0;
}
}
+2 -1
View File
@@ -7,7 +7,7 @@ export default {
'SuspendUserResponse',
'RejectUsernameResponse',
'SetUserStatusResponse',
'PostCommentResponse',
'CreateFlagResponse',
'EditCommentResponse',
'PostFlagResponse',
'CreateDontAgreeResponse',
@@ -17,3 +17,4 @@ export default {
'StopIgnoringUserResponse',
)
};
+24 -1
View File
@@ -104,6 +104,20 @@ class ErrAuthentication extends APIError {
}
}
/**
* ErrAlreadyExists is returned when an attempt to create a resource failed due to an existing one.
*/
class ErrAlreadyExists extends APIError {
constructor(existing = null) {
super('resource already exists', {
translation_key: 'ALREADY_EXISTS',
status: 409
}, {
existing
});
}
}
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new APIError('This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.', {
@@ -168,9 +182,17 @@ const ErrCommentTooShort = new APIError('Comment was too short', {
status: 400
});
// ErrAssetURLAlreadyExists is returned when a rename operation is requested
// but an asset already exists with the new url.
const ErrAssetURLAlreadyExists = new APIError('Asset URL already exists, cannot rename', {
translation_key: 'ASSET_URL_ALREADY_EXISTS',
status: 409
});
module.exports = {
ExtendableError,
APIError,
ErrAlreadyExists,
ErrPasswordTooShort,
ErrSettingsNotInit,
ErrMissingEmail,
@@ -191,5 +213,6 @@ module.exports = {
ErrInstallLock,
ErrLoginAttemptMaximumExceeded,
ErrEditWindowHasEnded,
ErrCommentTooShort
ErrCommentTooShort,
ErrAssetURLAlreadyExists
};
+1 -1
View File
@@ -36,7 +36,7 @@ const createAction = async ({user = {}}, {item_id, item_type, action_type, group
* Deletes an action based on the user id if the user owns that action.
* @param {Object} user the user performing the request
* @param {String} id the id of the action to delete
* @return {Promise} resolves when the action is deleted
* @return {Promise} resolves to the deleted action, or null if not found.
*/
const deleteAction = ({user}, {id}) => {
return ActionModel.findOneAndRemove({
+7 -6
View File
@@ -42,17 +42,17 @@ en:
banned: Banned
banned_user: "Banned User"
cancel: Cancel
dont_like_username: "I don't like this username"
dont_like_username: "Dislike username"
flaggedaccounts: "Flagged Usernames"
flags: Flags
impersonating: "This user is impersonating"
impersonating: "Impersonation"
loading: "Loading results"
moderator: Moderator
newsroom_role: "Newsroom Role"
no_flagged_accounts: "The Account Flags queue is currently empty."
no_flagged_accounts: "The Flagged Usernames queue is currently empty."
no_results: "No users found with that user name or email address. They're hiding!"
note: "Note: Banning this user will not let them edit comment or remove anything."
offensive: "This comment is offensive"
offensive: "Offensive"
other: Other
people: People
role: "Select role..."
@@ -144,8 +144,8 @@ en:
auto_update: "Data automatically updates every five minutes or when you Reload."
comment_count: comments
flags: Flags
most_flags: "Articles with the most flags"
most_conversations: "Articles with the most conversations"
most_flags: "Stories with the most flags"
most_conversations: "Stories with the most conversations"
next_update: "{0} minutes until next update."
no_activity: "There haven't been any comments anywhere in the last five minutes."
no_flags: "There have been no flags in the last 5 minutes! Hooray!"
@@ -189,6 +189,7 @@ en:
PASSWORD_REQUIRED: "Must input a password"
COMMENTING_CLOSED: "Commenting is already closed"
NOT_FOUND: "Resource not found"
ALREADY_EXISTS: "Resource already exists"
INVALID_ASSET_URL: "Assert URL is invalid"
email: "Not a valid E-Mail"
confirm_password: "Passwords don't match. Please check again"
+3 -3
View File
@@ -44,14 +44,14 @@ es:
dont_like_username: "No me gusta este nombre de usuario"
flaggedaccounts: "Nombres de Usuario Reportados"
flags: Reportes
impersonating: "El usuario esta suplantando identidad"
impersonating: Impersonando
loading: "Cargando resultados"
moderator: Moderator
newsroom_role: "Rol en la redacción"
no_flagged_accounts: "No hay ninguna cuenta reportada en este momento."
no_flagged_accounts: "No hay ningún nombre de usario reportado en este momento."
no_results: "No se encontraron usuarios con ese nombre o correo."
note: "Nota: Suspender a este usuario no le va a permitir (al usuario) borrar ni editar ni comentar."
offensive: "Este comentario es ofensivo"
offensive: Ofensivo
other: Otro
people: Gente
role: "Seleccionar rol..."
+40 -10
View File
@@ -149,6 +149,9 @@ export default (reaction) => (WrappedComponent) => {
client: PropTypes.object.isRequired,
};
// Whether or not a mutation is currently active.
duringMutation = false;
constructor(props, context) {
super(props, context);
@@ -208,6 +211,26 @@ export default (reaction) => (WrappedComponent) => {
}
}
postReaction = () => {
if (this.duringMutation) {
return;
}
this.duringMutation = true;
return this.props.postReaction(this.props.comment)
.then((result) => {this.duringMutation = false; return Promise.resolve(result); })
.catch((err) => {this.duringMutation = false; throw err; });
}
deleteReaction = () => {
if (this.duringMutation) {
return;
}
this.duringMutation = true;
return this.props.deleteReaction(this.props.comment)
.then((result) => {this.duringMutation = false; return Promise.resolve(result); })
.catch((err) => {this.duringMutation = false; throw err; });
}
render() {
const {comment} = this.props;
@@ -223,9 +246,16 @@ export default (reaction) => (WrappedComponent) => {
const alreadyReacted = !!reactionSummary;
const withReactionProps = {reactionSummary, count, alreadyReacted};
return <WrappedComponent {...this.props} {...withReactionProps} />;
return <WrappedComponent
showSignInDialog={this.props.showSignInDialog}
user={this.props.user}
comment={comment}
reactionSummary={reactionSummary}
count={count}
alreadyReacted={alreadyReacted}
postReaction={this.postReaction}
deleteReaction={this.deleteReaction}
/>;
}
}
@@ -240,16 +270,16 @@ export default (reaction) => (WrappedComponent) => {
}
`,
{
props: ({mutate, ownProps}) => ({
deleteReaction: () => {
props: ({mutate}) => ({
deleteReaction: (comment) => {
const reactionSummary = getMyActionSummary(
`${Reaction}ActionSummary`,
ownProps.comment
comment
);
const id = reactionSummary.current_user.id;
const item_id = ownProps.comment.id;
const item_id = comment.id;
const input = {id};
return mutate({
@@ -283,11 +313,11 @@ export default (reaction) => (WrappedComponent) => {
}
`,
{
props: ({mutate, ownProps}) => ({
postReaction: () => {
props: ({mutate}) => ({
postReaction: (comment) => {
const input = {
item_id: ownProps.comment.id,
item_id: comment.id,
};
return mutate({
+14 -1
View File
@@ -1,5 +1,6 @@
const wrapResponse = require('../../../graph/helpers/response');
const {SEARCH_OTHER_USERS} = require('../../../perms/constants');
const errors = require('../../../errors');
function getReactionConfig(reaction) {
reaction = reaction.toLowerCase();
@@ -134,13 +135,24 @@ function getReactionConfig(reaction) {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
return Promise.resolve(action);
});
})
.catch((err) => {
if (err instanceof errors.ErrAlreadyExists) {
return Promise.resolve(err.metadata.existing);
}
throw err;
});
});
return wrapResponse(reaction)(response);
},
[`delete${Reaction}Action`]: (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const response = Action.delete({id})
.then((action) => {
// Action doesn't exist or was already deleted.
if (!action) {
return Promise.resolve(null);
}
return Comments.get.load(action.item_id).then((comment) => {
// The comment is needed to allow better filtering e.g. by asset_id.
@@ -148,6 +160,7 @@ function getReactionConfig(reaction) {
return Promise.resolve(action);
});
});
return wrapResponse(reaction)(response);
}
},
+27 -10
View File
@@ -1,5 +1,6 @@
const ActionModel = require('../models/action');
const _ = require('lodash');
const errors = require('../errors');
module.exports = class ActionsService {
@@ -12,10 +13,10 @@ module.exports = class ActionsService {
}
/**
* Add an action.
* @param {String} item_id identifier of the comment (uuid)
* Inserts an action.
* @param {String} item_id identifier of the item (uuid)
* @param {String} user_id user id of the action (uuid)
* @param {String} action the new action to the comment
* @param {String} action the new action to the item
* @return {Promise}
*/
static insertUserAction(action) {
@@ -31,16 +32,32 @@ module.exports = class ActionsService {
};
// Create/Update the action.
return ActionModel.findOneAndUpdate(query, action, {
return new Promise((resolve, reject) => {
ActionModel.findOneAndUpdate(
query, {
// Ensure that if it's new, we return the new object created.
new: true,
// Only set when not existing.
$setOnInsert: action,
}, {
// Perform an upsert in the event that this doesn't exist.
upsert: true,
// Ensure that if it's new, we return the new object created.
new: true,
// Set the default values if not provided based on the mongoose models.
setDefaultsOnInsert: true
// Use raw result to get `updatedExisting`.
passRawResult: true,
// Perform an upsert in the event that this doesn't exist.
upsert: true,
// Set the default values if not provided based on the mongoose models.
setDefaultsOnInsert: true
}, (err, doc, raw) => {
if (err) { return reject(err); }
if (raw.lastErrorObject.updatedExisting) {
return reject(new errors.ErrAlreadyExists(raw.value));
}
return resolve(raw.value);
});
});
}
+56 -3
View File
@@ -1,3 +1,4 @@
const CommentModel = require('../models/comment');
const AssetModel = require('../models/asset');
const SettingsService = require('./settings');
const domainlist = require('./domainlist');
@@ -128,9 +129,61 @@ module.exports = class AssetsService {
* @param {Array} ids an array of Strings of asset_id
* @return {Promise} resolves to list of Assets
*/
static findMultipleById(ids) {
const query = ids.map((id) => ({id}));
return AssetModel.find(query);
static async findByIDs(ids) {
// Find the assets.
let assets = await AssetModel.find({
id: {
$in: ids
}
});
// Return them in the right order.
return ids.map((id) => assets.find((asset) => asset.id === id));
}
static async updateURL(id, url) {
// Try to see if an asset already exists with the given url.
let asset = await AssetsService.findByUrl(url);
if (asset !== null) {
throw errors.ErrAssetURLAlreadyExists;
}
// Seems that there was no other asset with the same url, try and perform
// the rename operation! An error may be thrown from this if the operation
// fails. This is ok.
await AssetModel.update({id}, {$set: {url}});
}
static async merge(srcAssetID, dstAssetID) {
// Fetch both assets.
let [srcAsset, dstAsset] = await AssetsService.findByIDs([srcAssetID, dstAssetID]);
if (!srcAsset || !dstAsset) {
throw errors.ErrNotFound;
}
// Resolve the merge operation, this invloves moving all resources attached
// to the src asset to the dst asset, and then removing the src asset.
// First, update all the old comments to the new asset.
await CommentModel.update({
asset_id: srcAssetID
}, {
$set: {
asset_id: dstAssetID
}
}, {
multi: true
});
// Second remove the old asset.
await AssetModel.remove({
id: srcAssetID
});
// That's it!
}
static all(skip = null, limit = null) {
+88 -6
View File
@@ -1,22 +1,29 @@
const AssetModel = require('../../../models/asset');
const CommentModel = require('../../../models/comment');
const AssetsService = require('../../../services/assets');
const CommentsService = require('../../../services/comments');
const SettingsService = require('../../../services/settings');
const url = require('url');
const chai = require('chai');
const expect = chai.expect;
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
// Use the chai should.
chai.should();
const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['new.test.com', 'test.com', 'override.test.com']}};
const defaults = {url:'http://test.com'};
describe('services.AssetsService', () => {
beforeEach(() => {
const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['new.test.com', 'test.com', 'override.test.com']}};
const defaults = {url:'http://test.com'};
let asset;
beforeEach(async () => {
await SettingsService.init(settings);
return SettingsService.init(settings).then(() => {
return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true});
});
asset = await AssetModel.findOneAndUpdate({id: '1'}, {$setOnInsert: defaults}, {upsert: true, new: true});
});
describe('#findById', ()=> {
@@ -120,4 +127,79 @@ describe('services.AssetsService', () => {
});
});
});
describe('#updateURL', () => {
it('should change the url if the asset was found, and there was no conflict', async () => {
let newURL = url.resolve(asset.url, '/new-url');
// Update the asset.
await AssetsService.updateURL(asset.id, newURL);
// Check that the url was updated.
let {url: databaseURL} = await AssetsService.findById(asset.id);
expect(databaseURL).to.equal(newURL);
});
it('should error if the new url already exists', async () => {
let newURL = url.resolve(asset.url, '/new-url');
// Create a new asset with our new URL.
await AssetModel.findOneAndUpdate({id: '2'}, {$setOnInsert: {url: newURL}}, {upsert: true, new: true});
return AssetsService.updateURL(asset.id, newURL).should.eventually.be.rejected;
});
});
describe('#merge', () => {
it('should error if either the src or the dst is missing', () => {
return AssetsService.merge('not-found', asset.id).should.eventually.be.rejected;
});
it('should merge the assets', async () => {
let newURL = url.resolve(asset.url, '/new-url');
// Create a new asset with our new URL.
await AssetModel.findOneAndUpdate({id: '2'}, {$setOnInsert: {url: newURL}}, {upsert: true, new: true});
// Create some comments on both assets.
await CommentsService.publicCreate([
{
asset_id: '1',
body: 'This is a comment!',
status: 'ACCEPTED'
},
{
asset_id: '1',
body: 'This is a comment!',
status: 'ACCEPTED'
},
{
asset_id: '2',
body: 'This is a comment!',
status: 'ACCEPTED'
},
{
asset_id: '2',
body: 'This is a comment!',
status: 'ACCEPTED'
}
]);
// Merge all the comments from asset 1 into asset 2, followed by deleting
// asset 1.
await AssetsService.merge('1', '2');
// Check to see if the comments are moved.
expect(await CommentModel.find({asset_id: '1'}).count()).to.equal(0);
expect(await CommentModel.find({asset_id: '2'}).count()).to.equal(4);
// Check to see if the asset was removed.
expect(await AssetModel.findOne({id: '1'})).to.equal(null);
});
});
});