add even and odd buttons and include translations

This commit is contained in:
EthanHealy01 2025-09-16 17:15:09 +01:00
parent ff84fbf8b4
commit c27a23b20b
6 changed files with 128 additions and 32 deletions

View File

@ -1207,6 +1207,29 @@
"every3rd": "Every 3rd", "every3rd": "Every 3rd",
"oddWithinExcluding": "Odd within 1-20 excluding 5-7", "oddWithinExcluding": "Odd within 1-20 excluding 5-7",
"combineSets": "Combine sets" "combineSets": "Combine sets"
},
"firstNPages": {
"title": "First N Pages",
"placeholder": "Number of pages"
},
"lastNPages": {
"title": "Last N Pages",
"placeholder": "Number of pages"
},
"everyNthPage": {
"title": "Every Nth Page",
"placeholder": "Step size"
},
"range": {
"title": "Range",
"fromPlaceholder": "From",
"toPlaceholder": "To"
},
"keywords": {
"title": "Keywords"
},
"advanced": {
"title": "Advanced"
} }
}, },
"compressPdfs": { "compressPdfs": {

View File

@ -852,6 +852,29 @@
"every3rd": "Every 3rd", "every3rd": "Every 3rd",
"oddWithinExcluding": "Odd within 1-20 excluding 5-7", "oddWithinExcluding": "Odd within 1-20 excluding 5-7",
"combineSets": "Combine sets" "combineSets": "Combine sets"
},
"firstNPages": {
"title": "First N Pages",
"placeholder": "Number of pages"
},
"lastNPages": {
"title": "Last N Pages",
"placeholder": "Number of pages"
},
"everyNthPage": {
"title": "Every Nth Page",
"placeholder": "Step size"
},
"range": {
"title": "Range",
"fromPlaceholder": "From",
"toPlaceholder": "To"
},
"keywords": {
"title": "Keywords"
},
"advanced": {
"title": "Advanced"
} }
}, },
"compressPdfs": { "compressPdfs": {

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Flex } from '@mantine/core'; import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from './BulkSelectionPanel.module.css'; import classes from './BulkSelectionPanel.module.css';
import { import {
appendExpression, appendExpression,
@ -8,6 +9,7 @@ import {
lastNExpression, lastNExpression,
everyNthExpression, everyNthExpression,
rangeExpression, rangeExpression,
LogicalOperator,
} from './BulkSelection'; } from './BulkSelection';
import SelectPages from './SelectPages'; import SelectPages from './SelectPages';
import OperatorsSection from './OperatorsSection'; import OperatorsSection from './OperatorsSection';
@ -27,6 +29,7 @@ const AdvancedSelectionPanel = ({
maxPages, maxPages,
advancedOpened, advancedOpened,
}: AdvancedSelectionPanelProps) => { }: AdvancedSelectionPanelProps) => {
const { t } = useTranslation();
const [rangeEnd, setRangeEnd] = useState<number | ''>(''); const [rangeEnd, setRangeEnd] = useState<number | ''>('');
const handleRangeEndChange = (val: string | number) => { const handleRangeEndChange = (val: string | number) => {
@ -47,11 +50,6 @@ const AdvancedSelectionPanel = ({
return null; return null;
}; };
const validateRangeEnd = (end: number): string | null => {
if (end <= 0) return 'Values must be positive';
return null;
};
// Named callback functions // Named callback functions
const applyExpression = (expr: string) => { const applyExpression = (expr: string) => {
const nextInput = appendExpression(csvInput, expr); const nextInput = appendExpression(csvInput, expr);
@ -59,9 +57,13 @@ const AdvancedSelectionPanel = ({
onUpdatePagesFromCSV(nextInput); onUpdatePagesFromCSV(nextInput);
}; };
const insertOperator = (op: 'and' | 'or' | 'not') => { const insertOperator = (op: LogicalOperator) => {
const next = insertOperatorSmart(csvInput, op); const next = insertOperatorSmart(csvInput, op);
setCsvInput(next); setCsvInput(next);
// Trigger visual selection update for 'even' and 'odd' operators
if (op === 'even' || op === 'odd') {
onUpdatePagesFromCSV(next);
}
}; };
const handleFirstNApply = (value: number) => { const handleFirstNApply = (value: number) => {
@ -95,36 +97,36 @@ const AdvancedSelectionPanel = ({
{/* Cards row */} {/* Cards row */}
<Flex direction="row" mb="xs" wrap="wrap"> <Flex direction="row" mb="xs" wrap="wrap">
<SelectPages <SelectPages
title="First N Pages" title={t('bulkSelection.firstNPages.title', 'First N Pages')}
placeholder="Number of pages" placeholder={t('bulkSelection.firstNPages.placeholder', 'Number of pages')}
onApply={handleFirstNApply} onApply={handleFirstNApply}
maxPages={maxPages} maxPages={maxPages}
validationFn={validatePositiveNumber} validationFn={validatePositiveNumber}
/> />
<SelectPages <SelectPages
title="Range" title={t('bulkSelection.range.title', 'Range')}
placeholder="From" placeholder={t('bulkSelection.range.fromPlaceholder', 'From')}
onApply={handleRangeApply} onApply={handleRangeApply}
maxPages={maxPages} maxPages={maxPages}
validationFn={validateRangeStart} validationFn={validateRangeStart}
isRange={true} isRange={true}
rangeEndValue={rangeEnd} rangeEndValue={rangeEnd}
onRangeEndChange={handleRangeEndChange} onRangeEndChange={handleRangeEndChange}
rangeEndPlaceholder="To" rangeEndPlaceholder={t('bulkSelection.range.toPlaceholder', 'To')}
/> />
<SelectPages <SelectPages
title="Last N Pages" title={t('bulkSelection.lastNPages.title', 'Last N Pages')}
placeholder="Number of pages" placeholder={t('bulkSelection.lastNPages.placeholder', 'Number of pages')}
onApply={handleLastNApply} onApply={handleLastNApply}
maxPages={maxPages} maxPages={maxPages}
validationFn={validatePositiveNumber} validationFn={validatePositiveNumber}
/> />
<SelectPages <SelectPages
title="Every Nth Page" title={t('bulkSelection.everyNthPage.title', 'Every Nth Page')}
placeholder="Step size" placeholder={t('bulkSelection.everyNthPage.placeholder', 'Step size')}
onApply={handleEveryNthApply} onApply={handleEveryNthApply}
maxPages={maxPages} maxPages={maxPages}
/> />

View File

@ -1,6 +1,6 @@
// Pure helper utilities for the BulkSelectionPanel UI // Pure helper utilities for the BulkSelectionPanel UI
export type LogicalOperator = 'and' | 'or' | 'not'; export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';
// Returns a new CSV expression with expr appended. // Returns a new CSV expression with expr appended.
// If current ends with an operator token, expr is appended directly. // If current ends with an operator token, expr is appended directly.
@ -9,7 +9,12 @@ export function appendExpression(currentInput: string, expr: string): string {
const current = (currentInput || '').trim(); const current = (currentInput || '').trim();
if (!current) return expr; if (!current) return expr;
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current); const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
return endsWithOperator ? `${current}${expr}` : `${current} or ${expr}`; // Add space if operator doesn't already have one
if (endsWithOperator) {
const needsSpace = !current.endsWith(' ');
return `${current}${needsSpace ? ' ' : ''}${expr}`;
}
return `${current} or ${expr}`;
} }
// Smartly inserts/normalizes a logical operator at the end of the current input. // Smartly inserts/normalizes a logical operator at the end of the current input.
@ -18,6 +23,18 @@ export function insertOperatorSmart(currentInput: string, op: LogicalOperator):
const text = (currentInput || '').trim(); const text = (currentInput || '').trim();
if (text.length === 0) return `${op} `; if (text.length === 0) return `${op} `;
// Handle 'even' and 'odd' as page selection expressions, not logical operators
if (op === 'even' || op === 'odd') {
// If current input ends with a logical operator, append the page selection with proper spacing
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
if (endsWithOperator) {
// Add space if the operator doesn't already have one
const needsSpace = !text.endsWith(' ');
return `${text}${needsSpace ? ' ' : ''}${op} `;
}
return `${text} or ${op} `;
}
// Extract up to the last two operator tokens (words or symbols) from the end // Extract up to the last two operator tokens (words or symbols) from the end
const tokens: string[] = []; const tokens: string[] = [];
let rest = text; let rest = text;
@ -39,19 +56,19 @@ export function insertOperatorSmart(currentInput: string, op: LogicalOperator):
// Normalize to allowed set // Normalize to allowed set
const phrase = tokens.join(' '); const phrase = tokens.join(' ');
const allowed = new Set(['and', 'or', 'not', 'and or', 'and not']); const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']);
// Helpers for transitions from a single trailing token // Helpers for transitions from a single trailing token
const fromSingle = (t: string): string => { const fromSingle = (t: string): string => {
if (t === 'and') { if (t === 'and') {
if (click === 'and') return 'and'; if (click === 'and') return 'and';
if (click === 'or') return 'and or'; if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or'
return 'and not'; return 'and not';
} }
if (t === 'or') { if (t === 'or') {
if (click === 'and') return 'and'; if (click === 'and') return 'and';
if (click === 'or') return 'or'; if (click === 'or') return 'or';
return 'not'; return 'or not';
} }
// t === 'not' // t === 'not'
if (click === 'and') return 'and'; if (click === 'and') return 'and';
@ -61,17 +78,19 @@ export function insertOperatorSmart(currentInput: string, op: LogicalOperator):
// From combined phrase // From combined phrase
const fromCombo = (p: string): string => { const fromCombo = (p: string): string => {
if (p === 'and or') {
if (click === 'or') return 'and or';
if (click === 'and') return 'and';
return 'and not';
}
if (p === 'and not') { if (p === 'and not') {
if (click === 'not') return 'and not'; if (click === 'not') return 'and not';
if (click === 'and') return 'and'; if (click === 'and') return 'and';
return 'and or'; if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or'
return 'and not';
} }
// Invalid combos (e.g., 'not and', 'not or', 'or and') → collapse to clicked op if (p === 'or not') {
if (click === 'not') return 'or not';
if (click === 'or') return 'or';
if (click === 'and') return 'and'; // 'or not and' is invalid, so just use 'and'
return 'or not';
}
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
return click; return click;
}; };

View File

@ -1,15 +1,19 @@
import { Button, Text, Group } from '@mantine/core'; import { Button, Text, Group, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from './BulkSelectionPanel.module.css'; import classes from './BulkSelectionPanel.module.css';
import { LogicalOperator } from './BulkSelection';
interface OperatorsSectionProps { interface OperatorsSectionProps {
csvInput: string; csvInput: string;
onInsertOperator: (op: 'and' | 'or' | 'not') => void; onInsertOperator: (op: LogicalOperator) => void;
} }
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => { const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
const { t } = useTranslation();
return ( return (
<div> <div>
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">Add Operators:</Text> <Text size="xs" c="var(--text-muted)" fw={500} mb="xs">{t('bulkSelection.keywords.title', 'Keywords')}:</Text>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Button <Button
size="sm" size="sm"
@ -42,6 +46,29 @@ const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps)
<Text size="xs" fw={500}>not</Text> <Text size="xs" fw={500}>not</Text>
</Button> </Button>
</Group> </Group>
<Divider my="sm" />
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('even')}
disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)"
>
<Text size="xs" fw={500}>even</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('odd')}
disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)"
>
<Text size="xs" fw={500}>odd</Text>
</Button>
</Group>
</div> </div>
); );
}; };

View File

@ -1,4 +1,5 @@
import { TextInput, Button, Text, Flex, Switch } from '@mantine/core'; import { TextInput, Button, Text, Flex, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '../../shared/LocalIcon'; import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { usePageSelectionTips } from '../../tooltips/usePageSelectionTips'; import { usePageSelectionTips } from '../../tooltips/usePageSelectionTips';
@ -21,6 +22,7 @@ const PageSelectionInput = ({
advancedOpened, advancedOpened,
onToggleAdvanced, onToggleAdvanced,
}: PageSelectionInputProps) => { }: PageSelectionInputProps) => {
const { t } = useTranslation();
const pageSelectionTips = usePageSelectionTips(); const pageSelectionTips = usePageSelectionTips();
return ( return (
@ -43,12 +45,12 @@ const PageSelectionInput = ({
</Tooltip> </Tooltip>
{typeof advancedOpened === 'boolean' && ( {typeof advancedOpened === 'boolean' && (
<Flex align="center" gap="xs"> <Flex align="center" gap="xs">
<Text size="sm" c="var(--text-secondary)">Advanced</Text> <Text size="sm" c="var(--text-secondary)">{t('bulkSelection.advanced.title', 'Advanced')}</Text>
<Switch <Switch
size="sm" size="sm"
checked={!!advancedOpened} checked={!!advancedOpened}
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)} onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
title="Advanced" title={t('bulkSelection.advanced.title', 'Advanced')}
className={classes.advancedSwitch} className={classes.advancedSwitch}
/> />
</Flex> </Flex>