Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mass Privater: Add mass unprivate functionality #1639

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/features/mass_privater.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,20 @@

.mass-privater-tag::before { content: '#'; }
.mass-privater-blog::before { content: '@'; }

.mass-privater-failed {
margin-top: 1em;
}
.mass-privater-failed > ul {
max-height: 300px;
overflow-y: auto;
}
.mass-privater-failed > ul > li {
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
list-style-type: none;
}
.mass-privater-failed:has(> ul:empty) {
display: none;
}
152 changes: 107 additions & 45 deletions src/features/mass_privater.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { dom } from '../utils/dom.js';
import { megaEdit } from '../utils/mega_editor.js';
import { showModal, modalCancelButton, modalCompleteButton, hideModal, showErrorModal } from '../utils/modals.js';
import { addSidebarItem, removeSidebarItem } from '../utils/sidebar.js';
import { apiFetch } from '../utils/tumblr_helpers.js';
import { apiFetch, createEditRequestBody, isNpfCompatible } from '../utils/tumblr_helpers.js';
import { userBlogs } from '../utils/user.js';

const getPostsFormId = 'xkit-mass-privater-get-posts';
Expand Down Expand Up @@ -49,8 +49,8 @@ const createNowString = () => {
return `${YYYY}-${MM}-${DD}T${hh}:${mm}`;
};

