diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 30a75b9..3565e57 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,5 +12,8 @@ module.exports = { }, rules: { "no-unused-vars": "warn" + }, + globals: { + chrome: true } }; diff --git a/README.md b/README.md index f3e7705..a8ec9cf 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ The extension is available for download at: - Sort playlists with 100 videos or less, by the following criteria: - Duration - Channel Name - - View Count - Index - - Upload Date (only for public playlists) + - Views (only for some locales) + - Upload Date (only for public playlists & some locales) +- Internationalization (i18n) support > **Note:** The sorting feature is only enabled for playlists containing 100 > videos or less. This is because for larger playlists (>100 videos), YouTube @@ -159,3 +160,10 @@ panel located on the left-hand side of the page. If you wish to request a new feature or report a bug, please open an issue by clicking [here](https://github.com/nrednav/youtube-playlist-duration-calculator/issues/new). + +## Translations + +At present, the extension only has translations for the `en` & `zh_CN` locales. + +Additional translations are most welcome! Please see +[docs/translations.md](./docs/translations.md) for more details. diff --git a/docs/testing.md b/docs/testing.md index 1252046..a80be5d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,6 +16,8 @@ This document describes the process involved in testing the extension. - https://www.youtube.com/playlist?list=WL - Liked Videos - https://www.youtube.com/playlist?list=LL +- Has unavailable videos + - https://www.youtube.com/playlist?list=PL3HWFB6aFvWBXGsbVJhJJK1ykcqlVz5lI ## Pre-requisites @@ -43,7 +45,7 @@ This document describes the process involved in testing the extension. - Duration (Shortest/Longest) - Channel Name (A-Z/Z-A) - Views (Most/Least) - - (For public playlists only) Upload Date (Oldest/Newest) + - (For public playlists only) Upload Date (Earliest/Latest) - Clicking on a sort criterion updates the order of videos in the playlist - For playlists with more than 100 videos - Scrolling to the bottom updates the summary section to display the text diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000..8c64411 --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,32 @@ +# Translations + +This document describes the process involved in submitting additional +translations for the extension. + +## Pre-requisites + +- An understanding of JSON + +## Process + +### Adding new translations + +- Download the project repository as a ZIP file +- Extract it to a folder +- Within the extracted folder, navigate into the `public/_locales` folder +- Make a copy of the `en` folder +- Rename the copy to `` + - `` should be replaced with the locale code for the translations + you are adding + - For a full list of supported locale codes, please see: https://developer.chrome.com/docs/extensions/reference/api/i18n#locales +- Edit the `messages.json` file within `` to add your translations + - You only have to update the values of the `message` properties throughout + the file +- Once you have finished adding translations + - Create a new issue by visiting this [link](https://github.com/nrednav/youtube-playlist-duration-calculator/issues/new) + - Attach the `messages.json` file containing your translations to the issue + +### Updating existing translations + +- Same as above except instead of copying the `en` folder, you can directly edit + the `messages.json` file in the existing locale folder diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..af4aef6 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + } +} diff --git a/package.json b/package.json index 9ede745..87415f5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "An extension to calculate & display the total duration of a youtube playlist.", "author": "nrednav", "private": true, - "version": "2.1.0-beta.6", + "version": "2.1.0-beta.7", "type": "module", "engines": { "node": ">=20", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json new file mode 100644 index 0000000..8dfd66c --- /dev/null +++ b/public/_locales/en/messages.json @@ -0,0 +1,98 @@ +{ + "loaderMessage": { + "message": "Calculating...", + "description": "Text to display when the extension is loading" + }, + "videoTitle_private": { + "message": "[Private video]", + "description": "Title displayed by YouTube for private videos" + }, + "videoTitle_deleted": { + "message": "[Deleted video]", + "description": "Title displayed by YouTube for deleted videos" + }, + "videoTitle_unavailable_v1": { + "message": "[Unavailable]", + "description": "First variation of title displayed by YouTube for unavailable videos" + }, + "videoTitle_unavailable_v2": { + "message": "[Video unavailable]", + "description": "Second variation of title displayed by YouTube for unavailable videos" + }, + "videoTitle_restricted": { + "message": "[Restricted video]", + "description": "Title displayed by YouTube for restricted videos" + }, + "videoTitle_ageRestricted": { + "message": "[Age restricted]", + "description": "Title displayed by YouTube for age-restricted videos" + }, + "problemEncountered_paragraphOne": { + "message": "Encountered a problem.", + "description": "Text to display in first paragraph when a problem has been encountered" + }, + "problemEncountered_paragraphTwo": { + "message": "Please reload this page to recalculate the playlist duration.", + "description": "Text to display in second paragraph when a problem has been encountered" + }, + "playlistSummary_totalDuration": { + "message": "Total duration:", + "description": "Text to display as label for the playlist duration" + }, + "playlistSummary_videosCounted": { + "message": "Videos counted:", + "description": "Text to display as label for the videos counted" + }, + "playlistSummary_videosNotCounted": { + "message": "Videos not counted:", + "description": "Text to display as label for the videos not counted" + }, + "playlistSummary_tooltip": { + "message": "Scroll down to count more videos", + "description": "Text to display within tooltip" + }, + "sortDropdown_label": { + "message": "Sort by:", + "description": "Text to display as label for the sort dropdown" + }, + "sortType_index_label_asc": { + "message": "Index (Ascending)", + "description": "Text to display for the ascending 'sort by index' option" + }, + "sortType_index_label_desc": { + "message": "Index (Descending)", + "description": "Text to display for the descending 'sort by index' option" + }, + "sortType_duration_label_asc": { + "message": "Duration (Shortest)", + "description": "Text to display for the ascending 'sort by duration' option" + }, + "sortType_duration_label_desc": { + "message": "Duration (Longest)", + "description": "Text to display for the descending 'sort by duration' option" + }, + "sortType_channelName_label_asc": { + "message": "Channel Name (A-Z)", + "description": "Text to display for the ascending 'sort by channel name' option" + }, + "sortType_channelName_label_desc": { + "message": "Channel Name (Z-A)", + "description": "Text to display for the descending 'sort by channel name' option" + }, + "sortType_views_label_asc": { + "message": "Views (Least)", + "description": "Text to display for the ascending 'sort by views' option" + }, + "sortType_views_label_desc": { + "message": "Views (Most)", + "description": "Text to display for the descending 'sort by views' option" + }, + "sortType_uploadDate_label_asc": { + "message": "Upload Date (Latest)", + "description": "Text to display for the ascending 'sort by upload date' option" + }, + "sortType_uploadDate_label_desc": { + "message": "Upload Date (Earliest)", + "description": "Text to display for the descending 'sort by upload date' option" + } +} diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json new file mode 100644 index 0000000..04c2bf9 --- /dev/null +++ b/public/_locales/zh_CN/messages.json @@ -0,0 +1,98 @@ +{ + "loaderMessage": { + "message": "数数...", + "description": "Text to display when the extension is loading" + }, + "videoTitle_private": { + "message": "[私享视频]", + "description": "Title displayed by YouTube for private videos" + }, + "videoTitle_deleted": { + "message": "[已删除的视频]", + "description": "Title displayed by YouTube for deleted videos" + }, + "videoTitle_unavailable_v1": { + "message": "[不可用]", + "description": "First variation of title displayed by YouTube for unavailable videos" + }, + "videoTitle_unavailable_v2": { + "message": "[视频不可用]", + "description": "Second variation of title displayed by YouTube for unavailable videos" + }, + "videoTitle_restricted": { + "message": "[受限视频]", + "description": "Title displayed by YouTube for restricted videos" + }, + "videoTitle_ageRestricted": { + "message": "[Age restricted]", + "description": "Title displayed by YouTube for age-restricted videos" + }, + "problemEncountered_paragraphOne": { + "message": "发现问题", + "description": "Text to display in first paragraph when a problem has been encountered" + }, + "problemEncountered_paragraphTwo": { + "message": "请重新加载此页面以再次计算播放列表持续时间", + "description": "Text to display in second paragraph when a problem has been encountered" + }, + "playlistSummary_totalDuration": { + "message": "总持续时间:", + "description": "Text to display as label for the playlist duration" + }, + "playlistSummary_videosCounted": { + "message": "计算中使用的视频数量:", + "description": "Text to display as label for the videos counted" + }, + "playlistSummary_videosNotCounted": { + "message": "未用于计算的视频数量:", + "description": "Text to display as label for the videos not counted" + }, + "playlistSummary_tooltip": { + "message": "向下滚动以计数更多", + "description": "Text to display within tooltip" + }, + "sortDropdown_label": { + "message": "排序方式:", + "description": "Text to display as label for the sort dropdown" + }, + "sortType_index_label_asc": { + "message": "索引 (升序)", + "description": "Text to display for the ascending 'sort by index' option" + }, + "sortType_index_label_desc": { + "message": "索引 (降序)", + "description": "Text to display for the descending 'sort by index' option" + }, + "sortType_duration_label_asc": { + "message": "持续时间 (最短)", + "description": "Text to display for the ascending 'sort by duration' option" + }, + "sortType_duration_label_desc": { + "message": "持续时间 (最长)", + "description": "Text to display for the descending 'sort by duration' option" + }, + "sortType_channelName_label_asc": { + "message": "频道名称 (升序)", + "description": "Text to display for the ascending 'sort by channel name' option" + }, + "sortType_channelName_label_desc": { + "message": "频道名称 (降序)", + "description": "Text to display for the descending 'sort by channel name' option" + }, + "sortType_views_label_asc": { + "message": "观看次数 (升序)", + "description": "Text to display for the ascending 'sort by views' option" + }, + "sortType_views_label_desc": { + "message": "观看次数 (降序)", + "description": "Text to display for the descending 'sort by views' option" + }, + "sortType_uploadDate_label_asc": { + "message": "上传日期 (最新)", + "description": "Text to display for the ascending 'sort by upload date' option" + }, + "sortType_uploadDate_label_desc": { + "message": "上传日期 (最早)", + "description": "Text to display for the descending 'sort by upload date' option" + } +} diff --git a/src/library/sorting.js b/src/library/sorting.js deleted file mode 100644 index 1b7b531..0000000 --- a/src/library/sorting.js +++ /dev/null @@ -1,297 +0,0 @@ -import { elementSelectors, getTimestampFromVideo } from "./index"; - -class PlaylistSorter { - constructor(strategy, sortOrder) { - this.strategy = strategy; - this.sortOrder = sortOrder; - } - - setStrategy(strategy, sortOrder) { - this.strategy = strategy; - this.sortOrder = sortOrder; - } - - sort(videos) { - return this.strategy.sort(videos, this.sortOrder); - } -} - -class SortByDurationStrategy { - /** - * Sorts a list of videos by their duration - * @param {Array} videos - * @param {"asc" | "desc"} sortOrder - * @returns {Array} - */ - sort(videos, sortOrder) { - return Array.from(videos) - .slice(0, 100) - .sort((videoA, videoB) => { - const timestampA = getTimestampFromVideo(videoA); - const timestampB = getTimestampFromVideo(videoB); - - if (sortOrder === "asc") { - return timestampA - timestampB; - } - - if (sortOrder === "desc") { - return timestampB - timestampA; - } - }); - } -} - -class SortByChannelNameStrategy { - /** - * Sorts a list of videos by their channel name - * @param {Array} videos - * @param {"asc" | "desc"} sortOrder - * @returns {Array} - */ - sort(videos, sortOrder) { - return Array.from(videos) - .slice(0, 100) - .sort((videoA, videoB) => { - const channelNameA = - videoA.querySelector(".ytd-channel-name").innerText; - const channelNameB = - videoB.querySelector(".ytd-channel-name").innerText; - - if (sortOrder === "asc") { - return channelNameA.localeCompare(channelNameB); - } - - if (sortOrder === "desc") { - return channelNameB.localeCompare(channelNameA); - } - }); - } -} - -class SortByIndexStrategy { - /** - * Sorts a list of videos by their index - * @param {Array} videos - * @param {"asc" | "desc"} sortOrder - * @returns {Array} - */ - sort(videos, sortOrder) { - return Array.from(videos) - .slice(0, 100) - .sort((videoA, videoB) => { - const indexA = videoA.querySelector( - "yt-formatted-string#index" - ).innerText; - const indexB = videoB.querySelector( - "yt-formatted-string#index" - ).innerText; - - if (sortOrder === "asc") { - return Number(indexA) - Number(indexB); - } - - if (sortOrder === "desc") { - return Number(indexB) - Number(indexA); - } - }); - } -} - -class SortByViewsStrategy { - /** - * Sorts a list of videos by their view count - * @param {Array} videos - * @param {"asc" | "desc"} sortOrder - * @returns {Array} - */ - sort(videos, sortOrder) { - return Array.from(videos) - .slice(0, 100) - .sort((videoA, videoB) => { - const videoInfoA = videoA.querySelector( - "yt-formatted-string#video-info" - ); - const videoInfoB = videoB.querySelector( - "yt-formatted-string#video-info" - ); - - const viewCountA = this.extractViewCount(videoInfoA); - const viewCountB = this.extractViewCount(videoInfoB); - - if (sortOrder === "asc") { - return viewCountA - viewCountB; - } - - if (sortOrder === "desc") { - return viewCountB - viewCountA; - } - }); - } - - /** - * Extracts the view count as a number from a video info element - * @param {Element} videoInfo - * @returns {number} - */ - extractViewCount(videoInfo) { - const viewCountElement = videoInfo.firstElementChild; - const viewCountRegex = /(\d+(\.\d+)?[km]?)/g; - const [viewCountString] = viewCountElement.textContent - .toLowerCase() - .match(viewCountRegex); - const suffix = viewCountString.slice(-1); - const viewCountBase = parseFloat(viewCountString); - - if (isNaN(viewCountBase)) { - return 0; - } - - if (suffix === "k") { - return Math.round(viewCountBase * 1000); - } else if (suffix === "m") { - return Math.round(viewCountBase * 1_000_000); - } else { - return Math.round(viewCountBase); - } - } -} - -class SortByUploadDateStrategy { - /** - * Sorts a list of videos by their upload date - * @param {Array} videos - * @param {"asc" | "desc"} sortOrder - * @returns {Array} - */ - sort(videos, sortOrder) { - return Array.from(videos) - .slice(0, 100) - .sort((videoA, videoB) => { - const videoInfoA = videoA.querySelector( - "yt-formatted-string#video-info" - ); - const videoInfoB = videoB.querySelector( - "yt-formatted-string#video-info" - ); - - const secondsA = this.extractUploadDateAsSeconds(videoInfoA); - const secondsB = this.extractUploadDateAsSeconds(videoInfoB); - - if (sortOrder === "asc") { - return secondsA - secondsB; - } - - if (sortOrder === "desc") { - return secondsB - secondsA; - } - }); - } - - /** - * Extracts the upload date as seconds from a video info element - * @param {Element} videoInfo - * @returns {number} - */ - extractUploadDateAsSeconds(videoInfo) { - const secondsByUnit = { - day: 1 * 86400, - week: 7 * 86400, - month: 30 * 86400, - year: 365 * 86400 - }; - - const uploadDateElement = videoInfo.children[2]; - const uploadDateRegex = /(\d+) (\w+) ago/; - const [value, unit] = uploadDateElement.textContent - .toLowerCase() - .match(uploadDateRegex) - .slice(1); - const normalizedUnit = unit.endsWith("s") ? unit.slice(0, -1) : unit; - return parseFloat(value) * secondsByUnit[normalizedUnit]; - } -} - -/** - * Generates an object containing information about each supported sort type - * @returns {Object} - */ -const generateSortTypes = () => ({ - index: { - enabled: videoHasElement("yt-formatted-string#index"), - label: { - asc: "Index (Ascending)", - desc: "Index (Descending)" - }, - strategy: SortByIndexStrategy - }, - duration: { - enabled: videoHasElement(elementSelectors.timestamp), - label: { - asc: "Duration (Shortest)", - desc: "Duration (Longest)" - }, - strategy: SortByDurationStrategy - }, - channelName: { - enabled: videoHasElement(".ytd-channel-name"), - label: { - asc: "Channel Name (A-Z)", - desc: "Channel Name (Z-A)" - }, - strategy: SortByChannelNameStrategy - }, - views: { - enabled: videoHasElement("yt-formatted-string#video-info"), - label: { - asc: "Views (Least)", - desc: "Views (Most)" - }, - strategy: SortByViewsStrategy - }, - uploadDate: { - enabled: - videoHasElement("yt-formatted-string#video-info") && - !pageHasNativeSortFeature(), - label: { - asc: "Upload Date (Newest)", - desc: "Upload Date (Oldest)" - }, - strategy: SortByUploadDateStrategy - } -}); - -/** - * Checks whether an element identified by identifier can be found within the - * first video element rendered in the playlist - * @param {string} identifier - * @returns {boolean} - */ -const videoHasElement = (identifier) => { - const videoElement = document.querySelector(elementSelectors.video); - return videoElement && videoElement.querySelector(identifier); -}; - -const pageHasNativeSortFeature = () => { - const nativeSortElement = document.querySelector( - "#filter-menu yt-sort-filter-sub-menu-renderer" - ); - return nativeSortElement !== null; -}; - -/** - * Generates a list of