popup.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110
  1. document.addEventListener("alpine:init", () => {
  2. //TODO
  3. // - preserve playlist order (sort)
  4. // - "play/resume playlist" button (pick up where you left off)
  5. // X track timestamp to truly resume where you left off
  6. // X periodically remove watched videos
  7. // X or move to an "old" category, and add a screen to see the list
  8. // - consider separating context menu items (rather than having a sub-menu)
  9. // X view playback history (?)
  10. // X include currently open tabs in export
  11. // - support playlist management (add, remove, rename playlists)
  12. // - support input text box for adding to playlist (how to handle title?)
  13. // - or raw json editing
  14. // X option (probably a button) to add current page (url, title) to playlist
  15. // X align with addLinkToPlaylist in background.js (no repeated videos)
  16. // - button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
  17. // - long-term: replace youtube page? rss feeds? need server?
  18. // X add personal rating feature ("enjoyed", "this was important", etc)
  19. // - option to move video (including status) to another playlist
  20. Alpine.data("playlistManager", () => ({
  21. playlists: {},
  22. currentIndices: {},
  23. history: {},
  24. sortedHistory: [],
  25. openMenus: new Set(), // Track which menus are open
  26. currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', or 'saveChannel'
  27. currentPlaylistName: "", // Track which playlist is being viewed
  28. playlistsForDisplay: [], // Computed array for display
  29. currentPlaylistVideos: [], // Videos for current playlist view
  30. currentTab: null, // Current active tab info
  31. isCurrentTabYoutube: false, // Whether current tab is YouTube
  32. isCurrentTabChannelPage: false, // Whether current tab is YouTube videos page
  33. addCurrentPageButtonText: "Add Current Page", // Button text
  34. // Save channel properties
  35. selectedCategory: "FOR BOTH", // Currently selected category
  36. availableCategories: [
  37. "FOR BOTH",
  38. "CHANNELS",
  39. "SCIFI",
  40. "POLI/REL",
  41. "REAL ESTATE",
  42. "HELICOPTER",
  43. "SAILING",
  44. "VAN LIFE",
  45. "BLOCKCHAIN",
  46. "MAKING",
  47. "DESIGN",
  48. "TECH CONF",
  49. "CREATIVE",
  50. ], // Available categories
  51. curlCommand: "", // Generated curl command
  52. checkInterval: 14, // Check interval in days
  53. init() {
  54. this.loadPlaylists();
  55. this.loadHistory();
  56. this.getCurrentTab();
  57. // Add document click handler to close menus
  58. document.addEventListener("click", (e) => {
  59. // If click is not on a more button or menu, close all menus
  60. if (!e.target.closest(".more-menu-container")) {
  61. this.closeAllMenus();
  62. }
  63. });
  64. },
  65. async loadPlaylists() {
  66. try {
  67. const result = await browser.storage.local.get("playlists");
  68. console.log("LOAD RESULT", result.playlists);
  69. this.playlists = result.playlists || {};
  70. if (result.playlists) {
  71. this.currentIndices = Object.keys(result.playlists).reduce(
  72. (acc, pln) => {
  73. const ind = result.playlists[pln].findIndex(
  74. (v) => v.status !== "done",
  75. );
  76. if (ind === -1) {
  77. acc[pln] = result.playlists[pln].length;
  78. } else {
  79. acc[pln] = ind;
  80. }
  81. return acc;
  82. },
  83. {},
  84. );
  85. } else {
  86. this.currentIndices = {};
  87. }
  88. } catch (error) {
  89. console.error("Error loading playlists:", error);
  90. }
  91. this.updatePlaylistsForDisplay();
  92. },
  93. updatePlaylistsForDisplay() {
  94. this.playlistsForDisplay = Object.entries(this.playlists).map(
  95. ([playlistName, videos]) => {
  96. const currentIndex = this.currentIndices[playlistName] || 0;
  97. const shouldShowTruncation = currentIndex > 0;
  98. const truncationText = shouldShowTruncation
  99. ? `(${currentIndex} previous video${currentIndex === 1 ? "" : "s"})`
  100. : "";
  101. // Get visible videos from current index onwards with original indices
  102. const visibleVideos = videos
  103. .slice(currentIndex)
  104. .map((video, index) => ({
  105. ...video,
  106. originalIndex: currentIndex + index,
  107. doneButtonText:
  108. video.status === "done" ? "Remove Done Status" : "Mark as Done",
  109. isNonContiguousDone: this.isNonContiguousDone(
  110. playlistName,
  111. currentIndex + index,
  112. ),
  113. }));
  114. return {
  115. name: playlistName,
  116. videos: videos,
  117. visibleVideos: visibleVideos,
  118. shouldShowTruncation: shouldShowTruncation,
  119. truncationText: truncationText,
  120. };
  121. },
  122. );
  123. },
  124. updateCurrentPlaylistVideos() {
  125. if (
  126. !this.currentPlaylistName ||
  127. !this.playlists[this.currentPlaylistName]
  128. ) {
  129. this.currentPlaylistVideos = [];
  130. } else {
  131. this.currentPlaylistVideos = this.playlists[
  132. this.currentPlaylistName
  133. ].map((video, index) => ({
  134. ...video,
  135. doneButtonText:
  136. video.status === "done" ? "Remove Done Status" : "Mark as Done",
  137. isNonContiguousDone: this.isNonContiguousDone(
  138. this.currentPlaylistName,
  139. index,
  140. ),
  141. }));
  142. }
  143. },
  144. async loadHistory() {
  145. try {
  146. const result = await browser.storage.local.get("history");
  147. console.log("LOAD HISTORY RESULT", result.history);
  148. this.history = result.history || {};
  149. this.sortHistoryByRecentInteraction();
  150. } catch (error) {
  151. console.error("Error loading history:", error);
  152. }
  153. },
  154. async getCurrentTab() {
  155. try {
  156. const tabs = await browser.tabs.query({
  157. active: true,
  158. currentWindow: true,
  159. });
  160. if (tabs.length > 0) {
  161. this.currentTab = tabs[0];
  162. const url = new URL(this.currentTab.url);
  163. this.isCurrentTabYoutube = url.hostname === "www.youtube.com";
  164. this.isCurrentTabChannelPage =
  165. this.isCurrentTabYoutube && url.pathname.includes("/videos");
  166. this.updateAddCurrentPageButtonText();
  167. this.updateCurlCommand();
  168. }
  169. } catch (error) {
  170. console.error("Error getting current tab:", error);
  171. this.currentTab = null;
  172. this.isCurrentTabYoutube = false;
  173. this.isCurrentTabChannelPage = false;
  174. this.updateAddCurrentPageButtonText();
  175. this.updateCurlCommand();
  176. }
  177. },
  178. updateAddCurrentPageButtonText() {
  179. if (!this.currentTab) {
  180. this.addCurrentPageButtonText = "Unable to get current page";
  181. } else if (!this.isCurrentTabYoutube) {
  182. this.addCurrentPageButtonText = "Add Current Page (YouTube only)";
  183. } else {
  184. this.addCurrentPageButtonText = "Add Current Page to Playlist";
  185. }
  186. },
  187. updateCurlCommand() {
  188. if (!this.currentTab || !this.isCurrentTabChannelPage) {
  189. this.curlCommand =
  190. "This feature is only available on YouTube channel pages (/videos).";
  191. return;
  192. }
  193. const title = this.currentTab.title.replace(" - YouTube", "");
  194. const url = this.currentTab.url;
  195. const category = this.selectedCategory;
  196. const interval = this.checkInterval;
  197. this.curlCommand = `curl -X POST -H 'content-type: application/json' -d '{"query": "mutation add{ addChannel(details: {category: \\"${category}\\", checkInterval: ${interval}, name: \\"${title}\\", url: \\"${url}\\"}){name} } "}' localhost:8543/data | jq '.'`;
  198. },
  199. async copyCurlCommand() {
  200. try {
  201. await navigator.clipboard.writeText(this.curlCommand);
  202. console.log("Curl command copied to clipboard");
  203. // Could add visual feedback here
  204. } catch (error) {
  205. console.error("Failed to copy to clipboard:", error);
  206. // Fallback for older browsers
  207. try {
  208. const textArea = document.createElement("textarea");
  209. textArea.value = this.curlCommand;
  210. document.body.appendChild(textArea);
  211. textArea.focus();
  212. textArea.select();
  213. document.execCommand("copy");
  214. document.body.removeChild(textArea);
  215. console.log("Curl command copied to clipboard (fallback)");
  216. } catch (fallbackError) {
  217. console.error("Fallback copy failed:", fallbackError);
  218. }
  219. }
  220. },
  221. selectCategory(category) {
  222. this.selectedCategory = category;
  223. this.updateCurlCommand();
  224. },
  225. updateCheckInterval(interval) {
  226. // Ensure it's a positive integer, default to 14 if invalid
  227. const parsedInterval = parseInt(interval);
  228. this.checkInterval = parsedInterval > 0 ? parsedInterval : 14;
  229. this.updateCurlCommand();
  230. },
  231. showSaveChannel() {
  232. this.currentView = "saveChannel";
  233. this.updateCurlCommand();
  234. },
  235. sortHistoryByRecentInteraction() {
  236. // Convert history object to array with video ID and sort by most recent interaction
  237. this.sortedHistory = Object.entries(this.history)
  238. .map(([videoId, videoData]) => {
  239. const lastInteraction =
  240. videoData.history.length > 0
  241. ? Math.max(...videoData.history.map((event) => event.timestamp))
  242. : 0;
  243. // Pre-process events with formatted data for CSP compliance
  244. const processedEvents = videoData.history
  245. .slice()
  246. .reverse()
  247. .map((event, index) => ({
  248. ...event,
  249. formattedAction: this.formatActionName(event.action),
  250. formattedPosition: `at ${this.formatVideoPosition(event.position)}`,
  251. formattedTimestamp: this.formatTimestamp(event.timestamp),
  252. uniqueKey: `${videoId}-${event.timestamp}-${index}`,
  253. }));
  254. return {
  255. videoId,
  256. formattedVideoId: `(${videoId})`,
  257. ...videoData,
  258. lastInteraction,
  259. processedEvents,
  260. tags: videoData.tags || [], // Ensure tags array exists
  261. };
  262. })
  263. .sort((a, b) => b.lastInteraction - a.lastInteraction);
  264. },
  265. formatTimestamp(timestamp) {
  266. const date = new Date(timestamp);
  267. const now = new Date();
  268. const diffMs = now - date;
  269. const diffMins = Math.floor(diffMs / (1000 * 60));
  270. const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
  271. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  272. if (diffMins < 1) return "Just now";
  273. if (diffMins < 60)
  274. return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
  275. if (diffHours < 24)
  276. return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
  277. if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
  278. return (
  279. date.toLocaleDateString() +
  280. " " +
  281. date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
  282. );
  283. },
  284. formatVideoPosition(seconds) {
  285. const minutes = Math.floor(seconds / 60);
  286. const remainingSeconds = Math.floor(seconds % 60);
  287. return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
  288. },
  289. async toggleTag(videoId, tag) {
  290. // Create a deep copy of history to avoid proxy issues
  291. const history = JSON.parse(JSON.stringify(this.history));
  292. if (!history[videoId]) {
  293. console.error(`Video ${videoId} not found in history`);
  294. return;
  295. }
  296. const videoData = history[videoId];
  297. if (!videoData.tags) {
  298. videoData.tags = [];
  299. }
  300. const tagIndex = videoData.tags.indexOf(tag);
  301. if (tagIndex === -1) {
  302. // Add tag
  303. videoData.tags.push(tag);
  304. } else {
  305. // Remove tag
  306. videoData.tags.splice(tagIndex, 1);
  307. }
  308. // Save to storage and update local state
  309. try {
  310. await browser.storage.local.set({ history: history });
  311. this.history = history;
  312. this.sortHistoryByRecentInteraction(); // Refresh display
  313. } catch (error) {
  314. console.error("Error saving tag changes:", error);
  315. }
  316. },
  317. isTagActive(videoId, tag) {
  318. const videoData = this.history[videoId];
  319. return videoData && videoData.tags && videoData.tags.includes(tag);
  320. },
  321. formatActionName(action) {
  322. const actionMap = {
  323. play: "Started",
  324. playing: "Playing",
  325. pause: "Paused",
  326. ended: "Finished",
  327. };
  328. return actionMap[action] || action;
  329. },
  330. formatPlaylistName(name) {
  331. // Convert "listening-1" to "Listening - 1"
  332. return name
  333. .split("-")
  334. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  335. .join(" - ");
  336. },
  337. openVideo(url) {
  338. browser.tabs.create({ url });
  339. },
  340. async removeVideo(playlistName, index) {
  341. const playlists = JSON.parse(JSON.stringify(this.playlists));
  342. // Make a copy of the current playlist
  343. const playlist = [...playlists[playlistName]];
  344. // Remove the video at the specified index
  345. playlist.splice(index, 1);
  346. // Create an updated playlists object with the remaining playlists unchanged
  347. const updatedPlaylists = {
  348. ...playlists,
  349. [playlistName]: playlist,
  350. };
  351. // Update the playlists in storage
  352. try {
  353. await browser.storage.local.set({ playlists: updatedPlaylists });
  354. this.playlists = updatedPlaylists;
  355. this.updatePlaylistsForDisplay();
  356. this.updateCurrentPlaylistVideos();
  357. } catch (error) {
  358. console.error("Error removing video:", error);
  359. }
  360. },
  361. async moveVideoUp(playlistName, index) {
  362. // Can't move the first item up
  363. if (index <= 0) return;
  364. const playlists = JSON.parse(JSON.stringify(this.playlists));
  365. // Make a copy of the current playlist
  366. const playlist = [...playlists[playlistName]];
  367. // Swap the video with the one above it
  368. [playlist[index], playlist[index - 1]] = [
  369. playlist[index - 1],
  370. playlist[index],
  371. ];
  372. // Create an updated playlists object
  373. const updatedPlaylists = {
  374. ...playlists,
  375. [playlistName]: playlist,
  376. };
  377. // Update the playlists in storage
  378. try {
  379. await browser.storage.local.set({ playlists: updatedPlaylists });
  380. this.playlists = updatedPlaylists;
  381. this.updatePlaylistsForDisplay();
  382. this.updateCurrentPlaylistVideos();
  383. } catch (error) {
  384. console.error("Error moving video up:", error);
  385. }
  386. },
  387. async moveVideoDown(playlistName, index) {
  388. const playlists = JSON.parse(JSON.stringify(this.playlists));
  389. const playlist = [...playlists[playlistName]];
  390. // Can't move the last item down
  391. if (index >= playlist.length - 1) return;
  392. // Swap the video with the one below it
  393. [playlist[index], playlist[index + 1]] = [
  394. playlist[index + 1],
  395. playlist[index],
  396. ];
  397. // Create an updated playlists object
  398. const updatedPlaylists = {
  399. ...playlists,
  400. [playlistName]: playlist,
  401. };
  402. // Update the playlists in storage
  403. try {
  404. await browser.storage.local.set({ playlists: updatedPlaylists });
  405. this.playlists = updatedPlaylists;
  406. this.updatePlaylistsForDisplay();
  407. this.updateCurrentPlaylistVideos();
  408. } catch (error) {
  409. console.error("Error moving video down:", error);
  410. }
  411. },
  412. async toggleVideoDoneStatus(playlistName, index) {
  413. const playlists = JSON.parse(JSON.stringify(this.playlists));
  414. const playlist = [...playlists[playlistName]];
  415. const video = { ...playlist[index] };
  416. // Toggle the done status
  417. if (video.status === "done") {
  418. // Remove status property (undefined status means not done)
  419. delete video.status;
  420. } else {
  421. // Set status to done
  422. video.status = "done";
  423. }
  424. // Update the video in the playlist
  425. playlist[index] = video;
  426. // Create an updated playlists object
  427. const updatedPlaylists = {
  428. ...playlists,
  429. [playlistName]: playlist,
  430. };
  431. // Update the playlists in storage
  432. try {
  433. await browser.storage.local.set({ playlists: updatedPlaylists });
  434. this.playlists = updatedPlaylists;
  435. this.updatePlaylistsForDisplay();
  436. this.updateCurrentPlaylistVideos();
  437. } catch (error) {
  438. console.error("Error toggling video done status:", error);
  439. }
  440. },
  441. async addCurrentPageToPlaylist() {
  442. if (
  443. !this.currentTab ||
  444. !this.isCurrentTabYoutube ||
  445. !this.currentPlaylistName
  446. ) {
  447. return;
  448. }
  449. // Create video object using current tab info
  450. const video = {
  451. url: this.currentTab.url,
  452. title: this.currentTab.title,
  453. };
  454. // Use shared utility function to add video (handles duplicate checking)
  455. const wasAdded = await PlaylistUtils.addVideoToPlaylist(
  456. this.currentPlaylistName,
  457. video,
  458. );
  459. if (wasAdded) {
  460. // Refresh displays only if video was actually added
  461. this.loadPlaylists(); // This will update both display arrays
  462. } else {
  463. // Could show user feedback that video already exists
  464. console.log("Video already exists in a playlist");
  465. }
  466. },
  467. async exportPlaylists() {
  468. try {
  469. // Get current playlists
  470. const playlistResult = await browser.storage.local.get("playlists");
  471. const playlists = playlistResult.playlists || {};
  472. // Get playback history
  473. const historyResult = await browser.storage.local.get("history");
  474. const playbackHistory = historyResult.history || {};
  475. // Get open tabs
  476. let openTabs = [];
  477. try {
  478. const tabsResult = await browser.tabs.query({});
  479. openTabs = tabsResult
  480. .filter(
  481. (tab) =>
  482. tab.url &&
  483. !tab.url.startsWith("moz-extension://") &&
  484. !tab.url.startsWith("about:") &&
  485. !tab.url.startsWith("chrome://"),
  486. )
  487. .map((tab) => ({
  488. url: tab.url,
  489. title: tab.title || "Untitled",
  490. }));
  491. } catch (error) {
  492. console.warn("Could not retrieve open tabs:", error);
  493. openTabs = [];
  494. }
  495. // Create export data object
  496. const exportData = {
  497. playlists,
  498. playbackHistory,
  499. openTabs,
  500. exportDate: new Date().toISOString(),
  501. };
  502. // Convert to JSON
  503. const jsonString = JSON.stringify(exportData, null, 2);
  504. // Create download
  505. const blob = new Blob([jsonString], { type: "application/json" });
  506. const url = URL.createObjectURL(blob);
  507. // Trigger download
  508. const a = document.createElement("a");
  509. a.href = url;
  510. a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`;
  511. document.body.appendChild(a);
  512. a.click();
  513. // Clean up
  514. setTimeout(() => {
  515. document.body.removeChild(a);
  516. URL.revokeObjectURL(url);
  517. }, 100);
  518. } catch (error) {
  519. console.error("Error exporting playlists:", error);
  520. }
  521. },
  522. videotitle: {
  523. ["@click"]() {
  524. console.log("TITLE CLICK", this.$el);
  525. },
  526. },
  527. videoPlayLink: {
  528. ["@click.prevent"]() {
  529. browser.tabs.update({ url: this.$el.href });
  530. },
  531. },
  532. isNonContiguousDone(playlistName, videoIndex) {
  533. const playlist = this.playlists[playlistName];
  534. if (
  535. !playlist ||
  536. !playlist[videoIndex] ||
  537. playlist[videoIndex].status !== "done"
  538. ) {
  539. return false;
  540. }
  541. // Check if there's any non-done video before this done video
  542. for (let i = 0; i < videoIndex; i++) {
  543. if (playlist[i].status !== "done") {
  544. return true;
  545. }
  546. }
  547. return false;
  548. },
  549. isCurrentVideo(playlistName, index) {
  550. const currentIndex = this.currentIndices[playlistName];
  551. return currentIndex === index;
  552. },
  553. isDoneVideo(playlistName, index) {
  554. const currentIndex = this.currentIndices[playlistName];
  555. return index < currentIndex;
  556. },
  557. isVideoDone(playlistName, index) {
  558. const video =
  559. this.playlists[playlistName] && this.playlists[playlistName][index];
  560. return video && video.status === "done";
  561. },
  562. videoItemClass: {
  563. [":class"]() {
  564. const playlistName = this.$el.dataset.playlistName;
  565. const index = parseInt(this.$el.dataset.playlistIndex);
  566. const video = this.playlists[playlistName][index];
  567. return {
  568. "current-video": this.isCurrentVideo(playlistName, index),
  569. "done-video":
  570. this.isDoneVideo(playlistName, index) && video.status === "done",
  571. "non-contiguous-done-video": this.isNonContiguousDone(
  572. playlistName,
  573. index,
  574. ),
  575. };
  576. },
  577. },
  578. removeVideoButton: {
  579. ["@click"]() {
  580. this.removeVideo(
  581. this.$el.dataset.playlistName,
  582. this.$el.dataset.playlistIndex,
  583. );
  584. this.closeAllMenus();
  585. },
  586. },
  587. toggleVideoDoneButton: {
  588. ["@click"]() {
  589. this.toggleVideoDoneStatus(
  590. this.$el.dataset.playlistName,
  591. parseInt(this.$el.dataset.playlistIndex),
  592. );
  593. this.closeAllMenus();
  594. },
  595. },
  596. moveUpButton: {
  597. ["@click"]() {
  598. this.moveVideoUp(
  599. this.$el.dataset.playlistName,
  600. parseInt(this.$el.dataset.playlistIndex),
  601. );
  602. },
  603. [":disabled"]() {
  604. return parseInt(this.$el.dataset.playlistIndex) === 0;
  605. },
  606. },
  607. moveDownButton: {
  608. ["@click"]() {
  609. this.moveVideoDown(
  610. this.$el.dataset.playlistName,
  611. parseInt(this.$el.dataset.playlistIndex),
  612. );
  613. },
  614. [":disabled"]() {
  615. return (
  616. parseInt(this.$el.dataset.playlistIndex) ===
  617. this.playlists[this.$el.dataset.playlistName].length - 1
  618. );
  619. },
  620. },
  621. exportButton: {
  622. ["@click"]() {
  623. this.exportPlaylists();
  624. },
  625. },
  626. importButton: {
  627. ["@click"]() {
  628. document.getElementById("import-file-input").click();
  629. },
  630. },
  631. importFileInput: {
  632. ["@change"]() {
  633. this.importFile(this.$event);
  634. },
  635. },
  636. importFile(event) {
  637. const file = event.target.files[0];
  638. if (!file) return;
  639. const reader = new FileReader();
  640. reader.onload = (e) => {
  641. try {
  642. const importedData = JSON.parse(e.target.result);
  643. this.validateAndImportPlaylists(importedData);
  644. } catch (error) {
  645. console.error("Error parsing JSON file:", error);
  646. alert(
  647. "Invalid JSON file. Please select a valid playlist export file.",
  648. );
  649. } finally {
  650. // Reset the file input so the same file can be selected again
  651. event.target.value = "";
  652. }
  653. };
  654. reader.readAsText(file);
  655. },
  656. validateAndImportPlaylists(data) {
  657. // Validate the playlists structure
  658. if (!data.playlists || typeof data.playlists !== "object") {
  659. alert("Invalid file format: Missing or invalid 'playlists' property");
  660. return;
  661. }
  662. // Validate each playlist
  663. const validPlaylists = {};
  664. let hasErrors = false;
  665. for (const [playlistName, videos] of Object.entries(data.playlists)) {
  666. // Check if videos is an array
  667. if (!Array.isArray(videos)) {
  668. console.error(
  669. `Playlist '${playlistName}' does not contain a valid array of videos`,
  670. );
  671. hasErrors = true;
  672. continue;
  673. }
  674. // Validate each video in the playlist
  675. const validVideos = videos.filter((video) => {
  676. if (!video || typeof video !== "object") {
  677. console.error(`Invalid video object in '${playlistName}'`);
  678. return false;
  679. }
  680. if (
  681. !video.url ||
  682. typeof video.url !== "string" ||
  683. !video.title ||
  684. typeof video.title !== "string"
  685. ) {
  686. console.error(
  687. `Video in '${playlistName}' missing required properties (url, title)`,
  688. );
  689. return false;
  690. }
  691. return true;
  692. });
  693. // Add the validated playlist if it has valid videos
  694. if (validVideos.length > 0) {
  695. validPlaylists[playlistName] = validVideos;
  696. }
  697. }
  698. if (Object.keys(validPlaylists).length === 0) {
  699. alert("No valid playlists found in the import file");
  700. return;
  701. }
  702. if (hasErrors) {
  703. const confirmImport = confirm(
  704. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  705. );
  706. if (!confirmImport) return;
  707. }
  708. // Update storage and state
  709. this.updatePlaylists(validPlaylists);
  710. },
  711. async updatePlaylists(playlists) {
  712. try {
  713. await browser.storage.local.set({ playlists });
  714. this.playlists = playlists;
  715. this.updatePlaylistsForDisplay();
  716. this.updateCurrentPlaylistVideos();
  717. alert("Playlists imported successfully!");
  718. } catch (error) {
  719. console.error("Error updating playlists:", error);
  720. alert("Error importing playlists: " + error.message);
  721. }
  722. },
  723. getMenuId(playlistName, index) {
  724. return `${playlistName}-${index}`;
  725. },
  726. isMenuOpen(playlistName, index) {
  727. return this.openMenus.has(this.getMenuId(playlistName, index));
  728. },
  729. toggleMenu(playlistName, index) {
  730. const menuId = this.getMenuId(playlistName, index);
  731. if (this.openMenus.has(menuId)) {
  732. this.openMenus.delete(menuId);
  733. } else {
  734. // Close all other menus first
  735. this.openMenus.clear();
  736. this.openMenus.add(menuId);
  737. }
  738. },
  739. closeAllMenus() {
  740. this.openMenus.clear();
  741. },
  742. moreMenuButton: {
  743. ["@click.stop"]() {
  744. this.toggleMenu(
  745. this.$el.dataset.playlistName,
  746. parseInt(this.$el.dataset.playlistIndex),
  747. );
  748. },
  749. },
  750. moreMenu: {
  751. ["x-show"]() {
  752. return this.isMenuOpen(
  753. this.$el.dataset.playlistName,
  754. parseInt(this.$el.dataset.playlistIndex),
  755. );
  756. },
  757. },
  758. // View navigation methods
  759. showHistory() {
  760. this.currentView = "history";
  761. this.loadHistory(); // Refresh history data when switching to history view
  762. },
  763. showPlaylists() {
  764. this.currentView = "playlists";
  765. },
  766. showPlaylist(playlistName) {
  767. this.currentView = "playlist";
  768. this.currentPlaylistName = playlistName;
  769. this.updateCurrentPlaylistVideos();
  770. },
  771. // Methods for truncated display
  772. shouldShowTruncation(playlistName) {
  773. const currentIndex = this.currentIndices[playlistName] || 0;
  774. return currentIndex > 0;
  775. },
  776. getTruncationText(playlistName) {
  777. const currentIndex = this.currentIndices[playlistName] || 0;
  778. const count = currentIndex;
  779. return `(${count} previous video${count === 1 ? "" : "s"})`;
  780. },
  781. getVisibleVideos(playlistName, videos) {
  782. const currentIndex = this.currentIndices[playlistName] || 0;
  783. // Show from current video onwards, but keep original indices
  784. return videos.slice(currentIndex).map((video, index) => ({
  785. ...video,
  786. originalIndex: currentIndex + index,
  787. }));
  788. },
  789. getCurrentPlaylistVideos() {
  790. if (
  791. !this.currentPlaylistName ||
  792. !this.playlists[this.currentPlaylistName]
  793. ) {
  794. return [];
  795. }
  796. return this.playlists[this.currentPlaylistName];
  797. },
  798. // Button bindings for navigation
  799. historyButton: {
  800. ["@click"]() {
  801. this.showHistory();
  802. },
  803. },
  804. saveChannelButton: {
  805. ["@click"]() {
  806. this.showSaveChannel();
  807. },
  808. },
  809. backButton: {
  810. ["@click"]() {
  811. this.showPlaylists();
  812. },
  813. },
  814. // Event handlers for playlist navigation
  815. playlistNameClick: {
  816. ["@click"]() {
  817. const playlistName = this.$el.dataset.playlistName;
  818. this.showPlaylist(playlistName);
  819. },
  820. },
  821. truncatedVideosClick: {
  822. ["@click"]() {
  823. const playlistName = this.$el.dataset.playlistName;
  824. this.showPlaylist(playlistName);
  825. },
  826. },
  827. truncatedVideosClick: {
  828. ["@click"]() {
  829. const playlistName = this.$el.dataset.playlistName;
  830. this.showPlaylist(playlistName);
  831. },
  832. },
  833. truncatedVideosDisplay: {
  834. ["x-show"]() {
  835. const playlistName = this.$el.dataset.playlistName;
  836. const playlistData = this.playlistsForDisplay.find(
  837. (p) => p.name === playlistName,
  838. );
  839. return playlistData ? playlistData.shouldShowTruncation : false;
  840. },
  841. ["@click"]() {
  842. const playlistName = this.$el.dataset.playlistName;
  843. this.showPlaylist(playlistName);
  844. },
  845. },
  846. // CSP-compliant view bindings
  847. playlistsHeader: {
  848. ["x-show"]() {
  849. return this.currentView === "playlists";
  850. },
  851. },
  852. historyHeader: {
  853. ["x-show"]() {
  854. return this.currentView === "history";
  855. },
  856. },
  857. playlistViewHeader: {
  858. ["x-show"]() {
  859. return this.currentView === "playlist";
  860. },
  861. },
  862. saveChannelHeader: {
  863. ["x-show"]() {
  864. return this.currentView === "saveChannel";
  865. },
  866. },
  867. playlistsContainer: {
  868. ["x-show"]() {
  869. return this.currentView === "playlists";
  870. },
  871. },
  872. historyContainer: {
  873. ["x-show"]() {
  874. return this.currentView === "history";
  875. },
  876. },
  877. playlistViewContainer: {
  878. ["x-show"]() {
  879. return this.currentView === "playlist";
  880. },
  881. },
  882. saveChannelContainer: {
  883. ["x-show"]() {
  884. return this.currentView === "saveChannel";
  885. },
  886. },
  887. exportContainer: {
  888. ["x-show"]() {
  889. return this.currentView === "playlists";
  890. },
  891. },
  892. // History-specific bindings for CSP compliance
  893. historyEmptyState: {
  894. ["x-show"]() {
  895. return this.sortedHistory.length === 0;
  896. },
  897. },
  898. // Playlist view-specific bindings for CSP compliance
  899. playlistViewEmptyState: {
  900. ["x-show"]() {
  901. return this.currentPlaylistVideos.length === 0;
  902. },
  903. },
  904. // Tag chip bindings for CSP compliance
  905. tagChip: {
  906. ["@click"]() {
  907. const videoId = this.$el.dataset.videoId;
  908. const tag = this.$el.dataset.tag;
  909. this.toggleTag(videoId, tag);
  910. },
  911. [":class"]() {
  912. const videoId = this.$el.dataset.videoId;
  913. const tag = this.$el.dataset.tag;
  914. return {
  915. active: this.isTagActive(videoId, tag),
  916. };
  917. },
  918. },
  919. // Save Channel specific bindings
  920. copyCurlButton: {
  921. ["@click"]() {
  922. this.copyCurlCommand();
  923. },
  924. [":disabled"]() {
  925. return !this.isCurrentTabChannelPage;
  926. },
  927. },
  928. categoryButton: {
  929. ["@click"]() {
  930. const category = this.$el.dataset.category;
  931. this.selectCategory(category);
  932. },
  933. [":class"]() {
  934. const category = this.$el.dataset.category;
  935. return {
  936. active: this.selectedCategory === category,
  937. };
  938. },
  939. },
  940. saveChannelNotice: {
  941. ["x-show"]() {
  942. return !this.isCurrentTabChannelPage;
  943. },
  944. },
  945. intervalInput: {
  946. [":value"]() {
  947. return this.checkInterval;
  948. },
  949. ["@input"]() {
  950. this.updateCheckInterval(this.$el.value);
  951. },
  952. },
  953. // Add current page button binding
  954. addCurrentPageButton: {
  955. ["@click"]() {
  956. this.addCurrentPageToPlaylist();
  957. },
  958. [":disabled"]() {
  959. return (
  960. !this.currentTab ||
  961. !this.isCurrentTabYoutube ||
  962. !this.currentPlaylistName
  963. );
  964. },
  965. [":class"]() {
  966. return {
  967. disabled:
  968. !this.currentTab ||
  969. !this.isCurrentTabYoutube ||
  970. !this.currentPlaylistName,
  971. };
  972. },
  973. },
  974. }));
  975. });