const showInitialPrompt = async () => {
const initialForm = dom('form', { id: getPostsFormId }, { submit: event => confirmInitialPrompt(event).catch(showErrorModal) }, [
const showInitialPrompt = async (makePrivate) => {
const initialForm = dom('form', { id: getPostsFormId }, { submit: event => confirmInitialPrompt(event, makePrivate).catch(showErrorModal) }, [
dom('label', null, null, [
'Posts on blog:',
dom('select', { name: 'blog', required: true }, null, userBlogs.map(createBlogOption))
Expand All @@ -72,7 +72,7 @@ const showInitialPrompt = async () => {
}

showModal({
title: 'Select posts to make private',
title: `Select posts to make ${makePrivate ? 'private' : 'public'}`,
message: [initialForm],
buttons: [
modalCancelButton,
Expand All @@ -81,7 +81,7 @@ const showInitialPrompt = async () => {
});
};

const confirmInitialPrompt = async event => {
const confirmInitialPrompt = async (event, makePrivate) => {
event.preventDefault();

const { submitter } = event;
Expand All @@ -100,7 +100,7 @@ const confirmInitialPrompt = async event => {
.map(tag => tag.trim().toLowerCase())
.filter(Boolean);

if (tags.length) {
if (makePrivate && tags.length) {
const getTagCount = async tag => {
const { response: { totalPosts } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'GET', queryParams: { tag } });
return totalPosts ?? 0;
Expand All @@ -123,20 +123,20 @@ const confirmInitialPrompt = async event => {

const message = tags.length
? [
'Every published post on ',
`Every ${makePrivate ? 'published' : 'private'} post on `,
createBlogSpan(name),
' from before ',
beforeElement,
' tagged ',
...elementsAsList(tags.map(createTagSpan), 'or'),
' will be set to private.'
` will be set to ${makePrivate ? 'private' : 'public'}.`
]
: [
'Every published post on ',
`Every ${makePrivate ? 'published' : 'private'} post on `,
createBlogSpan(name),
' from before ',
beforeElement,
' will be set to private.'
` will be set to ${makePrivate ? 'private' : 'public'}.`
];

showModal({
Expand All @@ -147,8 +147,8 @@ const confirmInitialPrompt = async event => {
dom(
'button',
{ class: 'red' },
{ click: () => privatePosts({ uuid, name, tags, before }).catch(showErrorModal) },
['Private them!']
{ click: () => editPosts({ makePrivate, uuid, name, tags, before }).catch(showErrorModal) },
[makePrivate ? 'Private them!' : 'Unprivate them!']
)
]
});
Expand Down Expand Up @@ -178,101 +178,163 @@ const showPostsNotFound = ({ name }) =>
buttons: [modalCompleteButton]
});

const privatePosts = async ({ uuid, name, tags, before }) => {
const editPosts = async ({ makePrivate, uuid, name, tags, before }) => {
const gatherStatus = dom('span', null, null, ['Gathering posts...']);
const privateStatus = dom('span');
const editStatus = dom('span');

const failedList = dom('ul');
const showFailedPost = ({ blogName, id, summary }) =>
failedList.append(
dom('li', null, null, [
dom('a', { href: `/@${blogName}/${id}`, target: '_blank' }, null, [id]),
summary ? `: ${summary.replaceAll('\n', ' ')}` : ''
])
);
const failedStatus = dom('div', { class: 'mass-privater-failed' }, null, [
dom('div', null, null, ['Failed/incompatible posts:']),
failedList
]);

showModal({
title: 'Making posts private...',
title: `Making posts ${makePrivate ? 'private' : 'public'}...`,
message: [
dom('small', null, null, ['Do not navigate away from this page.']),
'\n\n',
gatherStatus,
privateStatus
editStatus,
failedStatus
]
});

let fetchedPosts = 0;
const filteredPostIdsSet = new Set();
const filteredPostsMap = new Map();

const collect = async resource => {
while (resource) {
await Promise.all([
apiFetch(resource).then(({ response }) => {
response.posts
.filter(({ canEdit }) => canEdit === true)
.filter(({ state }) => state === 'published')
.filter(({ state }) => state === (makePrivate ? 'published' : 'private'))
.filter(({ timestamp }) => timestamp < before)
.forEach(({ id }) => filteredPostIdsSet.add(id));
.filter(postData =>
tags.length
? postData.tags.some(tag => tags.includes(tag.toLowerCase()))
: true
)
.forEach((postData) => filteredPostsMap.set(postData.id, postData));

fetchedPosts += response.posts.length;

resource = response.links?.next?.href;

gatherStatus.textContent = `Found ${filteredPostIdsSet.size} posts (checked ${fetchedPosts})${resource ? '...' : '.'}`;
gatherStatus.textContent = `Found ${filteredPostsMap.size} posts (checked ${fetchedPosts})${resource ? '...' : '.'}`;
}),
sleep(1000)
]);
}
};

if (tags.length) {
if (makePrivate && tags.length) {
for (const tag of tags) {
await collect(`/v2/blog/${uuid}/posts?${$.param({ tag, before, limit: 50 })}`);
}
} else {
await collect(`/v2/blog/${uuid}/posts?${$.param({ before, limit: 50 })}`);
}
const filteredPostIds = [...filteredPostIdsSet];

if (filteredPostIds.length === 0) {
if (filteredPostsMap.size === 0) {
showPostsNotFound({ name });
return;
}

let privatedCount = 0;
let privatedFailCount = 0;
const filteredPostIds = [...filteredPostsMap.keys()];
const filteredPosts = [...filteredPostsMap.values()];

let successCount = 0;
let failCount = 0;

while (filteredPostIds.length !== 0) {
const postIds = filteredPostIds.splice(0, 100);
if (makePrivate) {
while (filteredPostIds.length !== 0) {
const postIds = filteredPostIds.splice(0, 100);

if (privateStatus.textContent === '') privateStatus.textContent = '\nPrivating posts...';
if (editStatus.textContent === '') editStatus.textContent = '\nPrivating posts...';

await Promise.all([
megaEdit(postIds, { mode: 'private' }).then(() => {
privatedCount += postIds.length;
}).catch(() => {
privatedFailCount += postIds.length;
}).finally(() => {
privateStatus.textContent = `\nPrivated ${privatedCount} posts... ${privatedFailCount ? `(failed: ${privatedFailCount})` : ''}`;
}),
sleep(1000)
]);
await Promise.all([
megaEdit(postIds, { mode: 'private' }).then(() => {
successCount += postIds.length;
}).catch(() => {
failCount += postIds.length;
}).finally(() => {
editStatus.textContent = `\nPrivated ${successCount} posts... ${failCount ? `(failed: ${failCount})` : ''}`;
}),
sleep(1000)
]);
}
} else {
editStatus.textContent = '\nUnprivating posts...';

const editablePosts = [];
filteredPosts.forEach(postData => isNpfCompatible(postData)
? editablePosts.push(postData)
: showFailedPost(postData)
);
for (const postData of editablePosts) {
await Promise.all([
apiFetch(`/v2/blog/${uuid}/posts/${postData.id}`, {
method: 'PUT',
body: {
...createEditRequestBody(postData),
state: 'published'
}
Comment on lines +284 to +289
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm. the fact that this will only work on NPF/text/answer posts is... concerning.

i feel like we should have a separate "incompatible" count to show to the user, rather than trying to action on them here, knowing that the operation will always fail.

do we have a util for NPF compatibility yet? i feel like we need one.
if a post is stored in blocks format, or the original type is text, or the original type is note, it should be editable via NPF endpoints.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, shoot.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(And yes we do; it currently returns isBlocksPostFormat || shouldOpenInLegacy === false;).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made something. A bit rudimentary, maybe, but I'm not sure how to improve it.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should be a <table>...?

}).then(() => {
successCount++;
}).catch(() => {
showFailedPost(postData);
failCount++;
}).finally(() => {
editStatus.textContent = `\nUnprivated ${successCount} posts... ${failCount ? `(failed: ${failCount})` : ''}`;
}),
sleep(1000)
]);
}
}

await sleep(1000);

const failedListScrollTop = failedList.scrollTop;

showModal({
title: 'All done!',
message: [
`Privated ${privatedCount} posts${privatedFailCount ? ` (failed: ${privatedFailCount})` : ''}.\n`,
'Refresh the page to see the result.'
`${makePrivate ? 'Privated' : 'Unprivated'} ${successCount} posts${failCount ? ` (failed: ${failCount})` : ''}.\n`,
'Refresh the page to see the result.',
failedStatus
],
buttons: [
dom('button', null, { click: hideModal }, ['Close']),
dom('button', { class: 'blue' }, { click: () => location.reload() }, ['Refresh'])
]
});

failedList.scrollTop = failedListScrollTop;
};

const sidebarOptions = {
id: 'mass-privater',
title: 'Mass Privater',
rows: [{
label: 'Make posts private',
onclick: showInitialPrompt,
carrot: true
}],
rows: [
{
label: 'Private posts',
onclick: () => showInitialPrompt(true),
carrot: true
},
{
label: 'Unprivate posts',
onclick: () => showInitialPrompt(false),
carrot: true
}
],
visibility: () => /^\/blog\/[^/]+\/?$/.test(location.pathname)
};

Expand Down