Unclear reason for the backup

This commit is contained in:
Jason Thistlethwaite
2026-10-18 10:45:14 -04:00
parent 4e10a21142
commit 7ad3723525
6 changed files with 5499 additions and 0 deletions
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Attachment helpers for the power-user Redmine ticket client.
List (cache-first), download to local dir, upload via Redmine REST:
POST /uploads.json?filename=… (raw body)
PUT /issues/:id.json with issue.uploads token
"""
from __future__ import annotations
import json
import mimetypes
import re
from pathlib import Path
from typing import Any
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
def safe_filename(name: str) -> str:
name = (name or "attachment.bin").strip().replace("\\", "_").replace("/", "_")
name = re.sub(r"[^\w.\- ()\[\]+@]+", "_", name)
return name or "attachment.bin"
def attachments_from_issue(issue: dict[str, Any]) -> list[dict[str, Any]]:
atts = issue.get("attachments")
if isinstance(atts, list):
return [a for a in atts if isinstance(a, dict)]
return []
def attachment_dir(cache_dir: Path, project: str, issue_id: int) -> Path:
path = Path(cache_dir) / project / "attachments" / str(int(issue_id))
path.mkdir(parents=True, exist_ok=True)
return path
def pick_attachment(attachments: list[dict[str, Any]], attachment_id: int | None) -> dict[str, Any]:
if not attachments:
raise LookupError("Issue has no attachments.")
if attachment_id is None:
if len(attachments) == 1:
return attachments[0]
raise LookupError(
"Multiple attachments; pass --attachment-id. ids="
+ ",".join(str(a.get("id")) for a in attachments)
)
for att in attachments:
if int(att.get("id") or 0) == int(attachment_id):
return att
raise LookupError(f"Attachment id {attachment_id} not found on issue.")
def download_url_for_attachment(att: dict[str, Any], base_url: str) -> str:
urls = download_url_candidates(att, base_url)
return urls[0]
def download_url_candidates(att: dict[str, Any], base_url: str) -> list[str]:
"""Ordered download URL candidates (host rewrite + path variants)."""
base = base_url.rstrip("/")
aid = att.get("id")
filename = str(att.get("filename") or "").strip()
candidates: list[str] = []
def add(url: str) -> None:
if url and url not in candidates:
candidates.append(url)
for key in ("content_url", "contentUrl"):
raw = att.get(key)
if not raw:
continue
raw = str(raw)
add(raw)
# rewrite host to active base_url (prod content_url on LAN client, etc.)
try:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(raw)
base_p = urlparse(base if "://" in base else f"http://{base}")
rewritten = urlunparse(
(
base_p.scheme or parsed.scheme,
base_p.netloc or parsed.netloc,
parsed.path,
"",
parsed.query,
"",
)
)
add(rewritten)
except Exception:
pass
if aid is not None:
add(f"{base}/attachments/download/{int(aid)}")
if filename:
from urllib.parse import quote
add(f"{base}/attachments/download/{int(aid)}/{quote(filename)}")
if not candidates:
raise LookupError("Attachment missing id and content_url.")
return candidates
def download_attachment_bytes(
*,
url: str,
api_key: str,
impersonate_user: str | None = None,
timeout: int = 120,
fallback_urls: list[str] | None = None,
) -> bytes:
headers = {
"X-Redmine-API-Key": api_key,
"Accept": "*/*",
}
if impersonate_user:
headers["X-Redmine-Switch-User"] = impersonate_user
urls = [url] + [u for u in (fallback_urls or []) if u != url]
last_err: Exception | None = None
for u in urls:
req = urllib_request.Request(u, headers=headers, method="GET")
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except urllib_error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:200]
last_err = RuntimeError(f"HTTP {exc.code} downloading attachment ({u}): {detail}")
continue
except urllib_error.URLError as exc:
last_err = RuntimeError(f"Could not download attachment ({u}): {exc.reason}")
continue
raise last_err or RuntimeError("Attachment download failed")
def upload_file_token(
*,
base_url: str,
api_key: str,
path: Path,
filename: str | None = None,
impersonate_user: str | None = None,
timeout: int = 120,
) -> dict[str, Any]:
"""POST raw file to /uploads.json?filename=… Returns upload object with token."""
path = Path(path)
if not path.is_file():
raise FileNotFoundError(f"File not found: {path}")
name = filename or path.name
data = path.read_bytes()
q = urllib_parse.urlencode({"filename": name})
url = f"{base_url.rstrip('/')}/uploads.json?{q}"
headers = {
"X-Redmine-API-Key": api_key,
"Content-Type": "application/octet-stream",
"Accept": "application/json",
}
if impersonate_user:
headers["X-Redmine-Switch-User"] = impersonate_user
req = urllib_request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib_request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
except urllib_error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"HTTP {exc.code} uploading file: {detail}") from exc
except urllib_error.URLError as exc:
raise RuntimeError(f"Could not upload file: {exc.reason}") from exc
if not raw:
raise RuntimeError("Empty response from /uploads.json")
payload = json.loads(raw.decode("utf-8"))
upload = payload.get("upload") if isinstance(payload, dict) else None
if not isinstance(upload, dict) or not upload.get("token"):
raise RuntimeError(f"Unexpected upload response: {payload!r}")
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
upload.setdefault("filename", name)
upload.setdefault("content_type", content_type)
return upload
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""OCR / text extract helpers for scanned ticket attachments.
Primary path: pdftotext (fast, works on digital PDFs).
Fallback: pdftoppm + tesseract for image-heavy scans.
Also handles common image attachments via tesseract.
Does not write to Redmine — callers queue subject/notes drafts.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
def _run(cmd: list[str], *, timeout: int = 120) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def extract_text_from_pdf(path: Path, *, max_chars: int = 12000) -> tuple[str, str]:
"""Return (text, method). method is pdftotext|tesseract|empty."""
path = Path(path)
text = ""
pdftotext = shutil.which("pdftotext")
if pdftotext:
proc = _run([pdftotext, "-layout", "-nopgbrk", str(path), "-"], timeout=60)
text = (proc.stdout or "").strip()
if len(re.sub(r"\s+", "", text)) >= 40:
return text[:max_chars], "pdftotext"
# OCR fallback: first 2 pages as PNG
pdftoppm = shutil.which("pdftoppm")
tesseract = shutil.which("tesseract")
if not pdftoppm or not tesseract:
return text[:max_chars] if text else "", "empty" if not text else "pdftotext-thin"
with tempfile.TemporaryDirectory(prefix="rtl-ocr-") as td:
out_prefix = Path(td) / "page"
_run([pdftoppm, "-png", "-f", "1", "-l", "2", "-r", "200", str(path), str(out_prefix)], timeout=90)
chunks: list[str] = []
for png in sorted(Path(td).glob("page*.png")):
proc = _run([tesseract, str(png), "stdout", "-l", "eng"], timeout=90)
if proc.stdout:
chunks.append(proc.stdout.strip())
ocr_text = "\n\n".join(chunks).strip()
if ocr_text:
return ocr_text[:max_chars], "tesseract"
return text[:max_chars] if text else "", "empty"
def extract_text_from_image(path: Path, *, max_chars: int = 12000) -> tuple[str, str]:
tesseract = shutil.which("tesseract")
if not tesseract:
return "", "empty"
proc = _run([tesseract, str(path), "stdout", "-l", "eng"], timeout=90)
text = (proc.stdout or "").strip()
return (text[:max_chars], "tesseract") if text else ("", "empty")
def extract_text_from_file(path: Path, *, max_chars: int = 12000) -> tuple[str, str]:
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".pdf":
return extract_text_from_pdf(path, max_chars=max_chars)
if suffix in {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp", ".gif"}:
return extract_text_from_image(path, max_chars=max_chars)
if suffix in {".txt", ".md", ".csv"}:
try:
return path.read_text(encoding="utf-8", errors="replace")[:max_chars], "read"
except OSError:
return "", "empty"
return "", "empty"
_PERSON_LINE = re.compile(
r"^(?:name|employee|applicant|patient|to|for)\s*[:\-]\s*(.+)$",
re.I | re.M,
)
_COMPANY_LINE = re.compile(
r"^(?:company|organization|employer|vendor|client)\s*[:\-]\s*(.+)$",
re.I | re.M,
)
_DATE_LINE = re.compile(
r"\b((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b",
re.I,
)
_DOC_HINTS = (
(r"\bw-?2\b", "W-2"),
(r"\bw-?4\b", "W-4"),
(r"\bi-?9\b", "I-9"),
(r"\bdriver.?s?\s+license\b|\bdl\b", "Driver License"),
(r"\bpassport\b", "Passport"),
(r"\binvoice\b", "Invoice"),
(r"\bpacking\s+slip\b", "Packing Slip"),
(r"\bbill\s+of\s+lading\b|\bbol\b", "Bill of Lading"),
(r"\bresume\b|\bcv\b", "Resume"),
(r"\boffer\s+letter\b", "Offer Letter"),
(r"\bnda\b|non-?disclosure", "NDA"),
(r"\bhandbook\b", "Handbook"),
(r"\bdirect\s+deposit\b", "Direct Deposit Form"),
)
def suggest_subject_from_text(
text: str,
*,
original_subject: str = "",
max_len: int = 120,
) -> str:
"""Heuristic subject for scanned-doc tickets."""
clean = re.sub(r"[ \t]+", " ", text or "")
lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
# Drop very short / noise lines
content_lines = [ln for ln in lines if len(ln) >= 4 and not re.fullmatch(r"[\d\W]+", ln)][:40]
doc_type = "Scanned document"
joined = "\n".join(content_lines[:30])
for pat, label in _DOC_HINTS:
if re.search(pat, joined, re.I):
doc_type = label
break
person = None
m = _PERSON_LINE.search(joined)
if m:
person = re.sub(r"\s+", " ", m.group(1)).strip(" .,-")[:60]
company = None
m = _COMPANY_LINE.search(joined)
if m:
company = re.sub(r"\s+", " ", m.group(1)).strip(" .,-")[:60]
# Fallback: first substantial title-ish line
title_line = None
for ln in content_lines[:8]:
if len(ln) < 8 or len(ln) > 80:
continue
if re.search(r"https?://|@|page\s+\d", ln, re.I):
continue
title_line = ln
break
date = None
dm = _DATE_LINE.search(joined)
if dm:
date = dm.group(1)
parts: list[str] = [doc_type]
if person:
parts.append(f"— {person}")
elif company:
parts.append(f"— {company}")
elif title_line and title_line.lower() not in doc_type.lower():
parts.append(f"— {title_line[:50]}")
if date and date not in " ".join(parts):
parts.append(f"({date})")
subject = " ".join(parts)
subject = re.sub(r"\s+", " ", subject).strip()
if len(subject) > max_len:
subject = subject[: max_len - 1].rstrip() + "…"
if not subject or subject == "Scanned document":
# last resort: original if not generic
orig = (original_subject or "").strip()
if orig and not re.search(r"scanned\s+document", orig, re.I):
return orig[:max_len]
if title_line:
return f"Scanned document — {title_line[:80]}"[:max_len]
return subject
def attachment_is_ocr_candidate(att: dict[str, Any]) -> bool:
name = str(att.get("filename") or att.get("name") or "").lower()
ctype = str(att.get("content_type") or "").lower()
if name.endswith(".pdf") or "pdf" in ctype:
return True
if any(name.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp")):
return True
if ctype.startswith("image/"):
return True
return False
def looks_like_generic_scan_subject(subject: str) -> bool:
s = (subject or "").strip().lower()
if not s:
return True
return bool(
re.search(
r"scanned\s+document|scan\s+from|hp\s+scan|scanner|untitled|document\s+from\s+scan",
s,
)
)
def build_ocr_note(
*,
issue_id: int,
files: list[dict[str, str]],
proposed_subject: str,
) -> str:
lines = [
"=== OCR / attachment review (agent) ===",
f"issue_id: {issue_id}",
f"proposed_subject: {proposed_subject}",
"",
]
for f in files:
lines.append(f"## {f.get('filename')} ({f.get('method')})")
lines.append("")
snippet = (f.get("text") or "")[:2500].strip()
lines.append(snippet or "_(no extractable text)_")
lines.append("")
lines.append("_Generated by redmine_ticket_ocr; verify before customer-facing use._")
return "\n".join(lines) + "\n"
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Markdown rendering + pager helpers for cached Redmine tickets."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
def _nested_name(issue: dict[str, Any], key: str) -> str:
value = issue.get(key)
if isinstance(value, dict):
name = value.get("name")
return str(name) if name is not None else ""
return "" if value is None else str(value)
def _helpdesk(issue: dict[str, Any]) -> dict[str, Any]:
hd = issue.get("helpdesk_ticket") or {}
return hd if isinstance(hd, dict) else {}
def _contact(issue: dict[str, Any]) -> dict[str, Any]:
contact = _helpdesk(issue).get("contact") or {}
return contact if isinstance(contact, dict) else {}
def render_ticket_markdown(issue: dict[str, Any]) -> str:
issue_id = issue.get("id")
subject = str(issue.get("subject") or "").strip()
status = _nested_name(issue, "status")
project = _nested_name(issue, "project")
category = _nested_name(issue, "category")
assigned = _nested_name(issue, "assigned_to") or "(unassigned)"
author = _nested_name(issue, "author")
priority = _nested_name(issue, "priority")
created = issue.get("created_on") or ""
updated = issue.get("updated_on") or ""
hd = _helpdesk(issue)
contact = _contact(issue)
from_addr = hd.get("from_address") or contact.get("email") or ""
contact_id = contact.get("id")
company = contact.get("company") or ""
contact_name = contact.get("name") or ""
description = str(issue.get("description") or "").strip()
redmine_url = f"https://rm.ldrprep.com/issues/{issue_id}" if issue_id else ""
lines = [
f"# #{issue_id} — {subject}",
"",
"## Meta",
"",
f"- **Project:** {project}",
f"- **Status:** {status}",
f"- **Priority:** {priority}",
f"- **Category:** {category or '(none)'}",
f"- **Assignee:** {assigned}",
f"- **Author:** {author}",
f"- **Created:** {created}",
f"- **Updated:** {updated}",
f"- **Redmine:** {redmine_url}",
"",
"## Helpdesk / contact",
"",
f"- **From:** {from_addr}",
f"- **Contact ID:** {contact_id if contact_id is not None else '(none)'}",
f"- **Contact name:** {contact_name or '(none)'}",
f"- **Company:** {company or '(none)'}",
"",
"## Detected entities (heuristic)",
"",
]
try:
from redmine_ticket_entities import extract_entities_from_issue, format_entities_line
ents = extract_entities_from_issue(issue)
if ents:
for e in ents:
conf = e.get("confidence") or "high"
lines.append(f"- **{e.get('type')}:** `{e.get('value')}` ({conf})")
else:
lines.append("_(none)_")
except Exception:
lines.append("_(entity extractor unavailable)_")
lines.extend(
[
"",
"## Description",
"",
description or "_(empty)_",
"",
"## Journals",
"",
]
)
journals = issue.get("journals") or []
if not journals:
lines.append("_(no journals)_")
else:
for journal in journals:
if not isinstance(journal, dict):
continue
jid = journal.get("id")
jcreated = journal.get("created_on") or ""
user = journal.get("user") or {}
uname = user.get("name") if isinstance(user, dict) else ""
notes = str(journal.get("notes") or "").strip()
details = journal.get("details") or []
lines.append(f"### Journal #{jid} — {jcreated} — {uname}")
lines.append("")
if details:
for detail in details:
if not isinstance(detail, dict):
continue
lines.append(
f"- change: {detail.get('property')}/{detail.get('name')}: "
f"{detail.get('old_value')!r} → {detail.get('new_value')!r}"
)
lines.append("")
if notes:
lines.append(notes)
else:
lines.append("_(no notes)_")
lines.append("")
lines.append("---")
lines.append(f"_Rendered from cache; source updated_on={updated}_")
lines.append("")
return "\n".join(lines)
def markdown_cache_path(cache_dir: Path, project: str, issue_id: int | str) -> Path:
return Path(cache_dir) / project / "markdown" / f"{issue_id}.md"
def write_ticket_markdown(
issue: dict[str, Any],
cache_dir: Path,
project: str,
*,
force: bool = False,
) -> Path:
"""Write markdown cache if missing or ticket updated_on changed (via sidecar stamp)."""
issue_id = issue["id"]
path = markdown_cache_path(cache_dir, project, issue_id)
stamp_path = path.with_suffix(".md.updated_on")
updated_on = str(issue.get("updated_on") or "")
path.parent.mkdir(parents=True, exist_ok=True)
if (
not force
and path.is_file()
and stamp_path.is_file()
and stamp_path.read_text(encoding="utf-8").strip() == updated_on
):
return path
path.write_text(render_ticket_markdown(issue), encoding="utf-8")
stamp_path.write_text(updated_on + "\n", encoding="utf-8")
return path
def resolve_pager(preferred: str = "auto") -> list[str]:
"""Return argv prefix to display markdown (pager program + flags)."""
preferred = (preferred or "auto").lower()
if preferred == "cat":
return ["cat"]
if preferred in {"glow", "bat", "batcat"}:
path = shutil.which(preferred)
if not path:
raise FileNotFoundError(f"Pager {preferred!r} not found on PATH")
if preferred == "glow":
return [path]
return [path, "--language", "markdown", "--style", "plain"]
# auto
for name, args in (
("glow", []),
("bat", ["--language", "markdown", "--style", "plain"]),
("batcat", ["--language", "markdown", "--style", "plain"]),
):
path = shutil.which(name)
if path:
return [path, *args]
return ["cat"]
def display_markdown_file(path: Path, pager: str = "auto") -> int:
cmd = resolve_pager(pager) + [str(path)]
# Prefer streaming to user terminal
env = os.environ.copy()
try:
completed = subprocess.run(cmd, check=False, env=env)
return int(completed.returncode)
except FileNotFoundError:
print(path.read_text(encoding="utf-8"))
return 0
File diff suppressed because it is too large Load Diff