From 772eb241ca85501dfc2731e3ba04c4b93a7e4f33 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 12 Mar 2024 14:45:53 -0700 Subject: [PATCH 1/6] Create text_format.js --- src/util/text_format.js | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/util/text_format.js diff --git a/src/util/text_format.js b/src/util/text_format.js new file mode 100644 index 000000000..ecb8774f0 --- /dev/null +++ b/src/util/text_format.js @@ -0,0 +1,48 @@ +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 relativeTimeFormat = new Intl.RelativeTimeFormat(document.documentElement.lang, { style: 'long' }); +export 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'); +}; + +export 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 + */ +export 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, ', ']; + }); From f039c8be2eabd754e3a4828e57bead2df1964c28 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 12 Mar 2024 14:46:19 -0700 Subject: [PATCH 2/6] extract text formatting in published scripts --- src/scripts/mass_deleter.js | 9 +-------- src/scripts/mass_privater.js | 24 +----------------------- src/scripts/timeformat.js | 27 +-------------------------- src/scripts/timestamps.js | 26 +------------------------- 4 files changed, 4 insertions(+), 82 deletions(-) diff --git a/src/scripts/mass_deleter.js b/src/scripts/mass_deleter.js index a9c80193a..a0c52295a 100644 --- a/src/scripts/mass_deleter.js +++ b/src/scripts/mass_deleter.js @@ -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; @@ -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 }, [ diff --git a/src/scripts/mass_privater.js b/src/scripts/mass_privater.js index 934c926c0..d26135cd2 100644 --- a/src/scripts/mass_privater.js +++ b/src/scripts/mass_privater.js @@ -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 { dateTimeFormat, elementsAsList } from '../util/text_format.js'; import { apiFetch } from '../util/tumblr_helpers.js'; import { userBlogs } from '../util/user.js'; @@ -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 = () => { diff --git a/src/scripts/timeformat.js b/src/scripts/timeformat.js index b4ebca66b..03ad17a8f 100644 --- a/src/scripts/timeformat.js +++ b/src/scripts/timeformat.js @@ -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); diff --git a/src/scripts/timestamps.js b/src/scripts/timestamps.js index 1f3cc6296..827339058 100644 --- a/src/scripts/timestamps.js +++ b/src/scripts/timestamps.js @@ -4,6 +4,7 @@ import { apiFetch } from '../util/tumblr_helpers.js'; import { onNewPosts } from '../util/mutations.js'; import { getPreferences } from '../util/preferences.js'; import { keyToCss } from '../util/css_map.js'; +import { constructRelativeTimeString } from '../util/text_format.js'; const noteCountSelector = keyToCss('noteCount'); const reblogHeaderSelector = keyToCss('reblogHeader'); @@ -39,16 +40,6 @@ const longTimeFormat = new Intl.DateTimeFormat(locale, { second: '2-digit', timeZoneName: 'short' }); -const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, { 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 constructTimeString = function (unixTime) { const date = new Date(unixTime * 1000); @@ -91,21 +82,6 @@ const constructISOString = function (unixTime) { return `${fourDigitYear}-${twoDigitMonth}-${twoDigitDate}T${twoDigitHours}:${twoDigitMinutes}:${twoDigitSeconds}${timezoneOffsetIsNegative ? '+' : '-'}${twoDigitTimezoneOffsetHours}:${twoDigitTimezoneOffsetMinutes}`; }; -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 addPostTimestamps = async function () { getPostElements({ excludeClass: 'xkit-timestamps-done' }).forEach(async postElement => { const { id } = postElement.dataset; From cb72c4981f1e4611f372f8b205d2bfca0745b68e Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 12 Mar 2024 15:31:15 -0700 Subject: [PATCH 3/6] create `constructDurationString` --- src/util/text_format.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/util/text_format.js b/src/util/text_format.js index ecb8774f0..4f2dbde1d 100644 --- a/src/util/text_format.js +++ b/src/util/text_format.js @@ -46,3 +46,22 @@ export const elementsAsList = (array, andOr) => if (i === array.length - 2) return array.length === 2 ? [item, ` ${andOr} `] : [item, `, ${andOr} `]; return [item, ', ']; }); + +export const constructDurationString = function (seconds) { + const parts = []; + + for (const { unit, denominator } of thresholds) { + if (seconds >= denominator) { + const value = Math.trunc(seconds / denominator); + seconds -= value * denominator; + parts.push( + new Intl.NumberFormat(document.documentElement.lang, { + style: 'unit', + unit, + unitDisplay: 'long' + }).format(value) + ); + } + } + return elementsAsList(parts, 'and').join(''); +}; From dba2edc655cb83ee03d5d1be17cfacd552ffea71 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 12 Mar 2024 15:31:03 -0700 Subject: [PATCH 4/6] Add time remaining estimate to mass unliker --- src/scripts/mass_unliker.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/scripts/mass_unliker.js b/src/scripts/mass_unliker.js index 1a6c2ecd9..151120d72 100644 --- a/src/scripts/mass_unliker.js +++ b/src/scripts/mass_unliker.js @@ -2,11 +2,13 @@ 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 () { const likes = []; @@ -32,6 +34,7 @@ const unlikePosts = async function () { for (const { id, reblogKey } of likes) { 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 } }), @@ -63,7 +66,9 @@ const modalWorkingOptions = { dom('small', null, null, ['Do not navigate away from this page, or the process will be interrupted.\n\n']), gatherStatusElement, '\n', - unlikeStatusElement + unlikeStatusElement, + '\n', + remainingElement ] }; From 742c83df6111b513d3310c861c5843c343501f41 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 12 Mar 2024 15:42:12 -0700 Subject: [PATCH 5/6] Add stop button to mass unliker --- src/scripts/mass_unliker.js | 56 +++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/scripts/mass_unliker.js b/src/scripts/mass_unliker.js index 151120d72..26b62de84 100644 --- a/src/scripts/mass_unliker.js +++ b/src/scripts/mass_unliker.js @@ -11,6 +11,8 @@ const unlikeStatusElement = dom('span'); const remainingElement = dom('span'); const gatherLikes = async function () { + gatherStatusElement.textContent = 'Gathering likes...'; + const likes = []; let resource = '/v2/user/likes'; @@ -27,12 +29,51 @@ 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 { @@ -60,18 +101,6 @@ 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, - '\n', - remainingElement - ] -}; - const modalConfirmButton = dom( 'button', { class: 'red' }, @@ -79,7 +108,6 @@ const modalConfirmButton = dom( click () { gatherStatusElement.textContent = ''; unlikeStatusElement.textContent = ''; - showModal(modalWorkingOptions); unlikePosts().catch(showErrorModal); } }, From 791a5db3bae4f2a60a7399378e98b9e209fd11c8 Mon Sep 17 00:00:00 2001 From: Marcus Date: Fri, 29 Mar 2024 17:36:27 -0700 Subject: [PATCH 6/6] WIP: Add time remaining estimate to mass privater --- src/scripts/mass_privater.js | 83 +++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/src/scripts/mass_privater.js b/src/scripts/mass_privater.js index d26135cd2..6e4030cca 100644 --- a/src/scripts/mass_privater.js +++ b/src/scripts/mass_privater.js @@ -2,7 +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 { dateTimeFormat, elementsAsList } from '../util/text_format.js'; +import { constructDurationString, dateTimeFormat, elementsAsList } from '../util/text_format.js'; import { apiFetch } from '../util/tumblr_helpers.js'; import { userBlogs } from '../util/user.js'; @@ -78,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; @@ -125,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!'] ) ] @@ -156,9 +159,10 @@ 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...', @@ -166,21 +170,60 @@ const privatePosts = async ({ uuid, name, tags, before }) => { 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 @@ -190,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); } }; @@ -210,14 +255,12 @@ 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; @@ -225,9 +268,11 @@ const privatePosts = async ({ uuid, name, tags, before }) => { privatedFailCount += postIds.length; }).finally(() => { privateStatus.textContent = `\nPrivated ${privatedCount} posts... ${privatedFailCount ? `(failed: ${privatedFailCount})` : ''}`; + updateEstimates(); }), sleep(1000) ]); + privateDurations.push((Date.now() - now) / 1000); } await sleep(1000);