diff --git a/package.json b/package.json index f55d580..0446865 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "context": "^3.0.4", "global": "^4.4.0", "lodash": "^4.17.21", + "minimatch": "^10.0.3", "polished": "^4.2.2", "prettier": "^2.7.1", "react": "^18.2.0", diff --git a/src/devtoolApp/components/DownloadList/OptionSection/index.js b/src/devtoolApp/components/DownloadList/OptionSection/index.js index d652fcd..34074ac 100644 --- a/src/devtoolApp/components/DownloadList/OptionSection/index.js +++ b/src/devtoolApp/components/DownloadList/OptionSection/index.js @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { Toggle } from '../../Toggle'; -import { OptionSectionWrapper } from './styles'; +import { OptionSectionWrapper, FilterInputWrapper, FilterInput, FilterLabel } from './styles'; import * as optionActions from 'devtoolApp/store/option'; import useStore from 'devtoolApp/store'; @@ -8,7 +8,7 @@ export const OptionSection = () => { const { dispatch, state: { - option: { ignoreNoContentFile, beautifyFile }, + option: { ignoreNoContentFile, beautifyFile, urlFilter }, ui: { isSaving }, }, } = useStore(); @@ -21,8 +21,25 @@ export const OptionSection = () => { dispatch(optionActions.setBeautifyFile(willBeautify)); }, []); + const handleUrlFilterChange = useCallback((e) => { + dispatch(optionActions.setUrlFilter(e.target.value)); + }, []); + return ( + + + URL Filter (supports glob patterns with negation, separate multiple rules with |): + + + Ignore "No Content" files diff --git a/src/devtoolApp/components/DownloadList/OptionSection/styles.js b/src/devtoolApp/components/DownloadList/OptionSection/styles.js index a495807..5f662bf 100644 --- a/src/devtoolApp/components/DownloadList/OptionSection/styles.js +++ b/src/devtoolApp/components/DownloadList/OptionSection/styles.js @@ -3,3 +3,35 @@ import styled from 'styled-components'; export const OptionSectionWrapper = styled.div` padding: 0 20px 20px 20px; `; + +export const FilterInputWrapper = styled.div` + margin-bottom: 10px; +`; + +export const FilterInput = styled.input` + width: 100%; + padding: 8px 12px; + border: 1px solid ${({ theme }) => theme.grayScale.gray5}; + border-radius: 4px; + background-color: ${({ theme }) => theme.background}; + color: ${({ theme }) => theme.text}; + font-size: 14px; + + &:focus { + outline: none; + border-color: ${({ theme }) => theme.primary}; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + +export const FilterLabel = styled.label` + display: block; + margin-bottom: 4px; + font-size: 12px; + color: ${({ theme }) => theme.grayScale.gray10}; + font-weight: 500; +`; diff --git a/src/devtoolApp/hooks/useAppSaveAllResource.js b/src/devtoolApp/hooks/useAppSaveAllResource.js index 28285e3..451839f 100644 --- a/src/devtoolApp/hooks/useAppSaveAllResource.js +++ b/src/devtoolApp/hooks/useAppSaveAllResource.js @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import * as uiActions from '../store/ui'; import { downloadZipFile, resolveDuplicatedResources } from '../utils/file'; import { logResourceByUrl } from '../utils/resource'; +import { globMatch } from '../utils/general'; import { resetNetworkResource } from '../store/networkResource'; import { resetStaticResource } from '../store/staticResource'; import { INITIAL_STATE as UI_INITIAL_STATE } from '../store/ui'; @@ -14,7 +15,7 @@ export const useAppSaveAllResource = () => { const staticResourceRef = useRef(staticResource); const { downloadList, - option: { ignoreNoContentFile, beautifyFile }, + option: { ignoreNoContentFile, beautifyFile, urlFilter }, ui: { tab }, } = state; @@ -54,16 +55,20 @@ export const useAppSaveAllResource = () => { ...(networkResourceRef.current || []), ...(staticResourceRef.current || []), ]); - console.log(toDownload.filter(t => typeof t?.content !== 'string' && !!t?.content?.then)); - if (loaded && toDownload.length) { + + // Apply URL filter if specified + const filteredToDownload = urlFilter ? toDownload.filter((resource) => globMatch(urlFilter, resource.url)) : toDownload; + + console.log(toDownload.filter((t) => typeof t?.content !== 'string' && !!t?.content?.then)); + if (loaded && filteredToDownload.length) { downloadZipFile( - toDownload, + filteredToDownload, { ignoreNoContentFile, beautifyFile }, (item, isDone) => { dispatch(uiActions.setStatus(`Compressed: ${item.url} Processed: ${isDone}`)); }, () => { - logResourceByUrl(dispatch, downloadItem.url, toDownload); + logResourceByUrl(dispatch, downloadItem.url, filteredToDownload); if (i + 1 !== downloadList.length) { dispatch(resetNetworkResource()); dispatch(resetStaticResource()); diff --git a/src/devtoolApp/store/option/index.js b/src/devtoolApp/store/option/index.js index ee7aff7..824d9a4 100644 --- a/src/devtoolApp/store/option/index.js +++ b/src/devtoolApp/store/option/index.js @@ -1,15 +1,19 @@ import { getReducerConfig } from '../utils'; +import { loadUrlFilterFromStorage, saveUrlFilterToStorage } from 'devtoolApp/utils/general'; export const STATE_KEY = `option`; export const ACTIONS = { SET_IGNORE_NO_CONTENT_FILE: 'SET_IGNORE_NO_CONTENT_FILE', SET_BEAUTIFY_FILE: 'SET_BEAUTIFY_FILE', + SET_URL_FILTER: 'SET_URL_FILTER', + LOAD_URL_FILTER_FROM_STORAGE: 'LOAD_URL_FILTER_FROM_STORAGE', }; export const INITIAL_STATE = { ignoreNoContentFile: false, beautifyFile: false, + urlFilter: loadUrlFilterFromStorage(), // Load from sessionStorage on initialization }; export const setIgnoreNoContentFile = (willIgnore) => ({ @@ -22,6 +26,22 @@ export const setBeautifyFile = (willBeautify) => ({ payload: !!willBeautify, }); +export const setUrlFilter = (filter) => { + const urlFilter = filter || ''; + // Save to sessionStorage when setting URL filter + saveUrlFilterToStorage(urlFilter); + + return { + type: ACTIONS.SET_URL_FILTER, + payload: urlFilter, + }; +}; + +export const loadUrlFilterFromStorageAction = () => ({ + type: ACTIONS.LOAD_URL_FILTER_FROM_STORAGE, + payload: loadUrlFilterFromStorage(), +}); + export const uiReducer = (state = INITIAL_STATE, action) => { switch (action.type) { case ACTIONS.SET_IGNORE_NO_CONTENT_FILE: { @@ -36,6 +56,13 @@ export const uiReducer = (state = INITIAL_STATE, action) => { beautifyFile: action.payload, }; } + case ACTIONS.SET_URL_FILTER: + case ACTIONS.LOAD_URL_FILTER_FROM_STORAGE: { + return { + ...state, + urlFilter: action.payload, + }; + } default: { return state; } diff --git a/src/devtoolApp/utils/file.js b/src/devtoolApp/utils/file.js index b19fb00..3aac8a4 100644 --- a/src/devtoolApp/utils/file.js +++ b/src/devtoolApp/utils/file.js @@ -73,7 +73,7 @@ export const getContentRead = (item) => { export const addItemsToZipWriter = (zipWriter, items, options, eachDoneCallback, callback) => { const item = items[0]; const rest = items.slice(1); - + // console.log(item); // if item exist so add it to zip if (item) { // Beautify here diff --git a/src/devtoolApp/utils/general.js b/src/devtoolApp/utils/general.js index 78f8f6b..eade701 100644 --- a/src/devtoolApp/utils/general.js +++ b/src/devtoolApp/utils/general.js @@ -25,3 +25,84 @@ export const logIfDev = (...props) => { console.log('[DEVTOOL]', ...props); } }; + +import { minimatch } from 'minimatch'; + +/** + * Glob pattern matching using minimatch library + * Supports * (wildcard), ** (recursive wildcard), and negation with ! + * Supports multiple patterns separated by | (pipe) + * @param {string} pattern - glob pattern(s), can be separated by | + * @param {string} str - string to match + * @returns {boolean} - whether the string matches the pattern + */ +export const globMatch = (pattern, str) => { + if (!pattern || pattern.trim() === '') return true; // Empty pattern matches everything + if (!str) return false; + + // Split pattern by | to support multiple patterns + const patterns = pattern.split('|').map(p => p.trim()).filter(p => p); + + // If no valid patterns after filtering, match everything + if (patterns.length === 0) return true; + + // Separate positive and negative patterns + const positivePatterns = []; + const negativePatterns = []; + + patterns.forEach(singlePattern => { + if (singlePattern.startsWith('!')) { + negativePatterns.push(singlePattern.slice(1)); // Remove the ! prefix + } else { + positivePatterns.push(singlePattern); + } + }); + + // If there are no positive patterns, default to match all + const hasPositiveMatch = positivePatterns.length === 0 || positivePatterns.some(pattern => { + try { + return minimatch(str, pattern, { nocase: true }); + } catch (e) { + console.warn('[DEVTOOL] Invalid glob pattern:', pattern, e); + return false; + } + }); + + // If positive patterns don't match, return false + if (!hasPositiveMatch) return false; + + // Check negative patterns - if any negative pattern matches, exclude this item + const hasNegativeMatch = negativePatterns.some(pattern => { + try { + return minimatch(str, pattern, { nocase: true }); + } catch (e) { + console.warn('[DEVTOOL] Invalid negative glob pattern:', pattern, e); + return false; + } + }); + + // Return true if positive matches and no negative matches + return !hasNegativeMatch; +}; + +/** + * Storage utilities for URL filter persistence + */ +const URL_FILTER_STORAGE_KEY = 'resources_saver_url_filter'; + +export const saveUrlFilterToStorage = (filter) => { + try { + sessionStorage.setItem(URL_FILTER_STORAGE_KEY, filter || ''); + } catch (e) { + console.warn('[DEVTOOL] Failed to save URL filter to sessionStorage:', e); + } +}; + +export const loadUrlFilterFromStorage = () => { + try { + return sessionStorage.getItem(URL_FILTER_STORAGE_KEY) || ''; + } catch (e) { + console.warn('[DEVTOOL] Failed to load URL filter from sessionStorage:', e); + return ''; + } +};