Feature/v2/extract pages (#4828)

# Description of Changes

- Add the extract pages tool
- Componentize our bulk selection logic and warning messaages

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
EthanHealy01
2025-11-06 13:57:31 +00:00
committed by GitHub
parent 00fb40fb74
commit 138949caa7
14 changed files with 325 additions and 26 deletions
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint';
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
interface BulkSelectionPanelProps {
@@ -20,26 +20,9 @@ const BulkSelectionPanel = ({
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const maxPages = displayDocument?.pages?.length ?? 0;
// Validate input syntax and show lightweight feedback
useEffect(() => {
const text = (csvInput || '').trim();
if (!text) {
setSyntaxError(null);
return;
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
} catch {
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
}
}, [csvInput, maxPages]);
const handleClear = () => {
setCsvInput('');
onUpdatePagesFromCSV('');
@@ -56,10 +39,12 @@ const BulkSelectionPanel = ({
onToggleAdvanced={setAdvancedOpened}
/>
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={syntaxError}
syntaxError={null}
/>
<AdvancedSelectionPanel
@@ -10,7 +10,7 @@ import {
everyNthExpression,
rangeExpression,
LogicalOperator,
} from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
} from '@app/utils/bulkselection/selectionBuilders';
import SelectPages from '@app/components/pageEditor/bulkSelectionPanel/SelectPages';
import OperatorsSection from '@app/components/pageEditor/bulkSelectionPanel/OperatorsSection';
@@ -1,136 +0,0 @@
// Pure helper utilities for the BulkSelectionPanel UI
export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';
// Returns a new CSV expression with expr appended.
// If current ends with an operator token, expr is appended directly.
// Otherwise, it is joined with " or ".
export function appendExpression(currentInput: string, expr: string): string {
const current = (currentInput || '').trim();
if (!current) return expr;
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
// 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.
// Produces a trailing space to allow the next token to be typed naturally.
export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string {
const text = (currentInput || '').trim();
// Handle 'even' and 'odd' as page selection expressions, not logical operators
if (op === 'even' || op === 'odd') {
if (text.length === 0) return `${op} `;
// 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} `;
}
if (text.length === 0) return `${op} `;
// Extract up to the last two operator tokens (words or symbols) from the end
const tokens: string[] = [];
let rest = text;
for (let i = 0; i < 2; i++) {
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
if (!m || m.index === undefined) break;
const raw = m[1].toLowerCase();
const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw;
tokens.unshift(word);
rest = rest.slice(0, m.index).trimEnd();
}
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
const click = op; // desired operator
if (tokens.length === 0) {
return emit(text, click);
}
// Normalize to allowed set
const phrase = tokens.join(' ');
const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']);
// Helpers for transitions from a single trailing token
const fromSingle = (t: string): string => {
if (t === 'and') {
if (click === 'and') return 'and';
if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or'
return 'and not';
}
if (t === 'or') {
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'or not';
}
// t === 'not'
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'not';
};
// From combined phrase
const fromCombo = (p: string): string => {
if (p === 'and not') {
if (click === 'not') return 'and not';
if (click === 'and') return 'and';
if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or'
return 'and not';
}
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;
};
const base = rest.trim();
const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
if (!allowed.has(nextPhrase)) {
return emit(base, click);
}
return emit(base, nextPhrase);
}
// Expression builders for Advanced actions
export function firstNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
return `1-${end}`;
}
export function lastNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const count = Math.max(1, Math.floor(n));
const start = Math.max(1, maxPages - count + 1);
if (maxPages <= 0) return null;
return `${start}-${maxPages}`;
}
export function everyNthExpression(n: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
return `${Math.max(1, Math.floor(n))}n`;
}
export function rangeExpression(start: number, end: number, maxPages: number): string | null {
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
let s = Math.floor(start);
let e = Math.floor(end);
if (s > e) [s, e] = [e, s];
s = Math.max(1, s);
e = maxPages > 0 ? Math.min(maxPages, e) : e;
return `${s}-${e}`;
}
@@ -252,6 +252,25 @@
color: var(--text-brand-accent);
}
/* Compact error container for inline tool settings */
.errorCompact {
background-color: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
max-width: 100%;
}
/* Two-line clamp for compact error text */
.errorTextClamp {
color: var(--text-brand-accent);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark-mode adjustments */
:global([data-mantine-color-scheme='dark']) .selectedList {
background-color: var(--bg-raised);
@@ -1,7 +1,7 @@
import { Button, Text, Group, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { LogicalOperator } from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
import { LogicalOperator } from '@app/utils/bulkselection/selectionBuilders';
interface OperatorsSectionProps {
csvInput: string;