diff --git a/app/services/page_order.py b/app/services/page_order.py new file mode 100644 index 0000000..d888e92 --- /dev/null +++ b/app/services/page_order.py @@ -0,0 +1,290 @@ +""" +Page ordering for image-based instructions. + +Images arrive in whatever order the browser handed them over, so page_number +starts out as "order of arrival" rather than "order of the manual". Everything +that fixes that lives here: the sort strategies, the renumbering, and the +single apply_order() that every route and CLI command funnels through. + +One rule throughout: page numbers for a set are always a dense 1..n sequence +with no gaps, no duplicates and no NULLs. Any function that touches the order +leaves the set in that state. + +Only file_type == 'IMAGE' rows are ordered. PDFs are whole manuals, not pages. + +Created by: Jess Rogerson (yelling commands at Claude.AI) +""" + +import os +import re +from typing import Dict, Iterable, List, Optional, Sequence + +from app import db +from app.models.instruction import Instruction + + +# --- filename reading ------------------------------------------------------- + +# A number that was clearly meant as a page marker: "page 7", "pg_07", "p-7", +# "7 of 48", "7/48". Checked before falling back to "last number in the name". +_PAGE_MARKERS = ( + re.compile(r'(?:^|[^a-z0-9])(?:page|pg|p)[\s._\-]*(\d{1,4})(?![0-9])', re.I), + re.compile(r'(?:^|[^a-z0-9])(\d{1,4})\s*(?:of|_of_|/)\s*\d{1,4}', re.I), +) + +_DIGITS = re.compile(r'(\d+)') + + +def natural_key(name: str) -> tuple: + """ + Sort key that reads runs of digits as numbers, so page2 lands before + page10 instead of after it. + + Each token becomes a 3-tuple so numbers and text never get compared + against each other, which would raise TypeError on mixed names. + """ + key = [] + for token in _DIGITS.split(name or ''): + if token.isdigit(): + key.append((0, int(token), '')) + elif token: + key.append((1, 0, token.lower())) + return tuple(key) + + +def page_hint(name: str) -> Optional[int]: + """ + The page number a filename is claiming to be, or None. + + Tries explicit markers first ("page_12"), then falls back to the last + number in the name, which covers camera and scanner names like + IMG_2043.jpg or scan-0012.png. The value may not be a real page number, + but for a batch of files from one source it still sorts correctly. + """ + stem = os.path.splitext(name or '')[0] + + for pattern in _PAGE_MARKERS: + match = pattern.search(stem) + if match: + return int(match.group(1)) + + numbers = _DIGITS.findall(stem) + return int(numbers[-1]) if numbers else None + + +def _display_name(instruction: Instruction) -> str: + """The original upload name, falling back to the stored filename.""" + if instruction.file_name: + return instruction.file_name + return os.path.basename((instruction.file_path or '').replace('\\', '/')) + + +# --- sort strategies -------------------------------------------------------- +# +# Every key ends with instruction.id so the result is stable and repeatable +# when two files tie. + +def _key_filename(instruction: Instruction) -> tuple: + return (natural_key(_display_name(instruction)), instruction.id) + + +def _key_page_hint(instruction: Instruction) -> tuple: + hint = page_hint(_display_name(instruction)) + # Files with no number at all go to the back rather than the front. + return (hint is None, hint or 0, + natural_key(_display_name(instruction)), instruction.id) + + +def _key_uploaded(instruction: Instruction) -> tuple: + return (instruction.uploaded_at is None, instruction.uploaded_at, + instruction.id) + + +def _key_size(instruction: Instruction) -> tuple: + return (instruction.file_size or 0, instruction.id) + + +STRATEGIES = { + 'filename': ('Filename (natural order)', _key_filename), + 'page_hint': ('Page number found in filename', _key_page_hint), + 'uploaded': ('Upload order (oldest first)', _key_uploaded), + 'size': ('File size (smallest first)', _key_size), +} + +# Applied after sorting rather than as a key, so it can also reverse the +# order currently on screen. +REVERSIBLE = 'reverse' + + +def strategy_choices() -> List[tuple]: + """(value, label) pairs for the sort menu.""" + return [(value, label) for value, (label, _) in STRATEGIES.items()] + + +# --- reading the current order ---------------------------------------------- + +def _current_key(instruction: Instruction) -> tuple: + """Current order: by page number, NULLs last, id as tie-break.""" + return (instruction.page_number is None, instruction.page_number or 0, + instruction.id) + + +def image_pages(set_id: int) -> List[Instruction]: + """Every image instruction for a set, in its current page order.""" + rows = Instruction.query.filter_by(set_id=set_id, file_type='IMAGE').all() + return sorted(rows, key=_current_key) + + +def audit(set_id: int) -> Dict: + """ + What's wrong with the current numbering, for the UI banner and the CLI. + + Returns total, plus the duplicate numbers, the gaps in the sequence, and + how many pages have no number at all. is_clean is True when the set is a + dense 1..n run. + """ + pages = image_pages(set_id) + numbers = [p.page_number for p in pages if p.page_number is not None] + + seen, duplicates = set(), set() + for number in numbers: + if number in seen: + duplicates.add(number) + seen.add(number) + + gaps = [] + if numbers: + gaps = [n for n in range(1, max(numbers) + 1) if n not in seen] + + unnumbered = sum(1 for p in pages if p.page_number is None) + + return { + 'total': len(pages), + 'duplicates': sorted(duplicates), + 'gaps': gaps, + 'unnumbered': unnumbered, + 'is_clean': not duplicates and not gaps and not unnumbered, + } + + +# --- writing a new order ---------------------------------------------------- + +def apply_order(set_id: int, ordered_ids: Sequence, commit: bool = True) -> List[Instruction]: + """ + Write ordered_ids to page_number as 1..n. + + ordered_ids may be partial: any image belonging to the set that isn't + listed keeps its relative order and is appended after the listed ones. + That way a half-finished drag never strands a page at number 0. + + Raises ValueError on an id that isn't an image page of this set, or on a + duplicate id. + """ + pages = image_pages(set_id) + by_id = {page.id: page for page in pages} + + wanted: List[Instruction] = [] + taken = set() + + for raw_id in ordered_ids or []: + try: + page_id = int(raw_id) + except (TypeError, ValueError): + raise ValueError(f'Not a page id: {raw_id!r}') + + if page_id not in by_id: + raise ValueError(f'Page {page_id} is not an image page of this set') + if page_id in taken: + raise ValueError(f'Page {page_id} listed more than once') + + taken.add(page_id) + wanted.append(by_id[page_id]) + + # Anything the caller left out keeps its current relative position. + wanted.extend(page for page in pages if page.id not in taken) + + for position, page in enumerate(wanted, start=1): + if page.page_number != position: + page.page_number = position + + if commit: + db.session.commit() + + return wanted + + +def renumber(set_id: int, commit: bool = True) -> List[Instruction]: + """ + Close gaps and clear duplicates without changing the visible order. + + This is the repair job: the pages stay in the order they're already in, + they just end up numbered 1..n. + """ + return apply_order(set_id, [p.id for p in image_pages(set_id)], commit=commit) + + +def auto_sort(set_id: int, strategy: str = 'filename', + reverse: bool = False, commit: bool = True) -> List[Instruction]: + """ + Reorder a set's pages by one of the STRATEGIES, then renumber 1..n. + + Raises ValueError on an unknown strategy. + """ + if strategy not in STRATEGIES: + raise ValueError(f'Unknown sort strategy: {strategy}') + + _, key = STRATEGIES[strategy] + pages = sorted(image_pages(set_id), key=key, reverse=reverse) + return apply_order(set_id, [p.id for p in pages], commit=commit) + + +def reverse_order(set_id: int, commit: bool = True) -> List[Instruction]: + """Flip the current order end to end. Fixes back-to-front scan batches.""" + pages = list(reversed(image_pages(set_id))) + return apply_order(set_id, [p.id for p in pages], commit=commit) + + +def move_page(instruction_id: int, position: int, commit: bool = True) -> List[Instruction]: + """ + Put one page at a given 1-based position and shuffle the rest around it. + + position is clamped to the ends, so 0 or 999 both do something sensible. + Raises ValueError if the instruction isn't an image page. + """ + page = Instruction.query.get(instruction_id) + if page is None or page.file_type != 'IMAGE': + raise ValueError('That instruction is not an image page') + + pages = image_pages(page.set_id) + remaining = [p for p in pages if p.id != page.id] + + index = max(0, min(int(position) - 1, len(remaining))) + remaining.insert(index, page) + + return apply_order(page.set_id, [p.id for p in remaining], commit=commit) + + +def swap_pages(set_id: int, first_id: int, second_id: int, + commit: bool = True) -> List[Instruction]: + """Exchange two pages, leaving everything else alone.""" + pages = image_pages(set_id) + ids = [p.id for p in pages] + + try: + first_index = ids.index(int(first_id)) + second_index = ids.index(int(second_id)) + except ValueError: + raise ValueError('Both pages must belong to this set') + + ids[first_index], ids[second_index] = ids[second_index], ids[first_index] + return apply_order(set_id, ids, commit=commit) + + +def next_page_number(set_id: int) -> int: + """ + The number a newly uploaded image should get. + + Uses the count rather than max() + 1 so a set that was previously left + with gaps doesn't keep growing them. + """ + return len(image_pages(set_id)) + 1