Unclear reason for the backup
This commit is contained in:
@@ -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
|
||||||
+4886
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"base-url": "http://192.168.50.170",
|
||||||
|
"project": "customer-service",
|
||||||
|
"cache-dir": "/tmp/redmine/cache-dev/redmine_tickets"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
- [ ] No good way to link voicemail/call transcriptins to the contact_id of the called (the tickets are from the voip.ms service)
|
||||||
|
|
||||||
Reference in New Issue
Block a user