Files

291 lines
9.6 KiB
Python

"""
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