heymenn commited on
Commit
a610111
Β·
1 Parent(s): 6ecdcbc

features: independant from docfinder, eol creds, input ts mode

Browse files
app.py CHANGED
@@ -9,6 +9,7 @@ Three-step UI:
9
  4. DONE/ERROR β€” download ZIP of results
10
  """
11
 
 
12
  import io
13
  import json
14
  import os
@@ -18,11 +19,19 @@ import threading
18
  import time
19
  import uuid
20
  import zipfile
 
21
  from datetime import datetime
22
  from pathlib import Path
23
 
24
  import streamlit as st
25
 
 
 
 
 
 
 
 
26
  # ── EOL credential verification ───────────────────────────────────────────────
27
 
28
  def verify_eol_credentials(username: str, password: str) -> bool:
@@ -108,6 +117,14 @@ def new_state(sid: str) -> dict:
108
  "started_at": None,
109
  "completed_at": None,
110
  "return_code": None,
 
 
 
 
 
 
 
 
111
  }
112
 
113
 
@@ -235,6 +252,18 @@ def make_zip(output_dir: Path) -> bytes:
235
  return buf.read()
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  # ── Page config ───────────────────────────────────────────────────────────────
239
  st.set_page_config(
240
  page_title="CR Application Tool",
@@ -326,31 +355,264 @@ if status == "login":
326
  elif status == "upload":
327
  st.subheader("Step 1 β€” Upload contribution list")
328
 
329
- uploaded = st.file_uploader(
330
- "Excel contribution list (.xlsx or .xls)",
331
- type=["xlsx", "xls"],
332
- )
333
- person_name = st.text_input(
334
- "Contributor name (must match SubmittedBy column)",
335
- value=state.get("person_name", "Ly Thanh PHAN"),
336
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
- if uploaded and st.button("Parse CR list β†’", type="primary"):
339
- excel_path = session_dir(sid) / uploaded.name
340
- excel_path.write_bytes(uploaded.getbuffer())
 
 
 
 
 
 
 
 
 
341
 
342
- with st.spinner("Parsing Excel…"):
343
- try:
344
- from fetch_crs import parse_excel
345
- cr_list = parse_excel(str(excel_path), person_name)
346
- state["status"] = "preview"
347
- state["excel_filename"] = uploaded.name
348
- state["person_name"] = person_name
349
- state["cr_list"] = [list(row) for row in cr_list]
350
- save_state(sid, state)
351
- st.rerun()
352
- except Exception as exc:
353
- st.error(f"Failed to parse Excel: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  # ═════════════════════════════════════════════════════════════════════════���══
356
  # PREVIEW
@@ -386,10 +648,15 @@ elif status == "preview":
386
  with col2:
387
  if cr_list and st.button("β–Ά Start Pipeline", type="primary"):
388
  excel_path = session_dir(sid) / state["excel_filename"]
389
- output_dir = session_dir(sid) / "output"
 
 
390
  output_dir.mkdir(parents=True, exist_ok=True)
391
- log_path = session_dir(sid) / "pipeline.log"
 
 
392
  rc_path = _rc_path(sid)
 
393
 
394
  cmd = [
395
  sys.executable,
@@ -397,6 +664,7 @@ elif status == "preview":
397
  str(excel_path),
398
  state["person_name"],
399
  "--output-dir", str(output_dir),
 
400
  ]
401
 
402
  env = os.environ.copy()
@@ -425,6 +693,7 @@ elif status == "preview":
425
  state["pid"] = proc.pid
426
  state["output_dir"] = str(output_dir)
427
  state["log_path"] = str(log_path)
 
428
  state["started_at"] = datetime.now().isoformat()
429
  save_state(sid, state)
430
  st.rerun()
@@ -478,12 +747,11 @@ elif status in ("done", "error"):
478
  else:
479
  st.error(f"❌ Pipeline finished with errors (return code: {rc})")
480
 
481
- # Per-TS results table β€” merge all pipeline logs so retry results don't
482
- # replace original ones; later logs (pipeline_retry.log) supersede earlier
483
- # ones (pipeline.log) for the same TS key.
484
  _merged: dict[str, dict] = {}
485
- for _lf in sorted(session_dir(sid).glob("pipeline*.log")):
486
- for _r in parse_log_results(str(_lf)):
487
  _merged[_r["TS"]] = _r
488
  results = list(_merged.values())
489
  if results:
@@ -604,7 +872,7 @@ elif status in ("done", "error"):
604
  st.success(f"All {len(ready_entries)} TS(s) ready.")
605
 
606
  if st.button("β–Ά Apply CRs to recovered TSs", type="primary"):
607
- retry_log = str(session_dir(sid) / "pipeline_retry.log")
608
  _rc_path(sid).unlink(missing_ok=True) # clear old returncode
609
 
610
  cmd = [
@@ -630,23 +898,41 @@ elif status in ("done", "error"):
630
  ).start()
631
  st.session_state.proc = proc
632
 
633
- state["status"] = "running"
634
- state["pid"] = proc.pid
635
- state["log_path"] = retry_log
636
- state["started_at"] = datetime.now().isoformat()
 
637
  save_state(sid, state)
638
  st.rerun()
639
  else:
640
  st.warning("No TSs available yet β€” retry download or upload DOCX files above.")
641
 
642
- # Start new session
643
  st.divider()
644
- if st.button("Start new session"):
645
- new_sid = str(uuid.uuid4())
646
- st.session_state.sid = new_sid
647
- st.session_state.state = new_state(new_sid)
648
- if "proc" in st.session_state:
649
- del st.session_state.proc
650
- st.query_params["sid"] = new_sid
651
- save_state(new_sid, st.session_state.state)
652
- st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  4. DONE/ERROR β€” download ZIP of results
10
  """
11
 
12
+ import hashlib
13
  import io
14
  import json
15
  import os
 
19
  import time
20
  import uuid
21
  import zipfile
22
+ from collections import defaultdict
23
  from datetime import datetime
24
  from pathlib import Path
25
 
26
  import streamlit as st
27
 
28
+ # ── Load .env from the same directory as app.py ───────────────────────────────
29
+ try:
30
+ from dotenv import load_dotenv
31
+ load_dotenv(Path(__file__).parent / ".env")
32
+ except ImportError:
33
+ pass # python-dotenv not installed; rely on environment variables
34
+
35
  # ── EOL credential verification ───────────────────────────────────────────────
36
 
37
  def verify_eol_credentials(username: str, password: str) -> bool:
 
117
  "started_at": None,
118
  "completed_at": None,
119
  "return_code": None,
120
+ # TS mode fields
121
+ "mode": "contributor", # "contributor" | "ts"
122
+ "excel_hash": "",
123
+ "hf_repo": "OrganizedProgrammers/CR_Index",
124
+ "index_log": "",
125
+ "ts_id": "",
126
+ # Logs for the current pipeline run (main + retry); reset on new run
127
+ "run_log_paths": [],
128
  }
129
 
130
 
 
252
  return buf.read()
253
 
254
 
255
+ def load_hf_index_cached(hf_token: str, hf_repo: str) -> list[dict]:
256
+ """Load HF CR index, caching result in session_state to avoid redundant fetches."""
257
+ key = f"hf_index_{hf_repo}"
258
+ if key not in st.session_state:
259
+ from hf_cr_index import load_hf_index
260
+ try:
261
+ st.session_state[key] = load_hf_index(hf_token, hf_repo)
262
+ except Exception:
263
+ st.session_state[key] = []
264
+ return st.session_state[key]
265
+
266
+
267
  # ── Page config ───────────────────────────────────────────────────────────────
