import { ReactElement, useState, useEffect } from 'react'; import { Menu, MenuItem, Modal, ModalDialog, ModalClose, DialogTitle, DialogContent, Tooltip, IconButton, Checkbox, Input, FormLabel, FormControl, Button, Stack, LinearProgress, Typography, Select, Option } from '@mui/joy'; import './PageRow.less'; import { InView } from 'react-intersection-observer'; import { activateOrOpenTab } from '../../../../chrome/chromeNavigation'; import { Page } from '../../../../db/pages'; import { useAppSelector, useAppDispatch } from '../../../../store/hooks'; import { selectSelectedUrls, toggleSelected, clearSelectionAndSelect, rangeSelected, constructPageRowId, selectSimilarUrls, } from '../../../../store/uiSlice'; import { selectTabsForPage, selectIsActiveTab, selectIsDuplicate, setSimilarUrlsAsync, searchSimilarAsync, updateWindowsAsync, } from '../../../../store/windowsSlice'; import { updateTodoCompletion, updateTodoTitle, updateTodoPriority, updateTodoDueDate, convertPageToTodo, removePageTodoStatus } from '../../../../db/pages'; import { getIgnoredUrls, setIgnoredUrls, getNormalizeRules, setNormalizeRules, getSimilarityScaler } from '../../../../db/prefs'; import { updateUrlNormalizationCache, getIgnoredSubstrings, getNormalizeUrlRegexes } from '../../../../db/urlNormalizationPrefs'; import { enqueueOperation } from '../../../../db/queue'; import { useLiveQuery } from 'dexie-react-hooks'; import React from 'react'; import PageTags from './PageTags'; function tsToDateInputValue(ts: number): string { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function dateInputValueToTs(s: string): number { const [y, mo, day] = s.split('-').map(Number); return new Date(y, mo - 1, day).getTime(); } import PageInfo from '../../../inspector/PageInfo'; import { logger } from '../../../../utils/logger'; import { faEquals, faListCheck } from '@fortawesome/free-solid-svg-icons'; import { faSquare, faSquareCheck } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; interface PageRowProps { page: Page; windowId?: number; tabId?: number; } const PageRow = React.memo( ({ page, windowId = undefined, tabId = undefined }: PageRowProps): ReactElement => { const tabs = useAppSelector((state) => selectTabsForPage(state, page.nurl)); const isOpen = tabs.length > 0; const isActive = useAppSelector((state) => selectIsActiveTab(state, windowId, tabId)); const focusedWindowId = useAppSelector((state) => state.windows.focusedWindowId); const isDuplicate = useAppSelector((state) => selectIsDuplicate(state, page.nurl)); const selectedUrls = useAppSelector(selectSelectedUrls); const similarUrls = useAppSelector(selectSimilarUrls); const similarityScaler = useLiveQuery(getSimilarityScaler) ?? 5; const similarity = Math.min(similarUrls[page.nurl.normalized] || 0, 1.0); const scaledSimilarity = Math.min(similarity * similarityScaler, 1.0); const dispatch = useAppDispatch(); const isFocused = focusedWindowId === windowId && isActive; const isSelected = selectedUrls.includes(page.nurl.normalized); const [contextMenu, setContextMenu] = useState<{ top: number; left: number; } | null>(null); const [debugOpen, setDebugOpen] = useState(false); const [automateOpen, setAutomateOpen] = useState(false); const [todoEditOpen, setTodoEditOpen] = useState(false); const [editTodoTitle, setEditTodoTitle] = useState(''); const [editTodoPriority, setEditTodoPriority] = useState(null); const [editTodoDueDate, setEditTodoDueDate] = useState(''); const [todoFieldsExpanded, setTodoFieldsExpanded] = useState(false); const [ignoreDomain, setIgnoreDomain] = useState(false); const [ignoreText, setIgnoreText] = useState(''); const [addNormalization, setAddNormalization] = useState(false); const [normalizationPattern, setNormalizationPattern] = useState(''); const [normalizationReplacement, setNormalizationReplacement] = useState('$1'); const [doRenormalize, setDoRenormalize] = useState(false); useEffect(() => { if (contextMenu) { const handleGlobalClick = () => setContextMenu(null); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setContextMenu(null); }; // Delay adding listeners to avoid catching the event that opened the menu const frameId = requestAnimationFrame(() => { window.addEventListener('click', handleGlobalClick); window.addEventListener('contextmenu', handleGlobalClick); window.addEventListener('keydown', handleKeyDown); }); return () => { cancelAnimationFrame(frameId); window.removeEventListener('click', handleGlobalClick); window.removeEventListener('contextmenu', handleGlobalClick); window.removeEventListener('keydown', handleKeyDown); }; } }, [contextMenu]); const handleContextMenu = (event: React.MouseEvent) => { event.preventDefault(); setContextMenu({ top: event.clientY, left: event.clientX, }); }; const handleMenuClose = () => { setContextMenu(null); }; const handleTodoEditOpen = () => { setEditTodoTitle(page.title ?? ''); setEditTodoPriority(page.priority ?? null); setEditTodoDueDate(page.dueDate ? tsToDateInputValue(page.dueDate) : ''); setTodoEditOpen(true); handleMenuClose(); }; const handleTodoEditSave = async () => { const title = editTodoTitle.trim(); if (title) { await updateTodoTitle(page.nurl.normalized, title).catch((err: unknown) => logger.error('[PageRow] updateTodoTitle failed', err)); } await updateTodoPriority(page.nurl.normalized, editTodoPriority ?? undefined).catch((err: unknown) => logger.error('[PageRow] updateTodoPriority failed', err)); const dueDate = editTodoDueDate ? dateInputValueToTs(editTodoDueDate) : undefined; await updateTodoDueDate(page.nurl.normalized, dueDate).catch((err: unknown) => logger.error('[PageRow] updateTodoDueDate failed', err)); setTodoEditOpen(false); }; const handleAutomateClick = () => { // Prepare defaults // Ignore Domain let domain = ''; try { const urlObj = new URL(page.nurl.original); domain = urlObj.hostname; } catch (e) { domain = ''; } setIgnoreText(domain); setIgnoreDomain(false); // Normalization // Use selected URLs if current page is selected, otherwise just current page const targets = isSelected && selectedUrls.length > 0 ? selectedUrls : [page.nurl.normalized]; // Find common prefix let prefix = targets[0]; for (let i = 1; i < targets.length; i++) { let j = 0; while (j < prefix.length && j < targets[i].length && prefix[j] === targets[i][j]) { j++; } prefix = prefix.substring(0, j); } if (prefix.length > 0) { // Escape special chars const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); setNormalizationPattern(`(${escaped}).*`); } else { setNormalizationPattern(''); } setNormalizationReplacement('$1'); setAddNormalization(false); // User must opt-in setDoRenormalize(false); setAutomateOpen(true); handleMenuClose(); }; const handleAutomateSave = async () => { if (ignoreDomain && ignoreText) { // Use current cache to ensure we keep defaults const ignored = getIgnoredSubstrings(); if (!ignored.includes(ignoreText)) { const newIgnored = [...ignored, ignoreText]; await setIgnoredUrls(newIgnored); // We also need to get rules to update cache fully const rules = getNormalizeUrlRegexes(); updateUrlNormalizationCache(newIgnored, rules); } } if (addNormalization && normalizationPattern) { const rules = getNormalizeUrlRegexes(); // Check for duplicate? Assumed user knows what they are doing. const newRules: [string, string][] = [...rules, [normalizationPattern, normalizationReplacement]]; await setNormalizeRules(newRules); // Update cache const ignored = getIgnoredSubstrings(); updateUrlNormalizationCache(ignored, newRules); if (doRenormalize) { await enqueueOperation('renormalize', {}, 10); dispatch(updateWindowsAsync()); } } setAutomateOpen(false); }; const id = constructPageRowId(page.nurl.normalized, windowId, tabId); const noicon =
; return ( {({ inView, ref, entry }) => inView ? (
{isSelected ? ( { event.stopPropagation(); dispatch(toggleSelected({ url: page.nurl, id: id })); dispatch(setSimilarUrlsAsync()); }} sx={{ '--IconButton-size': '20px', minWidth: 0, minHeight: 0, p: 0 }} > ) : ( { event.stopPropagation(); dispatch(toggleSelected({ url: page.nurl, id: id })); dispatch(setSimilarUrlsAsync()); }} sx={{ '--IconButton-size': '20px', minWidth: 0, minHeight: 0, p: 0 }} > )} {isDuplicate ? ( ) : ( noicon )} {page.completionStatus !== undefined && ( { updateTodoCompletion(page.nurl.normalized, e.target.checked ? 'completed' : 'pending') .catch(err => logger.error('[PageRow] updateTodoCompletion failed', err)); }} /> )} {page.completionStatus !== undefined && ( TODO )} {page.completionStatus !== undefined && page.priority != null && ( {page.priority === 1 ? 'P0' : page.priority === 2 ? 'P1' : 'P2'} )}
{ event.stopPropagation(); if (page.completionStatus !== undefined) { handleTodoEditOpen(); } else { logger.debug('[PageRow] double click', page.nurl.normalized, tabs[0]?.windowId, tabs[0]?.id); void activateOrOpenTab(page.nurl.normalized, tabs[0]?.windowId, tabs[0]?.id); } }} onClick={(event) => { if (event.altKey) { // pass } else if (event.ctrlKey) { dispatch(toggleSelected({ url: page.nurl, id: id })); dispatch(setSimilarUrlsAsync()); } else if (event.metaKey) { dispatch(toggleSelected({ url: page.nurl, id: id })); dispatch(setSimilarUrlsAsync()); } else if (event.shiftKey) { dispatch(rangeSelected({ url: page.nurl, id: id })); dispatch(setSimilarUrlsAsync()); } else { dispatch( clearSelectionAndSelect({ url: page.nurl, id: id, }) ); dispatch(setSimilarUrlsAsync()); } }} > {page.title}
{page.completionStatus === undefined && ( { e.stopPropagation(); convertPageToTodo(page.nurl.normalized) .catch(err => logger.error('[PageRow] convertPageToTodo failed', err)); }} sx={{ '--IconButton-size': '18px', minWidth: 0, minHeight: 0, p: 0 }} > )} {page.completionStatus !== undefined && ( todoFieldsExpanded ? ( e.stopPropagation()}> { const val = e.target.value; updateTodoDueDate(page.nurl.normalized, val ? dateInputValueToTs(val) : undefined) .catch(err => logger.error('[PageRow] updateTodoDueDate failed', err)); }} sx={{ width: 110, fontSize: '0.65rem', '--Input-minHeight': '18px', p: '0 4px' }} /> { e.stopPropagation(); setTodoFieldsExpanded(false); }} >▲ ) : ( {page.dueDate && ( {new Date(page.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} )} { e.stopPropagation(); setTodoFieldsExpanded(true); }} >▼ ) )} ({ top: contextMenu.top, left: contextMenu.left, right: contextMenu.left, bottom: contextMenu.top, width: 0, height: 0, toJSON: () => {}, x: contextMenu.left, y: contextMenu.top, }), } : null } > { dispatch(searchSimilarAsync(page.nurl)); handleMenuClose(); }} > Search Similar {page.completionStatus !== undefined && ( Edit Todo... )} {page.completionStatus !== undefined && ( { removePageTodoStatus(page.nurl.normalized) .catch(err => logger.error('[PageRow] removePageTodoStatus failed', err)); handleMenuClose(); }} > Remove Todo Status )} {page.completionStatus === undefined && ( { convertPageToTodo(page.nurl.normalized) .catch(err => logger.error('[PageRow] convertPageToTodo failed', err)); handleMenuClose(); }} > Mark as Todo )} { setDebugOpen(true); handleMenuClose(); }} > Debug Details {page.completionStatus === undefined && ( Automate... )} setAutomateOpen(false)}> Automate Actions setIgnoreDomain(e.target.checked)} /> setIgnoreText(e.target.value)} placeholder="Domain to ignore" /> { setAddNormalization(e.target.checked); if (e.target.checked) setDoRenormalize(true); }} /> Regex Pattern setNormalizationPattern(e.target.value)} /> Replacement setNormalizationReplacement(e.target.value)} /> setDoRenormalize(e.target.checked)} /> setDebugOpen(false)}> Debug Details { setTodoEditOpen(false); setEditTodoTitle(''); setEditTodoPriority(null); setEditTodoDueDate(''); }}> Edit Todo Title setEditTodoTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void handleTodoEditSave(); else if (e.key === 'Escape') { setTodoEditOpen(false); setEditTodoTitle(''); setEditTodoPriority(null); setEditTodoDueDate(''); } }} /> Priority Due Date setEditTodoDueDate(e.target.value)} />
) : (
) } ); } ); export default PageRow;