Add page_order service for ordering image instruction pages
This commit is contained in:
1 parent
f7c20de5ac
commit
9e0fc343f7
1 file changed
+290
@@ -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
|
||||||
Reference in new issue
Block a user