268
  st.set_page_config(
269
  page_title="CR Application Tool",
 
355
  elif status == "upload":
356
  st.subheader("Step 1 β€” Upload contribution list")
357
 
358
+ mode_label = st.radio(
359
+ "Pipeline mode",
360
+ ["By contributor name", "By TS (all CRs for a spec)"],
361
+ key="mode_radio",
 
 
 
362
  )
363
+ pipeline_mode = "contributor" if mode_label.startswith("By contributor") else "ts"
364
+ state["mode"] = pipeline_mode
365
+
366
+ # ── Resolve active Excel (saved from a previous run, or freshly uploaded) ──
367
+ saved_name = state.get("excel_filename")
368
+ saved_path = session_dir(sid) / saved_name if saved_name else None
369
+ has_saved = saved_path is not None and saved_path.exists()
370
+
371
+ if has_saved:
372
+ st.success(f"Using: **{saved_name}**")
373
+ with st.expander("Replace Excel file"):
374
+ uploaded = st.file_uploader(
375
+ "Upload a different Excel file (.xlsx or .xls)",
376
+ type=["xlsx", "xls"],
377
+ key="excel_replace",
378
+ )
379
+ else:
380
+ uploaded = st.file_uploader(
381
+ "Excel contribution list (.xlsx or .xls)",
382
+ type=["xlsx", "xls"],
383
+ )
384
 
385
+ # When a new file is dropped, save it immediately and update state
386
+ if uploaded:
387
+ active_path = session_dir(sid) / uploaded.name
388
+ active_bytes = bytes(uploaded.getbuffer())
389
+ active_path.write_bytes(active_bytes)
390
+ state["excel_filename"] = uploaded.name
391
+ state["excel_hash"] = hashlib.sha256(active_bytes).hexdigest()[:16]
392
+ save_state(sid, state)
393
+ elif has_saved:
394
+ active_path = saved_path
395
+ else:
396
+ active_path = None
397
 
398
+ if pipeline_mode == "contributor":
399
+ person_name = st.text_input(
400
+ "Contributor name (must match SubmittedBy column)",
401
+ value=state.get("person_name", "Ly Thanh PHAN"),
402
+ )
403
+
404
+ if active_path and st.button("Parse CR list β†’", type="primary"):
405
+ with st.spinner("Parsing Excel…"):
406
+ try:
407
+ from fetch_crs import parse_excel
408
+ cr_list = parse_excel(str(active_path), person_name)
409
+ state["status"] = "preview"
410
+ state["person_name"] = person_name
411
+ state["cr_list"] = [list(row) for row in cr_list]
412
+ save_state(sid, state)
413
+ st.rerun()
414
+ except Exception as exc:
415
+ st.error(f"Failed to parse Excel: {exc}")
416
+
417
+ else: # TS mode
418
+ if active_path:
419
+ excel_hash = state.get("excel_hash") or hashlib.sha256(active_path.read_bytes()).hexdigest()[:16]
420
+ state["excel_hash"] = excel_hash
421
+
422
+ hf_token = os.environ.get("HF_TOKEN", "")
423
+ hf_repo = state.get("hf_repo", "OrganizedProgrammers/CR_Index")
424
+
425
+ # Check whether this Excel is already indexed
426
+ existing = load_hf_index_cached(hf_token, hf_repo)
427
+ already_indexed = any(r.get("excel_hash") == excel_hash for r in existing)
428
+
429
+ if already_indexed:
430
+ st.success(f"This Excel (`{excel_hash}`) is already indexed in HF.")
431
+ if st.button("Select TS β†’", type="primary"):
432
+ state["status"] = "ts_select"
433
+ save_state(sid, state)
434
+ st.rerun()
435
+ else:
436
+ st.info(f"Excel hash: `{excel_hash}` β€” not yet indexed.")
437
+ if st.button("Build CR Index", type="primary"):
438
+ # CRs downloaded during indexing go to the session-level cache
439
+ cr_cache_dir = session_dir(sid) / "CRs"
440
+ cr_cache_dir.mkdir(parents=True, exist_ok=True)
441
+ index_log = str(session_dir(sid) / "index.log")
442
+ rc_path = _rc_path(sid)
443
+ rc_path.unlink(missing_ok=True)
444
+
445
+ cmd = [
446
+ sys.executable,
447
+ str(SCRIPTS_DIR / "build_cr_index.py"),
448
+ str(active_path),
449
+ "--output-dir", str(session_dir(sid)),
450
+ "--hf-repo", hf_repo,
451
+ ]
452
+ env = os.environ.copy()
453
+ env["EOL_USER"] = st.session_state.eol_user
454
+ env["EOL_PASSWORD"] = st.session_state.eol_password
455
+ # HF_TOKEN is already in env via os.environ
456
+
457
+ log_file = open(index_log, "w")
458
+ proc = subprocess.Popen(
459
+ cmd,
460
+ stdout=log_file,
461
+ stderr=subprocess.STDOUT,
462
+ env=env,
463
+ )
464
+ log_file.close()
465
+
466
+ threading.Thread(
467
+ target=_run_and_save_rc,
468
+ args=(proc, rc_path),
469
+ daemon=True,
470
+ ).start()
471
+ st.session_state.proc = proc
472
+
473
+ state["status"] = "indexing"
474
+ state["pid"] = proc.pid
475
+ state["index_log"] = index_log
476
+ state["output_dir"] = "" # no pipeline output yet
477
+ state["started_at"] = datetime.now().isoformat()
478
+ save_state(sid, state)
479
+ st.rerun()
480
+
481
+ # ════════════════════════════════════════════════════════════════════════════
482
+ # INDEXING (build_cr_index.py running)
483
+ # ════════════════════════════════════════════════════════════════════════════
484
+ elif status == "indexing":
485
+ pid = state["pid"]
486
+ index_log = state.get("index_log", "")
487
+
488
+ proc = st.session_state.get("proc")
489
+ alive = False
490
+ if proc is not None:
491
+ alive = proc.poll() is None
492
+ else:
493
+ rc = read_return_code(sid)
494
+ if rc is None:
495
+ alive = is_process_alive(pid)
496
+
497
+ if alive:
498
+ st.subheader("⏳ Building CR Index…")
499
+ st.info(f"PID {pid} β€” started {state.get('started_at', '')[:19]}")
500
+ log_text = tail_log(index_log, 50)
501
+ st.text_area("Live log (last 50 lines)", value=log_text, height=400)
502
+ time.sleep(2)
503
+ st.rerun()
504
+ else:
505
+ rc = read_return_code(sid)
506
+ if rc is None and proc is not None:
507
+ rc = proc.returncode
508
+ state["return_code"] = rc
509
+ state["completed_at"] = datetime.now().isoformat()
510
+ if rc == 0:
511
+ # Invalidate cached HF index so ts_select gets fresh data
512
+ st.session_state.pop(f"hf_index_{state.get('hf_repo', '')}", None)
513
+ state["status"] = "ts_select"
514
+ else:
515
+ # Expose the index log as log_path so the error state can display it
516
+ state["log_path"] = state.get("index_log", "")
517
+ state["status"] = "error"
518
+ save_state(sid, state)
519
+ st.rerun()
520
+
521
+ # ════════════════════════════════════════════════════════════════════════════
522
+ # TS_SELECT (index ready β€” pick a spec to process)
523
+ # ════════════════════════════════════════════════════════════════════════════
524
+ elif status == "ts_select":
525
+ st.subheader("Step 2 β€” Select target TS spec")
526
+
527
+ hf_token = os.environ.get("HF_TOKEN", "")
528
+ hf_repo = state.get("hf_repo", "OrganizedProgrammers/CR_Index")
529
+ excel_hash = state.get("excel_hash", "")
530
+
531
+ with st.spinner("Loading CR index from HuggingFace…"):
532
+ records = load_hf_index_cached(hf_token, hf_repo)
533
+
534
+ records_for_excel = [r for r in records if r.get("excel_hash") == excel_hash]
535
+
536
+ if not records_for_excel:
537
+ st.error(f"No records found for excel_hash `{excel_hash}`. Try rebuilding the index.")
538
+ if st.button("← Back to upload"):
539
+ state["status"] = "upload"
540
+ save_state(sid, state)
541
+ st.rerun()
542
+ else:
543
+ by_spec = defaultdict(list)
544
+ for r in records_for_excel:
545
+ by_spec[r["spec_number"]].append(r)
546
+
547
+ spec_options = sorted(by_spec.keys())
548
+ selected_spec = st.selectbox("Select target TS spec", spec_options)
549
+
550
+ if selected_spec:
551
+ versions = defaultdict(list)
552
+ for r in by_spec[selected_spec]:
553
+ versions[r["version"]].append(r["uid"])
554
+ st.write("**Versions found:**")
555
+ for ver, uids in sorted(versions.items()):
556
+ st.write(f" v{ver}: {len(uids)} CR(s) β€” {', '.join(uids)}")
557
+
558
+ col1, col2 = st.columns(2)
559
+ with col1:
560
+ if st.button("← Back"):
561
+ state["status"] = "upload"
562
+ save_state(sid, state)
563
+ st.rerun()
564
+ with col2:
565
+ if st.button("β–Ά Apply CRs for this TS", type="primary"):
566
+ # Per-run directory so each pipeline's outputs are isolated
567
+ run_id = int(time.time())
568
+ output_dir = session_dir(sid) / f"run_{run_id}"
569
+ output_dir.mkdir(parents=True, exist_ok=True)
570
+ cr_cache_dir = session_dir(sid) / "CRs"
571
+ cr_cache_dir.mkdir(parents=True, exist_ok=True)
572
+ log_path = session_dir(sid) / f"pipeline_{run_id}.log"
573
+ rc_path = _rc_path(sid)
574
+ rc_path.unlink(missing_ok=True)
575
+
576
+ cmd = [
577
+ sys.executable,
578
+ str(SCRIPTS_DIR / "orchestrate_cr.py"),
579
+ "--output-dir", str(output_dir),
580
+ "--cr-cache-dir", str(cr_cache_dir),
581
+ "--ts-mode",
582
+ "--ts-id", selected_spec,
583
+ "--excel-hash", excel_hash,
584
+ "--hf-repo", hf_repo,
585
+ ]
586
+ env = os.environ.copy()
587
+ env["EOL_USER"] = st.session_state.eol_user
588
+ env["EOL_PASSWORD"] = st.session_state.eol_password
589
+ # HF_TOKEN already in env via os.environ
590
+
591
+ log_file = open(str(log_path), "w")
592
+ proc = subprocess.Popen(
593
+ cmd,
594
+ stdout=log_file,
595
+ stderr=subprocess.STDOUT,
596
+ env=env,
597
+ )
598
+ log_file.close()
599
+
600
+ threading.Thread(
601
+ target=_run_and_save_rc,
602
+ args=(proc, rc_path),
603
+ daemon=True,
604
+ ).start()
605
+ st.session_state.proc = proc
606
+
607
+ state["ts_id"] = selected_spec
608
+ state["status"] = "running"
609
+ state["pid"] = proc.pid
610
+ state["output_dir"] = str(output_dir)
611
+ state["log_path"] = str(log_path)
612
+ state["run_log_paths"] = [str(log_path)]
613
+ state["started_at"] = datetime.now().isoformat()
614
+ save_state(sid, state)
615
+ st.rerun()
616
 
617
  # ═════════════════════════════════════════════════════════════════════════���══
618
  # PREVIEW
 
648
  with col2:
649
  if cr_list and st.button("β–Ά Start Pipeline", type="primary"):
650
  excel_path = session_dir(sid) / state["excel_filename"]
651
+ # Per-run directory so each pipeline's outputs are isolated
652
+ run_id = int(time.time())
653
+ output_dir = session_dir(sid) / f"run_{run_id}"
654
  output_dir.mkdir(parents=True, exist_ok=True)
655
+ cr_cache_dir = session_dir(sid) / "CRs"
656
+ cr_cache_dir.mkdir(parents=True, exist_ok=True)
657
+ log_path = session_dir(sid) / f"pipeline_{run_id}.log"
658
  rc_path = _rc_path(sid)
659
+ rc_path.unlink(missing_ok=True)
660
 
661
  cmd = [
662
  sys.executable,
 
664
  str(excel_path),
665
  state["person_name"],
666
  "--output-dir", str(output_dir),
667
+ "--cr-cache-dir", str(cr_cache_dir),
668
  ]
669
 
670
  env = os.environ.copy()
 
693
  state["pid"] = proc.pid
694
  state["output_dir"] = str(output_dir)
695
  state["log_path"] = str(log_path)
696
+ state["run_log_paths"] = [str(log_path)]
697
  state["started_at"] = datetime.now().isoformat()
698
  save_state(sid, state)
699
  st.rerun()
 
747
  else:
748
  st.error(f"❌ Pipeline finished with errors (return code: {rc})")
749
 
750
+ # Per-TS results table β€” merge this run's logs so retry results supersede
751
+ # the original failed entries for the same TS key.
 
752
  _merged: dict[str, dict] = {}
753
+ for _lf in state.get("run_log_paths", []):
754
+ for _r in parse_log_results(_lf):
755
  _merged[_r["TS"]] = _r
756
  results = list(_merged.values())
757
  if results:
 
872
  st.success(f"All {len(ready_entries)} TS(s) ready.")
873
 
874
  if st.button("β–Ά Apply CRs to recovered TSs", type="primary"):
875
+ retry_log = str(session_dir(sid) / f"pipeline_{int(time.time())}_retry.log")
876
  _rc_path(sid).unlink(missing_ok=True) # clear old returncode
877
 
878
  cmd = [
 
898
  ).start()
899
  st.session_state.proc = proc
900
 
901
+ state["status"] = "running"
902
+ state["pid"] = proc.pid
903
+ state["log_path"] = retry_log
904
+ state["run_log_paths"] = state.get("run_log_paths", []) + [retry_log]
905
+ state["started_at"] = datetime.now().isoformat()
906
  save_state(sid, state)
907
  st.rerun()
908
  else:
909
  st.warning("No TSs available yet β€” retry download or upload DOCX files above.")
910
 
911
+ # Navigation
912
  st.divider()
913
+ col_restart, col_new = st.columns(2)
914
+ with col_restart:
915
+ if st.button("← Run another pipeline", type="primary"):
916
+ # Reset pipeline fields, keep session ID and credentials
917
+ for _k in ("cr_list", "pid", "output_dir", "log_path", "index_log",
918
+ "started_at", "completed_at", "return_code", "ts_id"):
919
+ state[_k] = None if _k != "cr_list" else []
920
+ # excel_filename, excel_hash and person_name are intentionally kept
921
+ # so the user does not have to re-upload on the next run.
922
+ state["run_log_paths"] = []
923
+ state["status"] = "upload"
924
+ if "proc" in st.session_state:
925
+ del st.session_state.proc
926
+ _rc_path(sid).unlink(missing_ok=True)
927
+ save_state(sid, state)
928
+ st.rerun()
929
+ with col_new:
930
+ if st.button("Start new session"):
931
+ new_sid = str(uuid.uuid4())
932
+ st.session_state.sid = new_sid
933
+ st.session_state.state = new_state(new_sid)
934
+ if "proc" in st.session_state:
935
+ del st.session_state.proc
936
+ st.query_params["sid"] = new_sid
937
+ save_state(new_sid, st.session_state.state)
938
+ st.rerun()
scripts/build_cr_index.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ build_cr_index.py β€” Build and push a CR index to a HuggingFace dataset.
4
+
5
+ Parses all Accepted CRs from the Excel, downloads each one (reusing cached
6
+ files), parses the cover page to get (spec_number, version), then pushes a
7
+ JSONL index to the HF dataset.
8
+
9
+ Usage:
10
+ python3 build_cr_index.py <excel_path> --output-dir DIR --hf-repo ORG/REPO
11
+
12
+ Environment variables:
13
+ EOL_USER, EOL_PASSWORD β€” ETSI EOL credentials for download_cr
14
+ HF_TOKEN β€” HuggingFace token (write access to hf-repo)
15
+ """
16
+
17
+ import argparse
18
+ import datetime
19
+ import hashlib
20
+ import os
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ # ── sys.path setup ────────────────────────────────────────────────────────────
26
+ SCRIPT_DIR = Path(__file__).parent
27
+ sys.path.insert(0, str(SCRIPT_DIR))
28
+
29
+ from fetch_crs import parse_excel_all_accepted, download_cr, parse_cr_cover, wsl_path
30
+ from hf_cr_index import load_hf_index, push_hf_index
31
+
32
+
33
+ def main():
34
+ ap = argparse.ArgumentParser(
35
+ description="Build and push CR index to HuggingFace dataset.",
36
+ )
37
+ ap.add_argument("excel_path", help="Path to .xls or .xlsx contribution list")
38
+ ap.add_argument(
39
+ "--output-dir",
40
+ default=str(Path.home() / "CR_Processing"),
41
+ help="Base output directory (default: ~/CR_Processing)",
42
+ )
43
+ ap.add_argument(
44
+ "--hf-repo",
45
+ default="OrganizedProgrammers/CR_Index",
46
+ help="HuggingFace dataset repo (default: OrganizedProgrammers/CR_Index)",
47
+ )
48
+ args = ap.parse_args()
49
+
50
+ eol_user = os.environ.get("EOL_USER", "")
51
+ eol_password = os.environ.get("EOL_PASSWORD", "")
52
+ hf_token = os.environ.get("HF_TOKEN", "")
53
+
54
+ if not eol_user or not eol_password:
55
+ sys.exit("ERROR: EOL_USER and EOL_PASSWORD must be set")
56
+ if not hf_token:
57
+ sys.exit("ERROR: HF_TOKEN must be set")
58
+
59
+ excel_path = Path(wsl_path(args.excel_path))
60
+ if not excel_path.exists():
61
+ sys.exit(f"ERROR: Excel file not found: {excel_path}")
62
+
63
+ output_dir = Path(wsl_path(args.output_dir)).expanduser()
64
+ cr_dir = output_dir / "CRs"
65
+ cr_dir.mkdir(parents=True, exist_ok=True)
66
+
67
+ # ── 1. Compute Excel hash ─────────────────────────────────────────────────
68
+ excel_hash = hashlib.sha256(excel_path.read_bytes()).hexdigest()[:16]
69
+ meeting_label = excel_path.stem
70
+ print(f"Excel: {excel_path.name}")
71
+ print(f"Excel hash: {excel_hash}")
72
+ print(f"Meeting: {meeting_label}")
73
+ print(f"HF repo: {args.hf_repo}")
74
+ print()
75
+
76
+ # ── 2. Parse all Accepted CRs ─────────────────────────────────────────────
77
+ print("Parsing Excel for all Accepted CRs...")
78
+ try:
79
+ cr_list = parse_excel_all_accepted(str(excel_path))
80
+ except Exception as e:
81
+ sys.exit(f"ERROR parsing Excel: {e}")
82
+ print(f"Found {len(cr_list)} Accepted CR(s)\n")
83
+
84
+ if not cr_list:
85
+ print("Nothing to index.")
86
+ sys.exit(0)
87
+
88
+ # ── 3. Load existing HF index ─────────────────────────────────────────────
89
+ print("Loading existing HF index...")
90
+ try:
91
+ existing = load_hf_index(hf_token, args.hf_repo)
92
+ except Exception as e:
93
+ print(f" WARNING: could not load existing index: {e}")
94
+ existing = []
95
+ existing_keys = {(r["excel_hash"], r["uid"]) for r in existing}
96
+ print(f" {len(existing)} existing record(s), {len(existing_keys)} unique keys\n")
97
+
98
+ # ── 4. Download and parse each new CR ─────────────────────────────────────
99
+ new_records = []
100
+ skipped = 0
101
+ failed = []
102
+
103
+ print("Processing CRs...")
104
+ for uid, title, submitted_by in cr_list:
105
+ if (excel_hash, uid) in existing_keys:
106
+ print(f" [{uid}] already indexed β€” skipping")
107
+ skipped += 1
108
+ continue
109
+
110
+ # Retry loop (3 attempts)
111
+ docx_path = None
112
+ note = ""
113
+ for attempt in range(1, 4):
114
+ docx_path, note = download_cr(uid, cr_dir, eol_user, eol_password)
115
+ if docx_path:
116
+ break
117
+ if attempt < 3:
118
+ print(f" [{uid}] attempt {attempt}/3 failed ({note}) β€” retrying in 5s")
119
+ time.sleep(5)
120
+
121
+ if not docx_path:
122
+ print(f" [{uid}] FAILED β€” {note}")
123
+ failed.append((uid, note))
124
+ continue
125
+
126
+ spec_number, version = parse_cr_cover(docx_path)
127
+ if not spec_number or not version:
128
+ print(f" [{uid}] WARNING: could not parse cover page β€” skipping")
129
+ failed.append((uid, "cover page parse failed"))
130
+ continue
131
+
132
+ print(f" [{uid}] -> TS {spec_number} v{version}")
133
+ new_records.append({
134
+ "excel_hash": excel_hash,
135
+ "meeting_label": meeting_label,
136
+ "uid": uid,
137
+ "title": title,
138
+ "submitted_by": submitted_by,
139
+ "spec_number": spec_number,
140
+ "version": version,
141
+ "parsed_at": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
142
+ })
143
+
144
+ print()
145
+ print(f"Summary: {len(new_records)} new, {skipped} skipped, {len(failed)} failed")
146
+ if failed:
147
+ print("Failed CRs:")
148
+ for uid, reason in failed:
149
+ print(f" [{uid}] {reason}")
150
+ print()
151
+
152
+ # ── 5. Merge and push ─────────────────────────────────────────────────────
153
+ if new_records:
154
+ all_records = existing + new_records
155
+ print(f"Pushing {len(all_records)} record(s) to {args.hf_repo}...")
156
+ try:
157
+ push_hf_index(all_records, hf_token, args.hf_repo)
158
+ print(" Push successful")
159
+ except Exception as e:
160
+ sys.exit(f"ERROR pushing to HF: {e}")
161
+ else:
162
+ print("No new records to push.")
163
+
164
+ # ── 6. Sentinel line (watched by app.py) ──────────────────────────────────
165
+ print(f"INDEX_COMPLETE excel_hash={excel_hash}")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
scripts/fetch_crs.py CHANGED
@@ -57,6 +57,23 @@ def parse_excel(excel_path: str, person_name: str):
57
  raise ValueError(f"Unsupported file extension: {ext!r}. Expected .xls or .xlsx")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def _name_pattern(name: str) -> re.Pattern:
61
  return re.compile(r"\b" + re.escape(name) + r"\b", re.IGNORECASE)
62
 
@@ -169,6 +186,103 @@ def _parse_xlsx(path: Path, person_name: str):
169
  return results
170
 
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  # ---------------------------------------------------------------------------
173
  # Step 2 β€” Download CR DOCXs
174
  # ---------------------------------------------------------------------------
 
57
  raise ValueError(f"Unsupported file extension: {ext!r}. Expected .xls or .xlsx")
58
 
59
 
60
+ def parse_excel_all_accepted(excel_path: str):
61
+ """
62
+ Return list of (uid, title, submitted_by) for ALL Accepted CRs
63
+ regardless of who submitted them. Used by build_cr_index.py.
64
+ Handles both .xls and .xlsx.
65
+ """
66
+ path = Path(wsl_path(excel_path))
67
+ ext = path.suffix.lower()
68
+
69
+ if ext == ".xls":
70
+ return _parse_xls_all(path)
71
+ elif ext == ".xlsx":
72
+ return _parse_xlsx_all(path)
73
+ else:
74
+ raise ValueError(f"Unsupported file extension: {ext!r}. Expected .xls or .xlsx")
75
+
76
+
77
  def _name_pattern(name: str) -> re.Pattern:
78
  return re.compile(r"\b" + re.escape(name) + r"\b", re.IGNORECASE)
79
 
 
186
  return results
187
 
188
 
189
+ def _parse_xls_all(path: Path):
190
+ """Return (uid, title, submitted_by) for all Accepted CRs (no person filter)."""
191
+ try:
192
+ import xlrd
193
+ except ImportError:
194
+ sys.exit("ERROR: xlrd is not installed. Run: pip install xlrd")
195
+
196
+ wb = xlrd.open_workbook(str(path))
197
+ try:
198
+ ws = wb.sheet_by_name("Contributions")
199
+ except xlrd.XLRDError:
200
+ ws = wb.sheet_by_index(0)
201
+
202
+ headers = [str(ws.cell_value(0, c)).strip() for c in range(ws.ncols)]
203
+ col = {h: i for i, h in enumerate(headers)}
204
+
205
+ uid_col = col.get("Uid") or col.get("UID") or col.get("uid")
206
+ type_col = col.get("Type") or col.get("type")
207
+ status_col = col.get("Status") or col.get("status")
208
+ by_col = col.get("SubmittedBy") or col.get("Submitted By") or col.get("submittedby")
209
+ title_col = col.get("Title") or col.get("title")
210
+
211
+ for name, c in [("Uid", uid_col), ("Type", type_col),
212
+ ("Status", status_col), ("SubmittedBy", by_col)]:
213
+ if c is None:
214
+ raise ValueError(f"Column {name!r} not found. Available: {list(col.keys())}")
215
+
216
+ results = []
217
+ for r in range(2, ws.nrows):
218
+ uid = str(ws.cell_value(r, uid_col)).strip()
219
+ doc_type = str(ws.cell_value(r, type_col)).strip()
220
+ status = str(ws.cell_value(r, status_col)).strip()
221
+ submitted_by = str(ws.cell_value(r, by_col)).strip()
222
+ title = str(ws.cell_value(r, title_col)).strip() if title_col is not None else ""
223
+
224
+ if doc_type != "CR":
225
+ continue
226
+ if status != "Accepted":
227
+ continue
228
+
229
+ results.append((uid, title, submitted_by))
230
+
231
+ return results
232
+
233
+
234
+ def _parse_xlsx_all(path: Path):
235
+ """Return (uid, title, submitted_by) for all Accepted CRs (no person filter)."""
236
+ try:
237
+ import openpyxl
238
+ except ImportError:
239
+ sys.exit("ERROR: openpyxl is not installed. Run: pip install openpyxl")
240
+
241
+ wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
242
+ ws = wb["Contributions"] if "Contributions" in wb.sheetnames else wb.active
243
+
244
+ rows = iter(ws.iter_rows(values_only=True))
245
+ header_row = next(rows)
246
+ headers = [str(h).strip() if h is not None else "" for h in header_row]
247
+ col = {h: i for i, h in enumerate(headers)}
248
+
249
+ next(rows, None) # skip empty duplicate row
250
+
251
+ uid_col = col.get("Uid") or col.get("UID") or col.get("uid")
252
+ type_col = col.get("Type") or col.get("type")
253
+ status_col = col.get("Status") or col.get("status")
254
+ by_col = col.get("SubmittedBy") or col.get("Submitted By") or col.get("submittedby")
255
+ title_col = col.get("Title") or col.get("title")
256
+
257
+ for name, c in [("Uid", uid_col), ("Type", type_col),
258
+ ("Status", status_col), ("SubmittedBy", by_col)]:
259
+ if c is None:
260
+ raise ValueError(f"Column {name!r} not found. Available: {list(col.keys())}")
261
+
262
+ results = []
263
+ for row in rows:
264
+ def cell(c):
265
+ v = row[c] if c < len(row) else None
266
+ return str(v).strip() if v is not None else ""
267
+
268
+ uid = cell(uid_col)
269
+ doc_type = cell(type_col)
270
+ status = cell(status_col)
271
+ submitted_by = cell(by_col)
272
+ title = cell(title_col) if title_col is not None else ""
273
+
274
+ if not uid:
275
+ continue
276
+ if doc_type != "CR":
277
+ continue
278
+ if status != "Accepted":
279
+ continue
280
+
281
+ results.append((uid, title, submitted_by))
282
+
283
+ return results
284
+
285
+
286
  # ---------------------------------------------------------------------------
287
  # Step 2 β€” Download CR DOCXs
288
  # ---------------------------------------------------------------------------
scripts/hf_cr_index.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_cr_index.py β€” Shared HuggingFace dataset index helpers.
3
+
4
+ Used by both build_cr_index.py and orchestrate_cr.py.
5
+ Depends only on huggingface_hub (pip install huggingface_hub).
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ def load_hf_index(hf_token: str, hf_repo: str) -> list[dict]:
13
+ """
14
+ Download cr_index.jsonl from the HF dataset and return parsed records.
15
+ Returns an empty list if the file does not exist yet.
16
+ """
17
+ from huggingface_hub import hf_hub_download
18
+ from huggingface_hub.errors import EntryNotFoundError
19
+
20
+ try:
21
+ path = hf_hub_download(
22
+ repo_id=hf_repo,
23
+ filename="cr_index.jsonl",
24
+ repo_type="dataset",
25
+ token=hf_token,
26
+ )
27
+ text = Path(path).read_text(encoding="utf-8")
28
+ return [json.loads(line) for line in text.splitlines() if line.strip()]
29
+ except EntryNotFoundError:
30
+ return []
31
+
32
+
33
+ def push_hf_index(records: list[dict], hf_token: str, hf_repo: str) -> None:
34
+ """
35
+ Push all records as cr_index.jsonl to the HF dataset repo.
36
+ Creates the repo if it does not exist.
37
+ """
38
+ from huggingface_hub import HfApi
39
+
40
+ api = HfApi()
41
+ api.create_repo(
42
+ repo_id=hf_repo,
43
+ repo_type="dataset",
44
+ exist_ok=True,
45
+ token=hf_token,
46
+ )
47
+ tmp = Path("/tmp/cr_index.jsonl")
48
+ tmp.write_text(
49
+ "\n".join(json.dumps(r, ensure_ascii=False) for r in records),
50
+ encoding="utf-8",
51
+ )
52
+ api.upload_file(
53
+ path_or_fileobj=str(tmp),
54
+ path_in_repo="cr_index.jsonl",
55
+ repo_id=hf_repo,
56
+ repo_type="dataset",
57
+ token=hf_token,
58
+ )
scripts/orchestrate_cr.py CHANGED
@@ -16,6 +16,11 @@ Arguments:
16
  Options:
17
  --output-dir Base output folder (default: ~/CR_Processing)
18
  --author Tracked-change author name (default: "CR Application")
 
 
 
 
 
19
  """
20
 
21
  import argparse
@@ -25,6 +30,7 @@ import io
25
  import json
26
  import os
27
  import re
 
28
  import sys
29
  import time
30
  from pathlib import Path
@@ -75,6 +81,249 @@ class _TeeWriter:
75
  self._real.flush()
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  # ── Main ──────────────────────────────────────────────────────────────────────
79
 
80
  def main():
@@ -86,7 +335,7 @@ def main():
86
  'excel_path',
87
  nargs='?',
88
  default=None,
89
- help='Path to .xls or .xlsx contribution list (not required in --retry-mode)',
90
  )
