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

Add time remaining displays and stop buttons to bulk actions #1409

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
9 changes: 1 addition & 8 deletions src/scripts/mass_deleter.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { dom } from '../util/dom.js';
import { megaEdit } from '../util/mega_editor.js';
import { modalCancelButton, modalCompleteButton, showErrorModal, showModal } from '../util/modals.js';
import { addSidebarItem, removeSidebarItem } from '../util/sidebar.js';
import { dateTimeFormat } from '../util/text_format.js';
import { apiFetch } from '../util/tumblr_helpers.js';

const timezoneOffsetMs = new Date().getTimezoneOffset() * 60000;
Expand All @@ -18,14 +19,6 @@ const createNowString = () => {

return `${YYYY}-${MM}-${DD}T${hh}:${mm}`;
};
const dateTimeFormat = new Intl.DateTimeFormat(document.documentElement.lang, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
});

const showDeleteDraftsPrompt = () => {
const form = dom('form', { id: 'xkit-mass-deleter-delete-drafts' }, { submit: confirmDeleteDrafts }, [
Expand Down
105 changes: 64 additions & 41 deletions src/scripts/mass_privater.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { dom } from '../util/dom.js';
import { megaEdit } from '../util/mega_editor.js';
import { showModal, modalCancelButton, modalCompleteButton, hideModal, showErrorModal } from '../util/modals.js';
import { addSidebarItem, removeSidebarItem } from '../util/sidebar.js';
import { constructDurationString, dateTimeFormat, elementsAsList } from '../util/text_format.js';
import { apiFetch } from '../util/tumblr_helpers.js';
import { userBlogs } from '../util/user.js';

Expand All @@ -12,29 +13,6 @@ const createTagSpan = tag => dom('span', { class: 'mass-privater-tag' }, null, [
const createBlogSpan = name => dom('span', { class: 'mass-privater-blog' }, null, [name]);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

const dateTimeFormat = new Intl.DateTimeFormat(document.documentElement.lang, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
});

/**
* Adds string elements between an array's items to format it as an English prose list.
* The Oxford comma is included.
* @param {any[]} array - Input array of any number of items
* @param {string} andOr - String 'and' or 'or', used before the last item
* @returns {any[]} An array alternating between the input items and strings
*/
const elementsAsList = (array, andOr) =>
array.flatMap((item, i) => {
if (i === array.length - 1) return [item];
if (i === array.length - 2) return array.length === 2 ? [item, ` ${andOr} `] : [item, `, ${andOr} `];
return [item, ', '];
});

const timezoneOffsetMs = new Date().getTimezoneOffset() * 60000;

const createNowString = () => {
Expand Down Expand Up @@ -100,18 +78,21 @@ const confirmInitialPrompt = async event => {
.map(tag => tag.trim().toLowerCase())
.filter(Boolean);

if (tags.length) {
const getTagCount = async tag => {
const { response: { totalPosts } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'GET', queryParams: { tag } });
return totalPosts ?? 0;
};
const counts = await Promise.all(tags.map(getTagCount));
const count = counts.reduce((a, b) => a + b, 0);

if (count === 0) {
showTagsNotFound({ tags, name });
return;
}
const getTagCount = async tag => {
const { response: { totalPosts } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'GET', queryParams: { tag } });
return totalPosts ?? 0;
};
const getCount = async () => {
const { response: { totalPosts } } = await apiFetch(`/v2/blog/${uuid}/posts`);
return totalPosts ?? 0;
};
const toCheckCount = tags.length
? (await Promise.all(tags.map(getTagCount))).reduce((a, b) => a + b, 0)
: await getCount();

if (toCheckCount === 0) {
tags.length ? showTagsNotFound({ tags, name }) : showPostsNotFound({ name });
return;
}

const beforeMs = elements.before.valueAsNumber + timezoneOffsetMs;
Expand Down Expand Up @@ -147,7 +128,7 @@ const confirmInitialPrompt = async event => {
dom(
'button',
{ class: 'red' },
{ click: () => privatePosts({ uuid, name, tags, before }).catch(showErrorModal) },
{ click: () => privatePosts({ uuid, name, tags, before, toCheckCount }).catch(showErrorModal) },
['Private them!']
)
]
Expand Down Expand Up @@ -178,31 +159,71 @@ const showPostsNotFound = ({ name }) =>
buttons: [modalCompleteButton]
});

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

showModal({
title: 'Making posts private...',
message: [
dom('small', null, null, ['Do not navigate away from this page.']),
'\n\n',
gatherStatus,
privateStatus
privateStatus,
'\n\n',
remainingStatus
]
});

const allPostIdsSet = new Set();
const filteredPostIdsSet = new Set();

let fetchCount = 0;
let checkedCount = 0;

const collectDurations = [1];
const privateDurations = [1];
const average = arr => arr.reduce((a, b) => a + b, 0) / arr.length;

let privatedCount = 0;
let privatedFailCount = 0;

const updateEstimates = () => {
const estimates = [];
const collectRemaining = constructDurationString((toCheckCount - checkedCount) / (checkedCount / fetchCount) * average(collectDurations));
collectRemaining && estimates.push(`Collection: ${collectRemaining}`);

if (privatedCount || privatedFailCount) {
const privateRemaining = constructDurationString(filteredPostIds.length / 100 * average(privateDurations));
privateRemaining && estimates.push(`Privating: ${privateRemaining}`);
} else {
const privateEstimate = constructDurationString(toCheckCount / 100 * average(privateDurations));
privateEstimate && estimates.push(`Privating: up to ${privateEstimate}`);
}
remainingStatus.replaceChildren(
...(estimates.length
? [
dom('span', null, null, ['Estimated time remaining:']),
'\n',
dom('small', null, null, [estimates.join('\n')])
]
: [])
);
};

const collect = async resource => {
while (resource) {
const now = Date.now();
await Promise.all([
apiFetch(resource).then(({ response }) => {
const posts = response.posts
.filter(({ canEdit }) => canEdit === true)
.filter(({ state }) => state === 'published');

fetchCount += 1;
checkedCount += posts.length;

posts.forEach(({ id }) => allPostIdsSet.add(id));

posts
Expand All @@ -212,9 +233,11 @@ const privatePosts = async ({ uuid, name, tags, before }) => {
resource = response.links?.next?.href;

gatherStatus.textContent = `Found ${filteredPostIdsSet.size} posts (checked ${allPostIdsSet.size})${resource ? '...' : '.'}`;
updateEstimates();
}),
sleep(1000)
]);
collectDurations.push((Date.now() - now) / 1000);
}
};

Expand All @@ -232,24 +255,24 @@ const privatePosts = async ({ uuid, name, tags, before }) => {
return;
}

let privatedCount = 0;
let privatedFailCount = 0;

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

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

const now = Date.now();
await Promise.all([
megaEdit(postIds, { mode: 'private' }).then(() => {
privatedCount += postIds.length;
}).catch(() => {
privatedFailCount += postIds.length;
}).finally(() => {
privateStatus.textContent = `\nPrivated ${privatedCount} posts... ${privatedFailCount ? `(failed: ${privatedFailCount})` : ''}`;
updateEstimates();
}),
sleep(1000)
]);
privateDurations.push((Date.now() - now) / 1000);
}

await sleep(1000);
Expand Down
57 changes: 45 additions & 12 deletions src/scripts/mass_unliker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { addSidebarItem, removeSidebarItem } from '../util/sidebar.js';
import { showModal, modalCancelButton, modalCompleteButton, showErrorModal } from '../util/modals.js';
import { apiFetch } from '../util/tumblr_helpers.js';
import { dom } from '../util/dom.js';
import { constructDurationString } from '../util/text_format.js';

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

const gatherStatusElement = dom('span');
const unlikeStatusElement = dom('span');
const remainingElement = dom('span');

const gatherLikes = async function () {
gatherStatusElement.textContent = 'Gathering likes...';

const likes = [];
let resource = '/v2/user/likes';

Expand All @@ -25,13 +29,53 @@ const gatherLikes = async function () {
};

const unlikePosts = async function () {
gatherStatusElement.textContent = 'Gathering likes...';
let stopped = false;
const stopButton = dom(
'button',
null,
{
click: () => {
stopped = true;
stopButton.textContent = 'Stopping...';
stopButton.disabled = true;
}
},
['Stop']
);

showModal({
title: 'Clearing your likes...',
message: [
dom('small', null, null, ['Do not navigate away from this page, or the process will be interrupted.\n\n']),
gatherStatusElement,
'\n',
unlikeStatusElement,
'\n',
remainingElement
],
buttons: [stopButton]
});

const likes = await gatherLikes();
let unlikedCount = 0;
let failureCount = 0;

for (const { id, reblogKey } of likes) {
if (stopped) {
showModal({
title: 'Stopped!',
message: [
`Unliked ${unlikedCount} posts.\n`,
`Failed to unlike ${failureCount} posts.\n\n`
],
buttons: [
modalCompleteButton
]
});
return;
}
unlikeStatusElement.textContent = `Unliking post with ID ${id}...`;
remainingElement.textContent = `Estimated time remaining: ${constructDurationString(likes.length - unlikedCount - failureCount)}`;
try {
await Promise.all([
apiFetch('/v2/user/unlike', { method: 'POST', body: { id, reblog_key: reblogKey } }),
Expand All @@ -57,24 +101,13 @@ const unlikePosts = async function () {
});
};

const modalWorkingOptions = {
title: 'Clearing your likes...',
message: [
dom('small', null, null, ['Do not navigate away from this page, or the process will be interrupted.\n\n']),
gatherStatusElement,
'\n',
unlikeStatusElement
]
};

const modalConfirmButton = dom(
'button',
{ class: 'red' },
{
click () {
gatherStatusElement.textContent = '';
unlikeStatusElement.textContent = '';
showModal(modalWorkingOptions);
unlikePosts().catch(showErrorModal);
}
},
Expand Down
27 changes: 1 addition & 26 deletions src/scripts/timeformat.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,11 @@ import moment from '../lib/moment.js';
import { keyToCss } from '../util/css_map.js';
import { pageModifications } from '../util/mutations.js';
import { getPreferences } from '../util/preferences.js';
import { constructRelativeTimeString } from '../util/text_format.js';

let format;
let displayRelative;

const relativeTimeFormat = new Intl.RelativeTimeFormat(document.documentElement.lang, { style: 'long' });
const thresholds = [
{ unit: 'year', denominator: 31557600 },
{ unit: 'month', denominator: 2629800 },
{ unit: 'week', denominator: 604800 },
{ unit: 'day', denominator: 86400 },
{ unit: 'hour', denominator: 3600 },
{ unit: 'minute', denominator: 60 },
{ unit: 'second', denominator: 1 }
];

const constructRelativeTimeString = function (unixTime) {
const now = Math.trunc(new Date().getTime() / 1000);
const unixDiff = unixTime - now;
const unixDiffAbsolute = Math.abs(unixDiff);

for (const { unit, denominator } of thresholds) {
if (unixDiffAbsolute >= denominator) {
const value = Math.trunc(unixDiff / denominator);
return relativeTimeFormat.format(value, unit);
}
}

return relativeTimeFormat.format(-0, 'second');
};

const formatTimeElements = function (timeElements) {
timeElements.forEach(timeElement => {
const momentDate = moment(timeElement.dateTime, moment.ISO_8601);
Expand Down
Loading