diff --git a/map/src/assets/icons/ic_action_folder_smart.svg b/map/src/assets/icons/ic_action_folder_smart.svg new file mode 100644 index 000000000..2cd56a55f --- /dev/null +++ b/map/src/assets/icons/ic_action_folder_smart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index bb684acb7..a62c5da87 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -3,6 +3,8 @@ import LoginContext from '../context/LoginContext'; import Utils, { seleniumUpdateActivity, useMutator } from '../util/Utils'; import TracksManager, { getGpxFiles, + filterSmartFolders, + filterRegularFolders, GPX_FILE_EXT, preparedGpxFile, TRACK_VISIBLE_FLAG, @@ -32,6 +34,7 @@ import { SEARCH_RESULTS_KEY, TRACKS_KEY, } from '../util/hooks/menu/useRecentDataSaver'; +import { SMART_TYPE } from '../menu/share/shareConstants'; export const OBJECT_TYPE_LOCAL_TRACK = 'local_track'; // track in localStorage export const OBJECT_TYPE_CLOUD_TRACK = 'cloud_track'; // track in OsmAnd Cloud @@ -95,6 +98,8 @@ async function loadListFiles( setProcessingGroups, setVisibleTracks, setShareWithMeFiles, + setTracksGroups, + setSmartFoldersCache, setUpdateFiles ) { if (loginUser !== listFiles.loginUser) { @@ -113,6 +118,7 @@ async function loadListFiles( }); getFilesForUpdateDetails(res.uniqueFiles, setUpdateFiles); setListFiles(res); + loadSmartFolders(setTracksGroups, setSmartFoldersCache); const favFiles = await loadShareFiles(setShareWithMeFiles); const ownFavorites = TracksManager.getFavoriteGroups(res); const allFavorites = [...ownFavorites, ...favFiles]; @@ -127,6 +133,57 @@ async function loadListFiles( } } +export async function loadSmartFolders(setTracksGroups, setSmartFoldersCache) { + const res = await getSmartFolders(); + setSmartFoldersCache({}); + const smartFolderGroups = (res ?? []).map((smartFolder) => { + return { + name: smartFolder.name, + fullName: smartFolder.name, + type: SMART_TYPE, + subfolders: [], + groupFiles: [], + files: [], + realSize: smartFolder.userFilePaths?.length ?? 0, + lastModifiedMs: smartFolder.creationTime, + lastModifiedDate: null, + userFilePaths: smartFolder.userFilePaths ?? [], + }; + }); + + setTracksGroups((prev) => { + const regularFolders = filterRegularFolders(prev); + return [...regularFolders, ...smartFolderGroups]; + }); +} + +export function populateSmartFolderFiles(smartFolder, listFiles, smartFoldersCache, setSmartFoldersCache) { + const filesArray = []; + const cached = smartFoldersCache?.[smartFolder.name]; + if (cached) { + return cached; + } + (smartFolder.userFilePaths ?? []).forEach((path) => { + const file = listFiles?.find((f) => f.name === path); + if (file) { + filesArray.push({ ...file, smartFolder: true }); + } + }); + const populated = { + ...smartFolder, + groupFiles: filesArray, + files: filesArray, + realSize: filesArray.length, + }; + setSmartFoldersCache((prev) => ({ ...prev, [smartFolder.name]: populated })); + return populated; +} + +async function getSmartFolders() { + const response = await apiGet(`${process.env.REACT_APP_USER_API_SITE}/mapapi/create-smart-folders`, {}); + return response?.data || null; +} + export function getFilesForUpdateDetails(files, setUpdateFiles) { const filesToUpdate = files .filter((f) => f.details && f.details.update && f.type === GPX && f.name.toLowerCase().endsWith(GPX_FILE_EXT)) @@ -363,6 +420,8 @@ export const AppContextProvider = (props) => { const [shareFilesCache, setShareFilesCache] = useState({}); const [shareWithMeFiles, setShareWithMeFiles] = useState(null); const [fitBoundsShareTracks, setFitBoundsShareTracks] = useState(null); + + const [smartFoldersCache, setSmartFoldersCache] = useState(null); // selected track const [selectedGpxFile, setSelectedGpxFile] = useState({}); const [unverifiedGpxFile, setUnverifiedGpxFile] = useState(null); // see Effect in LocalClientTrackLayer @@ -653,6 +712,7 @@ export const AppContextProvider = (props) => { uniqueFiles: updatedUniqueFiles, }; }); + await loadSmartFolders(setTracksGroups, setSmartFoldersCache); } } }; @@ -676,6 +736,8 @@ export const AppContextProvider = (props) => { setProcessingGroups, setVisibleTracks, setShareWithMeFiles, + setTracksGroups, + setSmartFoldersCache, setUpdateFiles ).then(() => { setGpxLoading(false); @@ -876,6 +938,8 @@ export const AppContextProvider = (props) => { setShareWithMeFiles, fitBoundsShareTracks, setFitBoundsShareTracks, + smartFoldersCache, + setSmartFoldersCache, trackAnalyzer, setTrackAnalyzer, excludedSegments, diff --git a/map/src/frame/GlobalFrame.js b/map/src/frame/GlobalFrame.js index 939a38538..a01899752 100644 --- a/map/src/frame/GlobalFrame.js +++ b/map/src/frame/GlobalFrame.js @@ -26,7 +26,7 @@ import dialogStyles from '../dialogs/dialog.module.css'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import isEmpty from 'lodash-es/isEmpty'; -import { createTrackGroups, getGpxFiles, TRACK_VISIBLE_FLAG } from '../manager/track/TracksManager'; +import { createTrackGroups, getGpxFiles, filterSmartFolders, TRACK_VISIBLE_FLAG } from '../manager/track/TracksManager'; import { addCloseTracksToRecently, VISIBLE_SHARE_MARKER } from '../menu/visibletracks/VisibleTracks'; import PhotosModal from '../menu/search/explore/PhotosModal'; import InstallBanner from './components/InstallBanner'; @@ -35,6 +35,7 @@ import GlobalGraph from '../graph/mapGraph/GlobalGraph'; import LoginContext from '../context/LoginContext'; import { poiUrlParams } from '../manager/PoiManager'; import { createUrlParams } from '../util/Utils'; +import { SMART_TYPE } from '../menu/share/shareConstants'; const ENCODED_COMMA = '%2C'; const ENCODED_COLON = '%3A'; @@ -367,7 +368,8 @@ const GlobalFrame = () => { const trackGroups = createTrackGroups({ files, ctx }); ctx.setTracksGroups(trackGroups); } else { - ctx.setTracksGroups([]); + const smartFolders = filterSmartFolders(ctx.tracksGroups); + ctx.setTracksGroups(smartFolders); } }, [ctx.listFiles, ctx.selectedSort]); diff --git a/map/src/manager/track/DeleteTrackManager.js b/map/src/manager/track/DeleteTrackManager.js index bb516a014..036533c0e 100644 --- a/map/src/manager/track/DeleteTrackManager.js +++ b/map/src/manager/track/DeleteTrackManager.js @@ -1,4 +1,10 @@ -import { isCloudTrack, isLocalTrack, loadShareFiles, OBJECT_TYPE_FAVORITE } from '../../context/AppContext'; +import { + isCloudTrack, + isLocalTrack, + loadShareFiles, + OBJECT_TYPE_FAVORITE, + loadSmartFolders, +} from '../../context/AppContext'; import { apiGet, apiPost } from '../../util/HttpApi'; import { findGroupByName, getAllVisibleFiles } from './TracksManager'; import { refreshGlobalFiles } from './SaveTrackManager'; @@ -79,6 +85,7 @@ async function deleteCloudFile(name, type, ctx) { } return { ...o }; }); + await loadSmartFolders(ctx.setTracksGroups, ctx.setSmartFoldersCache); } } } diff --git a/map/src/manager/track/SaveTrackManager.js b/map/src/manager/track/SaveTrackManager.js index 9ead3eb84..9af0296fd 100644 --- a/map/src/manager/track/SaveTrackManager.js +++ b/map/src/manager/track/SaveTrackManager.js @@ -6,6 +6,7 @@ import TracksManager, { isTrackExists, isEmptyTrack, getGpxFiles, + filterSmartFolders, DEFAULT_GROUP_NAME, GPX_FILE_TYPE, getGpxFileFromTrackData, @@ -26,6 +27,7 @@ import { import Utils from '../../util/Utils'; import { updateSortList } from '../../menu/actions/SortActions'; import { deleteLocalTrack, saveTrackToLocalStorage } from '../../context/LocalTrackStorage'; +import { SMART_TYPE } from '../../menu/share/shareConstants'; export function saveTrackToLocal({ ctx, track, selected = true, overwrite = false, cloudAutoSave = false } = {}) { const newLocalTracks = [...ctx.localTracks]; @@ -406,7 +408,8 @@ function updateTrackGroups(listFiles, ctx) { const trackGroups = createTrackGroups({ files, ctx }); ctx.setTracksGroups(trackGroups); } else { - ctx.setTracksGroups([]); + const smartFolders = filterSmartFolders(ctx.tracksGroups); + ctx.setTracksGroups(smartFolders); } } diff --git a/map/src/manager/track/TracksManager.js b/map/src/manager/track/TracksManager.js index 626d31aa5..d8c753da8 100644 --- a/map/src/manager/track/TracksManager.js +++ b/map/src/manager/track/TracksManager.js @@ -20,7 +20,7 @@ import anchorme from 'anchorme'; import { isVisibleTrack, updateVisibleCache } from '../../menu/visibletracks/VisibleTracks'; import { getFileStorage, GPX } from '../GlobalManager'; import { closeTrack } from './DeleteTrackManager'; -import { SHARE_TYPE } from '../../menu/share/shareConstants'; +import { SHARE_TYPE, SMART_TYPE } from '../../menu/share/shareConstants'; import { doSort } from '../../menu/actions/SortActions'; import { DEFAULT_SORT_METHOD } from '../../menu/tracks/TracksMenu'; import { TRACKS_KEY } from '../../util/hooks/menu/useRecentDataSaver'; @@ -56,6 +56,14 @@ export function fitBoundsOptions(ctx) { }; } +export function filterSmartFolders(tracksGroups) { + return tracksGroups?.filter((g) => g.type === SMART_TYPE) || []; +} + +export function filterRegularFolders(tracksGroups) { + return tracksGroups?.filter((g) => g.type !== SMART_TYPE) || []; +} + export function prepareLocalTrack(track) { const prepareTrack = cloneDeep(track); return { @@ -586,7 +594,7 @@ export function createTrackGroups({ files, isSmartf = false, ctx }) { subfolders: [], groupFiles: [], lastModifiedMs: null, - lastModifiedData: null, + lastModifiedDate: null, }; currentGroups.push(existingGroup); } @@ -605,12 +613,15 @@ export function createTrackGroups({ files, isSmartf = false, ctx }) { files: tracks, groupFiles: tracks, lastModifiedMs: null, - lastModifiedData: null, + lastModifiedDate: null, }; defaultGroup.subfolders = trackGroups.filter((group) => group.name !== DEFAULT_GROUP_NAME); trackGroups.push(defaultGroup); } + const smartFolders = filterSmartFolders(ctx.tracksGroups); + trackGroups.push(...smartFolders); + addFilesAndCalculateLastModified(trackGroups); const sorted = doSort({ @@ -645,30 +656,41 @@ function addFilesAndCalculateLastModified(groups) { group.files.push(file); } }); - const directTracksCount = (group.groupFiles || []).filter((file) => !isPlaceholderFile(file)).length; - const subfoldersTracksCount = group.subfolders.reduce((acc, subfolder) => acc + (subfolder.realSize ?? 0), 0); - group.realSize = directTracksCount + subfoldersTracksCount; + + if (group.type === SMART_TYPE && group.userFilePaths && group.groupFiles.length === 0) { + group.realSize = group.userFilePaths.length; + } else { + const directTracksCount = (group.groupFiles || []).filter((file) => !isPlaceholderFile(file)).length; + const subfoldersTracksCount = group.subfolders.reduce( + (acc, subfolder) => acc + (subfolder.realSize ?? 0), + 0 + ); + group.realSize = directTracksCount + subfoldersTracksCount; + } calculateLastModified(group); }); } function calculateLastModified(group) { + if (group.type === SMART_TYPE) { + return; + } if (!group.files || group.files.length === 0) { group.lastModifiedMs = null; - group.lastModifiedData = null; + group.lastModifiedDate = null; return; } - let minMs = Infinity; - let minData = null; + let maxMs = -Infinity; + let maxDate = null; for (const file of group.files) { - if (file.updatetimems < minMs) { - minMs = file.updatetimems; - minData = file.updatetime; + if (file.updatetimems > maxMs) { + maxMs = file.updatetimems; + maxDate = file.updatetime; } } - group.lastModifiedMs = minMs; - group.lastModifiedData = minData; + group.lastModifiedMs = maxMs; + group.lastModifiedDate = maxDate; } export function findGroupByName(groups, groupName) { diff --git a/map/src/menu/actions/GroupActions.jsx b/map/src/menu/actions/GroupActions.jsx index 2a2ee9cd2..3d417c11a 100644 --- a/map/src/menu/actions/GroupActions.jsx +++ b/map/src/menu/actions/GroupActions.jsx @@ -10,6 +10,8 @@ import DeleteFolderDialog from '../../dialogs/tracks/DeleteFolderDialog'; import { apiPost } from '../../util/HttpApi'; import AppContext from '../../context/AppContext'; import { useTranslation } from 'react-i18next'; +import { SMART_TYPE } from '../share/shareConstants'; +import { populateSmartFolderFiles } from '../../context/AppContext'; const GroupActions = forwardRef(({ group, setOpenActions, setProcessDownload }, ref) => { const ctx = useContext(AppContext); @@ -19,6 +21,19 @@ const GroupActions = forwardRef(({ group, setOpenActions, setProcessDownload }, const [openDeleteDialog, setOpenDeleteDialog] = useState(false); const { t } = useTranslation(); + const getGroupFiles = () => { + if (group.type === SMART_TYPE && group.files?.length === 0) { + const populated = populateSmartFolderFiles( + group, + ctx.listFiles?.uniqueFiles, + ctx.smartFoldersCache, + ctx.setSmartFoldersCache + ); + return populated.files; + } + return group.files; + }; + async function downloadFolderBackup() { setProcessDownload(true); const res = await apiPost(`${process.env.REACT_APP_USER_API_SITE}/mapapi/download-backup-folder`, [], { @@ -53,28 +68,30 @@ const GroupActions = forwardRef(({ group, setOpenActions, setProcessDownload }, <> + {group.type !== SMART_TYPE && ( + { + downloadFolderBackup().then(); + setOpenActions(false); + }} + > + + + + + + {t('web:download_as_osf')} + + + + )} { - downloadFolderBackup().then(); - setOpenActions(false); - }} - > - - - - - - {t('web:download_as_osf')} - - - - { - setNewCollection(group.files); + setNewCollection(getGroupFiles); setOpenActions(false); }} > @@ -87,36 +104,40 @@ const GroupActions = forwardRef(({ group, setOpenActions, setProcessDownload }, - - setOpenRenameDialog(true)} - > - - - - - - {t('shared_string_rename')} - - - - - setOpenDeleteDialog(true)} - > - - - - - - {t('shared_string_delete')} - - - + {group.type !== SMART_TYPE && ( + <> + + setOpenRenameDialog(true)} + > + + + + + + {t('shared_string_rename')} + + + + + setOpenDeleteDialog(true)} + > + + + + + + {t('shared_string_delete')} + + + + + )} {newCollection.length > 0 && ( diff --git a/map/src/menu/actions/SortActions.jsx b/map/src/menu/actions/SortActions.jsx index be1399431..8921d0247 100644 --- a/map/src/menu/actions/SortActions.jsx +++ b/map/src/menu/actions/SortActions.jsx @@ -1,5 +1,5 @@ import { Box, Divider, FormControl, FormControlLabel, Paper, Radio, RadioGroup } from '@mui/material'; -import { getAnalysisData, getGpxTime } from '../../manager/track/TracksManager'; +import { getAnalysisData, getGpxTime, filterSmartFolders } from '../../manager/track/TracksManager'; import React, { forwardRef, useContext, useEffect, useState } from 'react'; import { ReactComponent as AscendingIcon } from '../../assets/icons/ic_action_sort_by_name_ascending.svg'; import { ReactComponent as TimeIcon } from '../../assets/icons/ic_action_time.svg'; @@ -17,7 +17,8 @@ import FavoritesManager, { DEFAULT_FAV_GROUP_NAME } from '../../manager/Favorite import i18n from '../../i18n'; import ActionItem from '../components/ActionItem'; import { getSelectedSort } from '../components/buttons/SortFilesButton'; -import { SHARE_TYPE } from '../share/shareConstants'; +import { SHARE_TYPE, SMART_TYPE } from '../share/shareConstants'; +import { DEFAULT_GROUP_NAME } from '../../manager/track/TracksManager'; const az = (a, b) => (a > b) - (a < b); @@ -254,6 +255,10 @@ const SortActions = forwardRef( const groups = () => { if (trackGroup) { + if (trackGroup.name === DEFAULT_GROUP_NAME || trackGroup.fullName === DEFAULT_GROUP_NAME) { + const smartFolders = filterSmartFolders(ctx.tracksGroups); + return [...(trackGroup.subfolders || []), ...smartFolders]; + } return trackGroup.subfolders; } else if (favoriteGroup) { if (smartf?.type === SHARE_TYPE) { diff --git a/map/src/menu/share/shareConstants.js b/map/src/menu/share/shareConstants.js index 7d0313530..1ae255131 100644 --- a/map/src/menu/share/shareConstants.js +++ b/map/src/menu/share/shareConstants.js @@ -1,4 +1,5 @@ export const SHARE_TYPE = 'share'; +export const SMART_TYPE = 'smart'; export const SHARE_FILE_TYPE = 'shared_with_me'; export const REQUEST_ACCESS_TYPE = 'request'; export const APPROVED_ACCESS_TYPE = 'approved'; diff --git a/map/src/menu/tracks/CloudTrackGroup.jsx b/map/src/menu/tracks/CloudTrackGroup.jsx index eb4a44016..e717a7251 100644 --- a/map/src/menu/tracks/CloudTrackGroup.jsx +++ b/map/src/menu/tracks/CloudTrackGroup.jsx @@ -2,6 +2,7 @@ import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material' import React, { useContext, useEffect, useRef, useState } from 'react'; import AppContext from '../../context/AppContext'; import { ReactComponent as FolderIcon } from '../../assets/icons/ic_action_folder.svg'; +import { ReactComponent as SmartIcon } from '../../assets/icons/ic_action_folder_smart.svg'; import styles from '../trackfavmenu.module.css'; import GroupActions from '../actions/GroupActions'; import ActionsMenu from '../actions/ActionsMenu'; @@ -10,6 +11,8 @@ import { useTranslation } from 'react-i18next'; import DividerWithMargin from '../../frame/components/dividers/DividerWithMargin'; import ThreeDotsButton from '../../frame/components/btns/ThreeDotsButton'; import { fmt } from '../../util/dateFmt'; +import { SMART_TYPE } from '../share/shareConstants'; +import { populateSmartFolderFiles } from '../../context/AppContext'; export default function CloudTrackGroup({ index, group }) { const ctx = useContext(AppContext); @@ -25,25 +28,51 @@ export default function CloudTrackGroup({ index, group }) { } }, [ctx.openedPopper]); + const getFolderIcon = () => { + if (group.type === SMART_TYPE) { + return ; + } + return ; + }; + + const handleClick = (e) => { + if (e.target !== 'path') { + let groupToOpen = group; + + if (group.type === SMART_TYPE) { + groupToOpen = populateSmartFolderFiles( + group, + ctx.listFiles?.uniqueFiles, + ctx.smartFoldersCache, + ctx.setSmartFoldersCache + ); + } + + ctx.setOpenGroups((prevState) => [...prevState, groupToOpen]); + } + }; + + const getInfoText = () => { + if (group.type === SMART_TYPE) { + const filesCount = group.userFilePaths?.length ?? 0; + return `${filesCount} ${t('shared_string_gpx_files').toLowerCase()}`; + } + return `${fmt.monthShortDay(group.lastModifiedDate)}, ${t('shared_string_gpx_files').toLowerCase()} ${group.realSize}`; + }; + return ( <> { - if (e.target !== 'path') { - ctx.setOpenGroups((prevState) => [...prevState, group]); - } - }} + onClick={handleClick} > - - - + {getFolderIcon()} - {`${fmt.monthShortDay(group.lastModifiedData)}, ${t('shared_string_gpx_files').toLowerCase()} ${group.realSize}`} + {getInfoText()} { if (ctx.tracksGroups && folder) { let found = findGroupByName(ctx.tracksGroups, group.fullName); - if (ctx.openGroups && ctx.openGroups.length > 0) { - const updatedOpenGroups = [...ctx.openGroups]; - updatedOpenGroups[updatedOpenGroups.length - 1] = found; - ctx.setOpenGroups(updatedOpenGroups); - setGroup({ ...found }); + if (found) { + if (found.type === SMART_TYPE) { + found = populateSmartFolderFiles( + found, + ctx.listFiles?.uniqueFiles, + ctx.smartFoldersCache, + ctx.setSmartFoldersCache + ); + } + if (ctx.openGroups?.length > 0) { + const updatedOpenGroups = [...ctx.openGroups]; + updatedOpenGroups[updatedOpenGroups.length - 1] = found; + ctx.setOpenGroups(updatedOpenGroups); + setGroup({ ...found }); + } } } }, [ctx.tracksGroups]); diff --git a/map/src/menu/tracks/TracksMenu.jsx b/map/src/menu/tracks/TracksMenu.jsx index 151212ad8..e0289344e 100644 --- a/map/src/menu/tracks/TracksMenu.jsx +++ b/map/src/menu/tracks/TracksMenu.jsx @@ -17,7 +17,7 @@ import VisibleTracks, { getCountVisibleTracks } from '../visibletracks/VisibleTr import { useTranslation } from 'react-i18next'; import SmartFolder from '../components/SmartFolder'; import LoginContext from '../../context/LoginContext'; -import { SHARE_TYPE } from '../share/shareConstants'; +import { SHARE_TYPE, SMART_TYPE } from '../share/shareConstants'; import TrackGroupFolder from './TrackGroupFolder'; import { MAIN_URL_WITH_SLASH, MENU_IDS, VISIBLE_TRACKS_URL } from '../../manager/GlobalManager'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -158,10 +158,10 @@ export default function TracksMenu() { {!isEmpty(ctx.shareWithMeFiles?.tracks) && ( - + )} {ctx.tracksGroups && - (sortGroups && sortGroups.length > 0 ? sortGroups : ctx.tracksGroups) + (sortGroups?.length > 0 ? sortGroups : ctx.tracksGroups) .filter((g) => g.name !== DEFAULT_GROUP_NAME) .map((group, index) => { return ;