91
  ap.add_argument(
92
  'person_name',
@@ -109,10 +358,38 @@ def main():
109
  action='store_true',
110
  help='Skip steps 1-4; apply CRs to TSs listed in failed_ts.json that now have their DOCX on disk',
111
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  args = ap.parse_args()
113
 
114
- if not args.retry_mode and not args.excel_path:
115
- ap.error('excel_path is required when not in --retry-mode')
 
 
116
 
117
  eol_user = os.environ.get("EOL_USER", "")
118
  eol_password = os.environ.get("EOL_PASSWORD", "")
@@ -120,8 +397,9 @@ def main():
120
  sys.exit("ERROR: EOL_USER and EOL_PASSWORD must be set")
121
 
122
  output_dir = Path(wsl_path(args.output_dir)).expanduser()
123
- cr_dir = output_dir / 'CRs'
124
- ts_dir = output_dir / 'TS' # spec subfolders created per-TS below
 
125
  cr_dir.mkdir(parents=True, exist_ok=True)
126
  ts_dir.mkdir(parents=True, exist_ok=True)
127
 
@@ -159,7 +437,6 @@ def main():
159
  print(f' [TS {spec_number} v{version}] DOCX found β€” will apply')
160
  else:
161
  print(f' [TS {spec_number} v{version}] DOCX missing β€” skipping')
162
- # Reconstruct cr_paths for each UID
163
  cr_entry_dir = Path(entry['cr_dir'])
164
  for uid in entry['cr_uids']:
165
  extracted = cr_entry_dir / f'{uid}_extracted.docx'
@@ -169,7 +446,7 @@ def main():
169
  elif plain.exists():
170
  cr_paths[uid] = plain
171
 
172
- # ── Steps 5 & 6 (retry mode falls through to shared loop below) ──────
173
  report = []
174
 
175
  for (spec_number, version), uids in ts_groups.items():
@@ -246,7 +523,6 @@ def main():
246
 
247
  for line in log_lines:
248
  print(f' {line}')
249
- # Bubble every un-applied change into the warnings list
250
  for line in log_lines:
251
  if line.strip().startswith('ERROR'):
252
  errors.append(line.strip())
@@ -332,6 +608,87 @@ def main():
332
  print(f' ! {err}')
333
  return
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  excel_path = wsl_path(args.excel_path)
336
 
337
  # ── Step 1: Parse Excel ───────────────────────────────────────────────────
@@ -350,242 +707,21 @@ def main():
350
  print('Nothing to process.')
351
  return
352
 
353
- # ── Step 2: Download CR DOCXs ─────────────────────────────────────────────
354
- _section('Step 2 β€” Downloading CR DOCXs')
355
- cr_paths = {} # uid -> Path
356
-
357
- for uid, _ in cr_list:
358
- docx_path, note = download_cr(uid, cr_dir, eol_user, eol_password)
359
- if docx_path:
360
- cr_paths[uid] = docx_path
361
- print(f' [{uid}] OK ({note}) β€” {docx_path.name}')
362
-
363
- n_cr_failed = len(cr_list) - len(cr_paths)
364
- if n_cr_failed:
365
- print(f' {len(cr_paths)}/{len(cr_list)} downloaded ({n_cr_failed} failed β€” details in warnings)')
366
- else:
367
- print(f' All {len(cr_list)} CR(s) downloaded successfully')
368
-
369
- # ── Step 3: Parse cover pages β†’ group by target TS ───────────────────────
370
- _section('Step 3 β€” Parsing CR cover pages')
371
- ts_groups = {} # (spec_number, version) -> [uid, ...]
372
- uid_cover_failed = []
373
-
374
- for uid in cr_paths:
375
- spec_number, version = parse_cr_cover(cr_paths[uid])
376
- if spec_number and version:
377
- key = (spec_number, version)
378
- ts_groups.setdefault(key, []).append(uid)
379
- print(f' [{uid}] -> TS {spec_number} v{version}')
380
- else:
381
- uid_cover_failed.append(uid)
382
- print(f' [{uid}] WARNING: could not parse cover page β€” skipping')
383
-
384
- if not ts_groups:
385
- print('\nNo TSs identified. Nothing to apply.')
386
- return
387
-
388
- # ── Step 4: Download TSs ──────────────────────────────────────────────────
389
- _section('Step 4 β€” Downloading TSs')
390
- ts_paths = {} # (spec_number, version) -> Path
391
- spec_dirs = {} # (spec_number, version) -> Path (per-spec subfolder)
392
-
393
- for (spec_number, version) in ts_groups:
394
- spec_compact = spec_number.replace(' ', '')
395
- spec_dir = ts_dir / spec_compact
396
- spec_dir.mkdir(parents=True, exist_ok=True)
397
- spec_dirs[(spec_number, version)] = spec_dir
398
-
399
- print(f' [TS {spec_number} v{version}] ', end='', flush=True)
400
- filename, note = None, "not attempted"
401
- for attempt in range(1, 4):
402
- filename, note = download_ts(spec_number, version, spec_dir, eol_user, eol_password)
403
- if filename:
404
- break
405
- if attempt < 3:
406
- print(f'\n [attempt {attempt}/3 failed β€” retrying in 5s: {note}]', flush=True)
407
- print(f' [TS {spec_number} v{version}] ', end='', flush=True)
408
- time.sleep(5)
409
- else:
410
- print(f'\n [all 3 attempts failed]', flush=True)
411
- if filename:
412
- ts_paths[(spec_number, version)] = spec_dir / filename
413
- print(f'OK ({note}) β€” {spec_compact}/{filename}')
414
- else:
415
- print(f'FAILED β€” {note}')
416
-
417
- # Write failed_ts.json (even when empty so app.py can detect "no failures")
418
- failed_ts_entries = [
419
- {
420
- "spec_number": spec_number,
421
- "version": version,
422
- "spec_compact": spec_number.replace(' ', ''),
423
- "spec_dir": str(spec_dirs[(spec_number, version)]),
424
- "expected_filename": f"ts_{spec_number.replace(' ', '')}_v{version}.docx",
425
- "cr_uids": ts_groups[(spec_number, version)],
426
- "cr_dir": str(cr_dir),
427
- }
428
- for (spec_number, version) in ts_groups
429
- if (spec_number, version) not in ts_paths
430
- ]
431
- (output_dir / "failed_ts.json").write_text(
432
- json.dumps(failed_ts_entries, indent=2)
433
  )
434
 
435
- # ── Steps 5 & 6: Apply CRs + Finalise each TS ────────────────────────────
436
- _section('Steps 5 & 6 β€” Applying CRs and Finalising Metadata')
437
- report = [] # (ts_key, n_ok, n_skip, n_crs, out_path, log_path, errors)
438
-
439
- for (spec_number, version), uids in ts_groups.items():
440
- ts_key = f'TS {spec_number} v{version}'
441
- spec_compact = spec_number.replace(' ', '')
442
- spec_dir = spec_dirs.get((spec_number, version), ts_dir / spec_compact)
443
- spec_dir.mkdir(parents=True, exist_ok=True)
444
-
445
- # Derive new version early so filenames are known upfront
446
- new_v = derive_new_version(version)
447
- stem = f'ts_{spec_compact}_v{new_v}_was_v{version}'
448
- ts_applied = spec_dir / f'ts_{spec_compact}_v{version}_applied.docx'
449
- ts_final = spec_dir / f'{stem}.docx'
450
- log_path = spec_dir / f'{stem}.log'
451
- errors = []
452
-
453
- print(f'\n-- {ts_key} ({len(uids)} CR(s): {", ".join(uids)}) --')
454
-
455
- if (spec_number, version) not in ts_paths:
456
- msg = 'TS download failed β€” skipping'
457
- print(f' SKIP: {msg}')
458
- report.append((ts_key, 0, 0, len(uids), None, log_path, [msg]))
459
- continue
460
-
461
- ts_in = ts_paths[(spec_number, version)]
462
-
463
- # All per-TS output is captured to log_buf (tee: stdout + file)
464
- log_buf = io.StringIO()
465
- tee = _TeeWriter(sys.stdout, log_buf)
466
-
467
- with contextlib.redirect_stdout(tee):
468
- log_header = (
469
- f'Pipeline Log\n'
470
- f'TS: {spec_number} v{version} -> v{new_v}\n'
471
- f'CRs: {", ".join(uids)}\n'
472
- f'Date: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n'
473
- f'{"=" * 60}\n'
474
- )
475
- print(log_header, end='')
476
-
477
- # 5a. Parse all CR manifests and combine
478
- combined_manifest = []
479
- participating_uids = []
480
-
481
- for uid in uids:
482
- if uid not in cr_paths:
483
- errors.append(f'[{uid}] CR download had failed β€” skipped')
484
- continue
485
- print(f' Parsing {uid}... ', end='', flush=True)
486
- try:
487
- changes = parse_cr(cr_paths[uid])
488
- combined_manifest.extend(changes)
489
- participating_uids.append(uid)
490
- print(f'{len(changes)} change(s)')
491
- except Exception as e:
492
- errors.append(f'[{uid}] parse ERROR: {e}')
493
- print(f'ERROR: {e}')
494
-
495
- if not combined_manifest:
496
- print(' No changes parsed β€” skipping apply step.')
497
- report.append((ts_key, 0, 0, len(uids), None, log_path,
498
- errors + ['No changes parsed']))
499
- log_path.write_text(log_buf.getvalue(), encoding='utf-8')
500
- continue
501
-
502
- # 5b. Apply manifest to TS
503
- print(f' Applying {len(combined_manifest)} change(s) to {ts_in.name}...')
504
- try:
505
- n_ok, n_skip, log_lines = apply_manifest(
506
- ts_in, combined_manifest, ts_applied, author=author, date=tc_date
507
- )
508
- except Exception as e:
509
- errors.append(f'apply_manifest ERROR: {e}')
510
- print(f' ERROR: {e}')
511
- report.append((ts_key, 0, 0, len(uids), None, log_path, errors))
512
- log_path.write_text(log_buf.getvalue(), encoding='utf-8')
513
- continue
514
-
515
- for line in log_lines:
516
- print(f' {line}')
517
- # Bubble every un-applied change into the warnings list
518
- for line in log_lines:
519
- if line.strip().startswith('ERROR'):
520
- errors.append(line.strip())
521
- print(f' -> Applied: {n_ok} Skipped: {n_skip}')
522
-
523
- # 6. Finalise metadata (Change History, History, title paragraph)
524
- print(' Finalising metadata...')
525
- try:
526
- ts_doc = docx_lib.Document(str(ts_applied))
527
- rev = RevCounter(ts_doc)
528
-
529
- pub_ym, pub_month_year = compute_pub_date()
530
- old_v = version
531
-
532
- # Extract old date string from first paragraph
533
- title_text = ts_doc.paragraphs[0].text
534
- date_match = re.search(r'\((\d{4}-\d{2})\)', title_text)
535
- old_date_str = date_match.group(1) if date_match else ''
536
-
537
- print(f' Version: {old_v} -> {new_v}')
538
- print(f' Publication: {pub_month_year} ({pub_ym})')
539
-
540
- # One Change History row per CR
541
- for uid in participating_uids:
542
- try:
543
- meta = extract_cr_metadata(str(cr_paths[uid]))
544
- ch_cells = update_change_history_table(
545
- ts_doc, meta, pub_ym, old_v, new_v, rev, author, tc_date
546
- )
547
- print(f' [Change History] {uid}: {ch_cells}')
548
- except NoChangeHistoryTable:
549
- print(f' [Change History] {uid}: NOT PRESENT β€” this document has no Change History table (History table only)')
550
- except Exception as e:
551
- errors.append(f'[{uid}] Change History ERROR: {e}')
552
- print(f' [Change History] {uid}: ERROR β€” {e}')
553
-
554
- # One History row for the whole TS
555
- try:
556
- h_cells = update_history_table(
557
- ts_doc, new_v, pub_month_year, rev, author, tc_date
558
- )
559
- print(f' [History] {h_cells}')
560
- except Exception as e:
561
- errors.append(f'History table ERROR: {e}')
562
- print(f' [History] ERROR β€” {e}')
563
-
564
- # Title paragraph version + date
565
- if old_date_str:
566
- try:
567
- update_title_para(
568
- ts_doc, old_v, new_v, old_date_str, pub_ym, rev, author, tc_date
569
- )
570
- print(f' [Title] V{old_v} -> V{new_v}, ({old_date_str}) -> ({pub_ym})')
571
- except Exception as e:
572
- errors.append(f'Title update ERROR: {e}')
573
- print(f' [Title] ERROR β€” {e}')
574
- else:
575
- print(f' [Title] SKIP β€” no (YYYY-MM) pattern in: {title_text!r}')
576
-
577
- ts_doc.save(str(ts_final))
578
- print(f' Saved: {spec_compact}/{ts_final.name}')
579
- print(f' Log: {spec_compact}/{log_path.name}')
580
- report.append((ts_key, n_ok, n_skip, len(uids), ts_final, log_path, errors))
581
-
582
- except Exception as e:
583
- errors.append(f'Finalisation ERROR: {e}')
584
- print(f' Finalisation ERROR: {e}')
585
- report.append((ts_key, n_ok, n_skip, len(uids), ts_applied, log_path, errors))
586
-
587
- # Write log file after the tee context exits
588
- log_path.write_text(log_buf.getvalue(), encoding='utf-8')
589
 
590
  # ── Final Report ──────────────────────────────────────────────────────────
591
  _section('Final Report')
 
16
  Options:
17
  --output-dir Base output folder (default: ~/CR_Processing)
18
  --author Tracked-change author name (default: "CR Application")
19
+ --retry-mode Skip steps 1-4; apply CRs listed in failed_ts.json
20
+ --ts-mode Apply all CRs for a given spec number across all versions
21
+ --ts-id Spec number to process in ts-mode (e.g. "102 267")
22
+ --excel-hash Excel hash used to filter the HF index in ts-mode
23
+ --hf-repo HuggingFace dataset repo containing the CR index
24
  """
25
 
26
  import argparse
 
30
  import json
31
  import os
32
  import re
33
+ import shutil
34
  import sys
35
  import time
36
  from pathlib import Path
 
81
  self._real.flush()
82
 
83
 
84
+ # ── Shared Steps 2, 4, 5, 6 ──────────────────────────────────────────────────
85
+
86
+ def _run_steps_2_to_6(cr_list, ts_groups, output_dir, cr_dir, ts_dir,
87
+ eol_user, eol_password, author, tc_date):
88
+ """
89
+ Execute steps 2 (download CRs), 4 (download TSs), 5 & 6 (apply + finalise).
90
+
91
+ cr_list : list of (uid, title)
92
+ ts_groups : dict {(spec_number, version): [uid, ...]} β€” may be pre-built
93
+ (ts-mode) or None to trigger Step 3 (cover page parse).
94
+ """
95
+ # ── Step 2: Download CR DOCXs ─────────────────────────────────────────────
96
+ _section('Step 2 β€” Downloading CR DOCXs')
97
+ cr_paths = {} # uid -> Path
98
+
99
+ for uid, _ in cr_list:
100
+ docx_path, note = download_cr(uid, cr_dir, eol_user, eol_password)
101
+ if docx_path:
102
+ cr_paths[uid] = docx_path
103
+ print(f' [{uid}] OK ({note}) β€” {docx_path.name}')
104
+ else:
105
+ print(f' [{uid}] FAILED β€” {note}')
106
+
107
+ n_cr_failed = len(cr_list) - len(cr_paths)
108
+ if n_cr_failed:
109
+ print(f' {len(cr_paths)}/{len(cr_list)} downloaded ({n_cr_failed} failed)')
110
+ else:
111
+ print(f' All {len(cr_list)} CR(s) downloaded successfully')
112
+
113
+ # ── Step 3: Parse cover pages (only when ts_groups not pre-built) ─────────
114
+ if ts_groups is None:
115
+ _section('Step 3 β€” Parsing CR cover pages')
116
+ ts_groups = {}
117
+ uid_cover_failed = []
118
+
119
+ for uid in cr_paths:
120
+ spec_number, version = parse_cr_cover(cr_paths[uid])
121
+ if spec_number and version:
122
+ key = (spec_number, version)
123
+ ts_groups.setdefault(key, []).append(uid)
124
+ print(f' [{uid}] -> TS {spec_number} v{version}')
125
+ else:
126
+ uid_cover_failed.append(uid)
127
+ print(f' [{uid}] WARNING: could not parse cover page β€” skipping')
128
+
129
+ if not ts_groups:
130
+ print('\nNo TSs identified. Nothing to apply.')
131
+ return [], {}, {}, {}
132
+
133
+ # ── Step 4: Download TSs ──────────────────────────────────────────────────
134
+ _section('Step 4 β€” Downloading TSs')
135
+ ts_paths = {} # (spec_number, version) -> Path
136
+ spec_dirs = {} # (spec_number, version) -> Path (per-spec subfolder)
137
+
138
+ for (spec_number, version) in ts_groups:
139
+ spec_compact = spec_number.replace(' ', '')
140
+ spec_dir = ts_dir / spec_compact
141
+ spec_dir.mkdir(parents=True, exist_ok=True)
142
+ spec_dirs[(spec_number, version)] = spec_dir
143
+
144
+ print(f' [TS {spec_number} v{version}] ', end='', flush=True)
145
+ filename, note = None, "not attempted"
146
+ for attempt in range(1, 4):
147
+ filename, note = download_ts(spec_number, version, spec_dir, eol_user, eol_password)
148
+ if filename:
149
+ break
150
+ if attempt < 3:
151
+ print(f'\n [attempt {attempt}/3 failed β€” retrying in 5s: {note}]', flush=True)
152
+ print(f' [TS {spec_number} v{version}] ', end='', flush=True)
153
+ time.sleep(5)
154
+ else:
155
+ print(f'\n [all 3 attempts failed]', flush=True)
156
+ if filename:
157
+ ts_paths[(spec_number, version)] = spec_dir / filename
158
+ print(f'OK ({note}) β€” {spec_compact}/{filename}')
159
+ else:
160
+ print(f'FAILED β€” {note}')
161
+
162
+ # Write failed_ts.json
163
+ failed_ts_entries = [
164
+ {
165
+ "spec_number": spec_number,
166
+ "version": version,
167
+ "spec_compact": spec_number.replace(' ', ''),
168
+ "spec_dir": str(spec_dirs[(spec_number, version)]),
169
+ "expected_filename": f"ts_{spec_number.replace(' ', '')}_v{version}.docx",
170
+ "cr_uids": ts_groups[(spec_number, version)],
171
+ "cr_dir": str(cr_dir),
172
+ }
173
+ for (spec_number, version) in ts_groups
174
+ if (spec_number, version) not in ts_paths
175
+ ]
176
+ (output_dir / "failed_ts.json").write_text(
177
+ json.dumps(failed_ts_entries, indent=2)
178
+ )
179
+
180
+ # ── Steps 5 & 6: Apply CRs + Finalise each TS ────────────────────────────
181
+ _section('Steps 5 & 6 β€” Applying CRs and Finalising Metadata')
182
+ report = [] # (ts_key, n_ok, n_skip, n_crs, out_path, log_path, errors)
183
+
184
+ for (spec_number, version), uids in ts_groups.items():
185
+ ts_key = f'TS {spec_number} v{version}'
186
+ spec_compact = spec_number.replace(' ', '')
187
+ spec_dir = spec_dirs.get((spec_number, version), ts_dir / spec_compact)
188
+ spec_dir.mkdir(parents=True, exist_ok=True)
189
+
190
+ new_v = derive_new_version(version)
191
+ stem = f'ts_{spec_compact}_v{new_v}_was_v{version}'
192
+ ts_applied = spec_dir / f'ts_{spec_compact}_v{version}_applied.docx'
193
+ ts_final = spec_dir / f'{stem}.docx'
194
+ log_path = spec_dir / f'{stem}.log'
195
+ errors = []
196
+
197
+ print(f'\n-- {ts_key} ({len(uids)} CR(s): {", ".join(uids)}) --')
198
+
199
+ if (spec_number, version) not in ts_paths:
200
+ msg = 'TS download failed β€” skipping'
201
+ print(f' SKIP: {msg}')
202
+ report.append((ts_key, 0, 0, len(uids), None, log_path, [msg]))
203
+ continue
204
+
205
+ ts_in = ts_paths[(spec_number, version)]
206
+
207
+ log_buf = io.StringIO()
208
+ tee = _TeeWriter(sys.stdout, log_buf)
209
+
210
+ with contextlib.redirect_stdout(tee):
211
+ log_header = (
212
+ f'Pipeline Log\n'
213
+ f'TS: {spec_number} v{version} -> v{new_v}\n'
214
+ f'CRs: {", ".join(uids)}\n'
215
+ f'Date: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n'
216
+ f'{"=" * 60}\n'
217
+ )
218
+ print(log_header, end='')
219
+
220
+ combined_manifest = []
221
+ participating_uids = []
222
+
223
+ for uid in uids:
224
+ if uid not in cr_paths:
225
+ errors.append(f'[{uid}] CR download had failed β€” skipped')
226
+ continue
227
+ print(f' Parsing {uid}... ', end='', flush=True)
228
+ try:
229
+ changes = parse_cr(cr_paths[uid])
230
+ combined_manifest.extend(changes)
231
+ participating_uids.append(uid)
232
+ print(f'{len(changes)} change(s)')
233
+ except Exception as e:
234
+ errors.append(f'[{uid}] parse ERROR: {e}')
235
+ print(f'ERROR: {e}')
236
+
237
+ if not combined_manifest:
238
+ print(' No changes parsed β€” skipping apply step.')
239
+ report.append((ts_key, 0, 0, len(uids), None, log_path,
240
+ errors + ['No changes parsed']))
241
+ log_path.write_text(log_buf.getvalue(), encoding='utf-8')
242
+ continue
243
+
244
+ print(f' Applying {len(combined_manifest)} change(s) to {ts_in.name}...')
245
+ try:
246
+ n_ok, n_skip, log_lines = apply_manifest(
247
+ ts_in, combined_manifest, ts_applied, author=author, date=tc_date
248
+ )
249
+ except Exception as e:
250
+ errors.append(f'apply_manifest ERROR: {e}')
251
+ print(f' ERROR: {e}')
252
+ report.append((ts_key, 0, 0, len(uids), None, log_path, errors))
253
+ log_path.write_text(log_buf.getvalue(), encoding='utf-8')
254
+ continue
255
+
256
+ for line in log_lines:
257
+ print(f' {line}')
258
+ for line in log_lines:
259
+ if line.strip().startswith('ERROR'):
260
+ errors.append(line.strip())
261
+ print(f' -> Applied: {n_ok} Skipped: {n_skip}')
262
+
263
+ print(' Finalising metadata...')
264
+ try:
265
+ ts_doc = docx_lib.Document(str(ts_applied))
266
+ rev = RevCounter(ts_doc)
267
+
268
+ pub_ym, pub_month_year = compute_pub_date()
269
+ old_v = version
270
+
271
+ title_text = ts_doc.paragraphs[0].text
272
+ date_match = re.search(r'\((\d{4}-\d{2})\)', title_text)
273
+ old_date_str = date_match.group(1) if date_match else ''
274
+
275
+ print(f' Version: {old_v} -> {new_v}')
276
+ print(f' Publication: {pub_month_year} ({pub_ym})')
277
+
278
+ for uid in participating_uids:
279
+ try:
280
+ meta = extract_cr_metadata(str(cr_paths[uid]))
281
+ ch_cells = update_change_history_table(
282
+ ts_doc, meta, pub_ym, old_v, new_v, rev, author, tc_date
283
+ )
284
+ print(f' [Change History] {uid}: {ch_cells}')
285
+ except NoChangeHistoryTable:
286
+ print(f' [Change History] {uid}: NOT PRESENT β€” this document has no Change History table (History table only)')
287
+ except Exception as e:
288
+ errors.append(f'[{uid}] Change History ERROR: {e}')
289
+ print(f' [Change History] {uid}: ERROR β€” {e}')
290
+
291
+ try:
292
+ h_cells = update_history_table(
293
+ ts_doc, new_v, pub_month_year, rev, author, tc_date
294
+ )
295
+ print(f' [History] {h_cells}')
296
+ except Exception as e:
297
+ errors.append(f'History table ERROR: {e}')
298
+ print(f' [History] ERROR β€” {e}')
299
+
300
+ if old_date_str:
301
+ try:
302
+ update_title_para(
303
+ ts_doc, old_v, new_v, old_date_str, pub_ym, rev, author, tc_date
304
+ )
305
+ print(f' [Title] V{old_v} -> V{new_v}, ({old_date_str}) -> ({pub_ym})')
306
+ except Exception as e:
307
+ errors.append(f'Title update ERROR: {e}')
308
+ print(f' [Title] ERROR β€” {e}')
309
+ else:
310
+ print(f' [Title] SKIP β€” no (YYYY-MM) pattern in: {title_text!r}')
311
+
312
+ ts_doc.save(str(ts_final))
313
+ print(f' Saved: {spec_compact}/{ts_final.name}')
314
+ print(f' Log: {spec_compact}/{log_path.name}')
315
+ report.append((ts_key, n_ok, n_skip, len(uids), ts_final, log_path, errors))
316
+
317
+ except Exception as e:
318
+ errors.append(f'Finalisation ERROR: {e}')
319
+ print(f' Finalisation ERROR: {e}')
320
+ report.append((ts_key, n_ok, n_skip, len(uids), ts_applied, log_path, errors))
321
+
322
+ log_path.write_text(log_buf.getvalue(), encoding='utf-8')
323
+
324
+ return report, cr_paths, ts_paths, spec_dirs
325
+
326
+
327
  # ── Main ──────────────────────────────────────────────────────────────────────
328
 
329
  def main():
 
335
  'excel_path',
336
  nargs='?',
337
  default=None,
338
+ help='Path to .xls or .xlsx contribution list (not required in --retry-mode or --ts-mode)',
339
  )
340
  ap.add_argument(
341
  'person_name',
 
358
  action='store_true',
359
  help='Skip steps 1-4; apply CRs to TSs listed in failed_ts.json that now have their DOCX on disk',
360
  )
361
+ ap.add_argument(
362
+ '--ts-mode',
363
+ action='store_true',
364
+ help='Apply all CRs for a given spec number across all versions (uses HF index)',
365
+ )
366
+ ap.add_argument(
367
+ '--ts-id',
368
+ default='',
369
+ help='Spec number to process in ts-mode, e.g. "102 267"',
370
+ )
371
+ ap.add_argument(
372
+ '--excel-hash',
373
+ default='',
374
+ help='Excel hash used to filter the HF index in ts-mode',
375
+ )
376
+ ap.add_argument(
377
+ '--hf-repo',
378
+ default='OrganizedProgrammers/CR_Index',
379
+ help='HuggingFace dataset repo containing the CR index',
380
+ )
381
+ ap.add_argument(
382
+ '--cr-cache-dir',
383
+ default='',
384
+ help='Shared directory for caching downloaded CR DOCXs across runs '
385
+ '(default: <output-dir>/CRs)',
386
+ )
387
  args = ap.parse_args()
388
 
389
+ if args.ts_mode and not args.ts_id:
390
+ ap.error('--ts-id is required when using --ts-mode')
391
+ if not args.ts_mode and not args.retry_mode and not args.excel_path:
392
+ ap.error('excel_path is required when not in --retry-mode or --ts-mode')
393
 
394
  eol_user = os.environ.get("EOL_USER", "")
395
  eol_password = os.environ.get("EOL_PASSWORD", "")
 
397
  sys.exit("ERROR: EOL_USER and EOL_PASSWORD must be set")
398
 
399
  output_dir = Path(wsl_path(args.output_dir)).expanduser()
400
+ cr_cache = args.cr_cache_dir.strip()
401
+ cr_dir = Path(wsl_path(cr_cache)).expanduser() if cr_cache else output_dir / 'CRs'
402
+ ts_dir = output_dir / 'TS'
403
  cr_dir.mkdir(parents=True, exist_ok=True)
404
  ts_dir.mkdir(parents=True, exist_ok=True)
405
 
 
437
  print(f' [TS {spec_number} v{version}] DOCX found β€” will apply')
438
  else:
439
  print(f' [TS {spec_number} v{version}] DOCX missing β€” skipping')
 
440
  cr_entry_dir = Path(entry['cr_dir'])
441
  for uid in entry['cr_uids']:
442
  extracted = cr_entry_dir / f'{uid}_extracted.docx'
 
446
  elif plain.exists():
447
  cr_paths[uid] = plain
448
 
449
+ # ── Steps 5 & 6 (retry mode) ─────────────────────────────────────────
450
  report = []
451
 
452
  for (spec_number, version), uids in ts_groups.items():
 
523
 
524
  for line in log_lines:
525
  print(f' {line}')
 
526
  for line in log_lines:
527
  if line.strip().startswith('ERROR'):
528
  errors.append(line.strip())
 
608
  print(f' ! {err}')
609
  return
610
 
611
+ # ── TS mode β€” load HF index, skip Steps 1 & 3 ────────────────────────────
612
+ if args.ts_mode:
613
+ hf_token = os.environ.get("HF_TOKEN", "")
614
+ if not hf_token:
615
+ sys.exit("ERROR: HF_TOKEN must be set in ts-mode")
616
+
617
+ from hf_cr_index import load_hf_index
618
+
619
+ _section(f'TS mode β€” spec {args.ts_id!r}')
620
+ print(f'Loading HF index from {args.hf_repo}...')
621
+ try:
622
+ all_records = load_hf_index(hf_token, args.hf_repo)
623
+ except Exception as e:
624
+ sys.exit(f'ERROR loading HF index: {e}')
625
+
626
+ records = [
627
+ r for r in all_records
628
+ if r.get("excel_hash") == args.excel_hash
629
+ and r.get("spec_number") == args.ts_id
630
+ ]
631
+
632
+ if not records:
633
+ sys.exit(
634
+ f'ERROR: no indexed CRs found for spec {args.ts_id!r} '
635
+ f'with excel_hash={args.excel_hash!r}'
636
+ )
637
+
638
+ # Build ts_groups from index (bypasses Step 3)
639
+ ts_groups = {}
640
+ for r in records:
641
+ key = (r["spec_number"], r["version"])
642
+ ts_groups.setdefault(key, []).append(r["uid"])
643
+
644
+ # Build cr_list for Step 2 download
645
+ cr_list = [(r["uid"], r["title"]) for r in records]
646
+
647
+ print(f'Found {len(records)} CR(s) across {len(ts_groups)} version(s):')
648
+ for (spec, ver), uids in ts_groups.items():
649
+ print(f' TS {spec} v{ver}: {", ".join(uids)}')
650
+
651
+ report, cr_paths, ts_paths, spec_dirs = _run_steps_2_to_6(
652
+ cr_list, ts_groups, output_dir, cr_dir, ts_dir,
653
+ eol_user, eol_password, author, tc_date,
654
+ )
655
+
656
+ # Copy the CRs actually applied into the run output dir so the ZIP
657
+ # contains exactly the CRs used for this TS (only needed when using
658
+ # a shared CR cache that lives outside output_dir).
659
+ _run_cr_dir = output_dir / 'CRs'
660
+ if cr_dir.resolve() != _run_cr_dir.resolve():
661
+ _run_cr_dir.mkdir(parents=True, exist_ok=True)
662
+ for _p in cr_paths.values():
663
+ if _p.exists():
664
+ shutil.copy2(_p, _run_cr_dir / _p.name)
665
+
666
+ _section('Final Report (TS mode)')
667
+ n_success = sum(1 for r in report if r[4] is not None and not r[6])
668
+ n_partial = sum(1 for r in report if r[4] is not None and r[6])
669
+ n_failed = sum(1 for r in report if r[4] is None)
670
+
671
+ print(f'Spec: {args.ts_id}')
672
+ print(f'CRs found: {len(cr_list)}')
673
+ print(f'TSs updated: {n_success} fully OK, {n_partial} with warnings, {n_failed} failed')
674
+ print()
675
+
676
+ for ts_key, n_ok, n_skip, n_crs, out_path, log_path, errors in report:
677
+ status = 'OK' if out_path and not errors else ('WARN' if out_path else 'FAIL')
678
+ print(f' [{status}] {ts_key}')
679
+ print(f' CRs: {n_crs} | Body changes applied: {n_ok} | Skipped: {n_skip}')
680
+ if out_path:
681
+ print(f' Output: {out_path.parent.name}/{out_path.name}')
682
+ if log_path and log_path.exists():
683
+ print(f' Log: {log_path.parent.name}/{log_path.name}')
684
+ for err in errors:
685
+ print(f' ! {err}')
686
+
687
+ print()
688
+ print(f'Output directory: {output_dir}/')
689
+ return
690
+
691
+ # ── Normal mode ───────────────────────────────────────────────────────────
692
  excel_path = wsl_path(args.excel_path)
693
 
694
  # ── Step 1: Parse Excel ───────────────────────────────────────────────────
 
707
  print('Nothing to process.')
708
  return
709
 
710
+ # Steps 2, 3 (cover page parse), 4, 5, 6
711
+ report, cr_paths, ts_paths, spec_dirs = _run_steps_2_to_6(
712
+ cr_list, None, output_dir, cr_dir, ts_dir,
713
+ eol_user, eol_password, author, tc_date,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  )
715
 
716
+ # Copy the CRs actually applied into the run output dir so the ZIP
717
+ # contains exactly the CRs used for this run (only needed when using
718
+ # a shared CR cache that lives outside output_dir).
719
+ _run_cr_dir = output_dir / 'CRs'
720
+ if cr_dir.resolve() != _run_cr_dir.resolve():
721
+ _run_cr_dir.mkdir(parents=True, exist_ok=True)
722
+ for _p in cr_paths.values():
723
+ if _p.exists():
724
+ shutil.copy2(_p, _run_cr_dir / _p.name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
 
726
  # ── Final Report ──────────────────────────────────────────────────────────
727
  _section('Final Report')