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