calkit-python 0.46.0__py3-none-any.whl → 0.47.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calkit/__init__.py +34 -24
- calkit/cli/check.py +3 -1
- calkit/cli/latex.py +427 -0
- calkit/cli/new.py +48 -33
- calkit/docx.py +692 -0
- calkit/dvc/core.py +17 -0
- calkit/environments.py +2 -1
- calkit/latex.py +451 -1
- calkit/models/core.py +29 -18
- calkit/models/docx.py +79 -0
- calkit/models/pipeline.py +12 -4
- calkit/pipeline.py +25 -5
- calkit/tests/cli/test_new.py +43 -19
- calkit/tests/models/test_core.py +24 -0
- calkit/tests/models/test_pipeline.py +1 -1
- calkit/tests/test_docx.py +397 -0
- {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/METADATA +1 -1
- {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/RECORD +35 -32
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/WHEEL +0 -0
- {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py
CHANGED
|
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
|
|
16
16
|
dependencies,
|
|
17
17
|
detect,
|
|
18
18
|
docker,
|
|
19
|
+
docx,
|
|
19
20
|
dvc,
|
|
20
21
|
environments,
|
|
21
22
|
fs,
|
|
@@ -27,8 +28,10 @@ if TYPE_CHECKING:
|
|
|
27
28
|
invenio,
|
|
28
29
|
julia,
|
|
29
30
|
jupyter,
|
|
31
|
+
latex,
|
|
30
32
|
licenses,
|
|
31
33
|
magics,
|
|
34
|
+
markdown,
|
|
32
35
|
matlab,
|
|
33
36
|
models,
|
|
34
37
|
notebooks,
|
|
@@ -38,53 +41,60 @@ if TYPE_CHECKING:
|
|
|
38
41
|
pipeline,
|
|
39
42
|
procedures,
|
|
40
43
|
provenance,
|
|
44
|
+
questions,
|
|
41
45
|
releases,
|
|
42
46
|
reproducibility,
|
|
43
47
|
resources,
|
|
44
48
|
schema,
|
|
45
49
|
server,
|
|
46
50
|
templates,
|
|
51
|
+
workspace,
|
|
47
52
|
)
|
|
48
53
|
|
|
49
54
|
from .notebooks import declare_notebook # noqa: F401
|
|
50
55
|
|
|
51
56
|
# Lazy-load submodules to speed up imports
|
|
52
57
|
_SUBMODULES = {
|
|
53
|
-
"git",
|
|
54
|
-
"dvc",
|
|
55
|
-
"hub",
|
|
56
|
-
"fs",
|
|
57
|
-
"jupyter",
|
|
58
|
-
"config",
|
|
59
|
-
"models",
|
|
60
|
-
"office",
|
|
61
|
-
"templates",
|
|
62
|
-
"conda",
|
|
63
58
|
"calc",
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"reproducibility",
|
|
67
|
-
"github",
|
|
68
|
-
"invenio",
|
|
69
|
-
"releases",
|
|
70
|
-
"resources",
|
|
71
|
-
"licenses",
|
|
72
|
-
"overleaf",
|
|
73
|
-
"julia",
|
|
74
|
-
"notebooks",
|
|
75
|
-
"environments",
|
|
76
|
-
"pipeline",
|
|
77
|
-
"matlab",
|
|
59
|
+
"conda",
|
|
60
|
+
"config",
|
|
78
61
|
"datasets",
|
|
79
62
|
"dependencies",
|
|
80
63
|
"detect",
|
|
81
64
|
"docker",
|
|
65
|
+
"docx",
|
|
66
|
+
"dvc",
|
|
67
|
+
"environments",
|
|
68
|
+
"fs",
|
|
69
|
+
"git",
|
|
70
|
+
"github",
|
|
82
71
|
"gui",
|
|
72
|
+
"hub",
|
|
83
73
|
"install",
|
|
74
|
+
"invenio",
|
|
75
|
+
"julia",
|
|
76
|
+
"jupyter",
|
|
77
|
+
"latex",
|
|
78
|
+
"licenses",
|
|
84
79
|
"magics",
|
|
80
|
+
"markdown",
|
|
81
|
+
"matlab",
|
|
82
|
+
"models",
|
|
83
|
+
"notebooks",
|
|
84
|
+
"office",
|
|
85
85
|
"ops",
|
|
86
|
+
"overleaf",
|
|
87
|
+
"pipeline",
|
|
88
|
+
"procedures",
|
|
89
|
+
"provenance",
|
|
90
|
+
"questions",
|
|
91
|
+
"releases",
|
|
92
|
+
"reproducibility",
|
|
93
|
+
"resources",
|
|
86
94
|
"schema",
|
|
87
95
|
"server",
|
|
96
|
+
"templates",
|
|
97
|
+
"workspace",
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
|
calkit/cli/check.py
CHANGED
|
@@ -1517,7 +1517,9 @@ def check_docker_env(
|
|
|
1517
1517
|
deps_md5s=deps_md5s,
|
|
1518
1518
|
run_config=run_config,
|
|
1519
1519
|
)
|
|
1520
|
-
|
|
1520
|
+
# newline="\n" so the file is byte-identical on every platform,
|
|
1521
|
+
# rather than a Windows checkout flipping it to CRLF.
|
|
1522
|
+
with open(arch_lock_fpath, "w", newline="\n") as f:
|
|
1521
1523
|
json.dump(arch_lock, f, indent=4)
|
|
1522
1524
|
|
|
1523
1525
|
current_arch = get_docker_arch()
|
calkit/cli/latex.py
CHANGED
|
@@ -613,3 +613,430 @@ def _build_diff(
|
|
|
613
613
|
finally:
|
|
614
614
|
if not keep_tex and os.path.isfile(diff_tex):
|
|
615
615
|
os.remove(diff_tex)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@latex_app.command(name="to-docx")
|
|
619
|
+
def to_docx(
|
|
620
|
+
pdf_path: Annotated[str, typer.Argument(help="Compiled PDF to export.")],
|
|
621
|
+
source: Annotated[
|
|
622
|
+
str | None,
|
|
623
|
+
typer.Option(
|
|
624
|
+
"--source",
|
|
625
|
+
help=(
|
|
626
|
+
"Main .tex file. Defaults to the pipeline stage's target, "
|
|
627
|
+
"else the .tex next to the PDF."
|
|
628
|
+
),
|
|
629
|
+
),
|
|
630
|
+
] = None,
|
|
631
|
+
output: Annotated[
|
|
632
|
+
str | None,
|
|
633
|
+
typer.Option(
|
|
634
|
+
"--output",
|
|
635
|
+
"-o",
|
|
636
|
+
help="Where to write the .docx. Defaults to <pdf>-for-review.docx.",
|
|
637
|
+
),
|
|
638
|
+
] = None,
|
|
639
|
+
comment_only: Annotated[
|
|
640
|
+
bool,
|
|
641
|
+
typer.Option("--comment-only", help="Lock the document to comments."),
|
|
642
|
+
] = False,
|
|
643
|
+
force: Annotated[
|
|
644
|
+
bool,
|
|
645
|
+
typer.Option("--force", "-f", help="Overwrite an existing export."),
|
|
646
|
+
] = False,
|
|
647
|
+
) -> None:
|
|
648
|
+
"""Export a Word copy of a LaTeX document for review.
|
|
649
|
+
|
|
650
|
+
Uses Word's own PDF import, so the copy looks like the PDF, then records
|
|
651
|
+
inside the file which source line each paragraph came from and the text
|
|
652
|
+
as sent, so ``merge-docx`` can bring edits and comments back.
|
|
653
|
+
"""
|
|
654
|
+
import datetime
|
|
655
|
+
import sys
|
|
656
|
+
import uuid
|
|
657
|
+
from typing import Any
|
|
658
|
+
|
|
659
|
+
import calkit.docx
|
|
660
|
+
import calkit.git
|
|
661
|
+
import calkit.pipeline
|
|
662
|
+
from calkit.models.docx import LatexDocxExport
|
|
663
|
+
from calkit.models.pipeline import LatexStage
|
|
664
|
+
|
|
665
|
+
ck_info = calkit.load_calkit_info()
|
|
666
|
+
stages = ck_info.get("pipeline", {}).get("stages", {})
|
|
667
|
+
stage_name = calkit.pipeline.get_stage_for_output(pdf_path, ck_info)
|
|
668
|
+
# A latex stage's PDF is implied by its target, not listed as an output
|
|
669
|
+
if stage_name is None:
|
|
670
|
+
for name, stage in stages.items():
|
|
671
|
+
if isinstance(stage, dict) and stage.get("kind") == "latex":
|
|
672
|
+
pdf = LatexStage.model_validate(stage).pdf_path
|
|
673
|
+
if pdf == Path(pdf_path).as_posix():
|
|
674
|
+
stage_name = name
|
|
675
|
+
break
|
|
676
|
+
if source is None and stage_name is not None:
|
|
677
|
+
source = stages[stage_name].get("target_path")
|
|
678
|
+
if source is None:
|
|
679
|
+
source = Path(pdf_path).with_suffix(".tex").as_posix()
|
|
680
|
+
source = Path(source).as_posix()
|
|
681
|
+
if not os.path.isfile(source):
|
|
682
|
+
raise_error(f"Source {source} does not exist; pass --source")
|
|
683
|
+
if stage_name is not None:
|
|
684
|
+
typer.echo(f"Running stage {stage_name} to make sure the PDF is fresh")
|
|
685
|
+
subprocess.run(
|
|
686
|
+
[sys.executable, "-m", "calkit", "run", stage_name], check=True
|
|
687
|
+
)
|
|
688
|
+
if not os.path.isfile(pdf_path):
|
|
689
|
+
raise_error(f"{pdf_path} does not exist")
|
|
690
|
+
if output is None:
|
|
691
|
+
output = (
|
|
692
|
+
Path(pdf_path)
|
|
693
|
+
.with_name(Path(pdf_path).stem + "-for-review.docx")
|
|
694
|
+
.as_posix()
|
|
695
|
+
)
|
|
696
|
+
if os.path.exists(output) and not force:
|
|
697
|
+
raise_error(f"{output} already exists; use --force to overwrite it")
|
|
698
|
+
typer.echo("Converting the PDF with Word")
|
|
699
|
+
try:
|
|
700
|
+
calkit.docx.pdf_to_docx(pdf_path, output)
|
|
701
|
+
except RuntimeError as e:
|
|
702
|
+
raise_error(str(e))
|
|
703
|
+
doc = calkit.docx.Document(output)
|
|
704
|
+
lines = calkit.latex.flatten(source)
|
|
705
|
+
blks = calkit.latex.blocks(lines)
|
|
706
|
+
paras = doc.paragraphs
|
|
707
|
+
matched = calkit.latex.align([p.text for p in paras], blks)
|
|
708
|
+
# Bookmark each anchored paragraph; a block rendering as several
|
|
709
|
+
# paragraphs gets numbered suffixes
|
|
710
|
+
original: dict[str, str] = {}
|
|
711
|
+
para_for_block: dict[int, Any] = {}
|
|
712
|
+
seen: dict[str, int] = {}
|
|
713
|
+
for i, (para, blk) in enumerate(zip(paras, matched)):
|
|
714
|
+
if blk is None or para.element is None:
|
|
715
|
+
continue
|
|
716
|
+
name = calkit.latex.make_bookmark_name(blk.path, blk.lineno)
|
|
717
|
+
seen[name] = seen.get(name, 0) + 1
|
|
718
|
+
if seen[name] > 1:
|
|
719
|
+
name = f"{name}_{seen[name]}"
|
|
720
|
+
doc.add_bookmark(para.element, name, 9000 + i)
|
|
721
|
+
original[name] = para.text
|
|
722
|
+
para_for_block.setdefault(id(blk), para.element)
|
|
723
|
+
# Existing comment blocks in the source go out as Word comments
|
|
724
|
+
threads, anchors, highlights, resolved = [], [], [], []
|
|
725
|
+
for path in sorted({ln.path for ln in lines}):
|
|
726
|
+
file_lines = Path(path).read_text(encoding="utf-8").split("\n")
|
|
727
|
+
for tc in calkit.latex.parse_comments(file_lines):
|
|
728
|
+
after = tc.lineno + tc.nlines
|
|
729
|
+
blk = next(
|
|
730
|
+
(b for b in blks if b.path == path and b.lineno >= after), None
|
|
731
|
+
)
|
|
732
|
+
if blk is not None and id(blk) in para_for_block:
|
|
733
|
+
threads.append(tc.messages())
|
|
734
|
+
anchors.append(para_for_block[id(blk)])
|
|
735
|
+
highlights.append(
|
|
736
|
+
(tc.highlight, tc.highlight_occ) if tc.highlight else None
|
|
737
|
+
)
|
|
738
|
+
resolved.append(tc.resolved)
|
|
739
|
+
doc.add_comments(threads, anchors, highlights, resolved)
|
|
740
|
+
if comment_only:
|
|
741
|
+
doc.protect("comments")
|
|
742
|
+
else:
|
|
743
|
+
doc.track_changes()
|
|
744
|
+
doc.show_all_markup()
|
|
745
|
+
rev, dirty = None, False
|
|
746
|
+
try:
|
|
747
|
+
repo = calkit.git.get_repo()
|
|
748
|
+
rev = repo.head.commit.hexsha
|
|
749
|
+
dirty = any(repo.is_dirty(path=p) for p in {ln.path for ln in lines})
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
export_id = str(uuid.uuid4())
|
|
753
|
+
doc.write_original(
|
|
754
|
+
calkit.docx.Original(
|
|
755
|
+
export_id, rev, source, original, doc.media_hashes
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
doc.set_identifier(f"calkit-latex-export:{export_id}:{rev or ''}:{source}")
|
|
759
|
+
doc.save()
|
|
760
|
+
record = LatexDocxExport(
|
|
761
|
+
id=export_id,
|
|
762
|
+
created=datetime.datetime.now(datetime.timezone.utc),
|
|
763
|
+
source=source,
|
|
764
|
+
pdf=Path(pdf_path).as_posix(),
|
|
765
|
+
docx=Path(output).as_posix(),
|
|
766
|
+
rev=rev,
|
|
767
|
+
dirty=dirty,
|
|
768
|
+
permission="comment" if comment_only else "suggest",
|
|
769
|
+
paragraphs=len(original),
|
|
770
|
+
unanchored=sum(
|
|
771
|
+
1 for p, m in zip(paras, matched) if m is None and p.text
|
|
772
|
+
),
|
|
773
|
+
comments_exported=len(threads),
|
|
774
|
+
files={
|
|
775
|
+
p: "md5:" + calkit.get_md5(p)
|
|
776
|
+
for p in sorted(
|
|
777
|
+
{ln.path for ln in lines}
|
|
778
|
+
| {Path(pdf_path).as_posix(), Path(output).as_posix()}
|
|
779
|
+
)
|
|
780
|
+
},
|
|
781
|
+
)
|
|
782
|
+
os.makedirs(calkit.latex.DOCX_EXPORTS_DIR, exist_ok=True)
|
|
783
|
+
with open(
|
|
784
|
+
os.path.join(calkit.latex.DOCX_EXPORTS_DIR, f"{export_id}.json"),
|
|
785
|
+
"w",
|
|
786
|
+
encoding="utf-8",
|
|
787
|
+
newline="\n",
|
|
788
|
+
) as f:
|
|
789
|
+
f.write(record.model_dump_json(indent=2))
|
|
790
|
+
typer.echo(
|
|
791
|
+
f"Wrote {output} ({record.paragraphs} paragraphs anchored, "
|
|
792
|
+
f"{record.unanchored} not, {len(threads)} comments)"
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@latex_app.command(name="merge-docx")
|
|
797
|
+
def merge_docx(
|
|
798
|
+
docx_path: Annotated[str, typer.Argument(help="Reviewed .docx to merge.")],
|
|
799
|
+
no_comments: Annotated[
|
|
800
|
+
bool,
|
|
801
|
+
typer.Option(
|
|
802
|
+
"--no-comments", help="Don't write comments to the .tex."
|
|
803
|
+
),
|
|
804
|
+
] = False,
|
|
805
|
+
) -> None:
|
|
806
|
+
"""Merge a reviewed Word document back into the LaTeX source.
|
|
807
|
+
|
|
808
|
+
Accepted changes are applied. Tracked changes not yet accepted or
|
|
809
|
+
rejected, and edits that no longer fit the source, are left alone with
|
|
810
|
+
a warning: deal with them in Word and merge again. Comments become
|
|
811
|
+
comment blocks above the paragraph; threads resolved in Word are
|
|
812
|
+
marked resolved.
|
|
813
|
+
"""
|
|
814
|
+
import datetime
|
|
815
|
+
|
|
816
|
+
import calkit.docx
|
|
817
|
+
import calkit.git
|
|
818
|
+
from calkit.cli.core import warn
|
|
819
|
+
from calkit.models.docx import LatexDocxMerge, LatexDocxMergeChange
|
|
820
|
+
|
|
821
|
+
doc = calkit.docx.Document(docx_path)
|
|
822
|
+
original = doc.read_original()
|
|
823
|
+
if original is None:
|
|
824
|
+
raise_error(
|
|
825
|
+
f"{docx_path} was not exported by Calkit, or its metadata was "
|
|
826
|
+
"stripped by another application"
|
|
827
|
+
)
|
|
828
|
+
if not os.path.isfile(original.source):
|
|
829
|
+
raise_error(f"Source {original.source} does not exist")
|
|
830
|
+
# Figures come from the pipeline, so a picture swapped or edited in
|
|
831
|
+
# Word can't be merged
|
|
832
|
+
media = doc.media_hashes
|
|
833
|
+
changed = sorted(
|
|
834
|
+
name
|
|
835
|
+
for name in set(media) | set(original.media)
|
|
836
|
+
if media.get(name) != original.media.get(name)
|
|
837
|
+
)
|
|
838
|
+
if changed:
|
|
839
|
+
warn(
|
|
840
|
+
"Figures were changed in Word and can't be merged; edit the "
|
|
841
|
+
"pipeline instead: " + ", ".join(changed)
|
|
842
|
+
)
|
|
843
|
+
lines = calkit.latex.flatten(original.source)
|
|
844
|
+
blks = calkit.latex.blocks(lines)
|
|
845
|
+
path_for_hash = {
|
|
846
|
+
calkit.latex.make_bookmark_name(p, 0).split("_")[1]: p
|
|
847
|
+
for p in {ln.path for ln in lines}
|
|
848
|
+
}
|
|
849
|
+
edits: dict[str, list[tuple[int, int, list[str]]]] = {}
|
|
850
|
+
changes: list[LatexDocxMergeChange] = []
|
|
851
|
+
for para in doc.paragraphs:
|
|
852
|
+
if para.bookmark is None or para.bookmark not in original.paragraphs:
|
|
853
|
+
continue
|
|
854
|
+
sent = original.paragraphs[para.bookmark]
|
|
855
|
+
if para.text == sent:
|
|
856
|
+
continue
|
|
857
|
+
parts = para.bookmark.split("_")
|
|
858
|
+
path, lineno = path_for_hash.get(parts[1], ""), int(parts[2])
|
|
859
|
+
blk = calkit.latex.find_block(
|
|
860
|
+
blks, path, lineno, sent
|
|
861
|
+
) or calkit.latex.find_block(blks, path, lineno, para.text)
|
|
862
|
+
if blk is None:
|
|
863
|
+
warn(f"Can't place an edit from {path}:{lineno}: {para.text[:60]}")
|
|
864
|
+
changes.append(
|
|
865
|
+
LatexDocxMergeChange(
|
|
866
|
+
path=path, lineno=lineno, status="unplaced"
|
|
867
|
+
)
|
|
868
|
+
)
|
|
869
|
+
continue
|
|
870
|
+
loc = f"{blk.path}:{blk.lineno}"
|
|
871
|
+
if para.pending:
|
|
872
|
+
warn(f"Tracked change at {loc} not yet accepted or rejected")
|
|
873
|
+
changes.append(
|
|
874
|
+
LatexDocxMergeChange(
|
|
875
|
+
path=blk.path,
|
|
876
|
+
lineno=blk.lineno,
|
|
877
|
+
status="pending",
|
|
878
|
+
author=", ".join(para.authors) or None,
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
continue
|
|
882
|
+
sent_tex = calkit.latex.from_word_text(sent)
|
|
883
|
+
new_tex = calkit.latex.from_word_text(para.text)
|
|
884
|
+
if calkit.latex.already_applied(blk, sent_tex, new_tex):
|
|
885
|
+
changes.append(
|
|
886
|
+
LatexDocxMergeChange(
|
|
887
|
+
path=blk.path, lineno=blk.lineno, status="already-applied"
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
continue
|
|
891
|
+
new_lines = calkit.latex.apply_edit(blk, sent_tex, new_tex)
|
|
892
|
+
if new_lines is None:
|
|
893
|
+
warn(
|
|
894
|
+
f"Edit at {loc} touches markup; apply it by hand: {para.text[:60]}"
|
|
895
|
+
)
|
|
896
|
+
changes.append(
|
|
897
|
+
LatexDocxMergeChange(
|
|
898
|
+
path=blk.path, lineno=blk.lineno, status="unplaced"
|
|
899
|
+
)
|
|
900
|
+
)
|
|
901
|
+
continue
|
|
902
|
+
edits.setdefault(blk.path, []).append(
|
|
903
|
+
(blk.lineno, len(blk.lines), new_lines)
|
|
904
|
+
)
|
|
905
|
+
changes.append(
|
|
906
|
+
LatexDocxMergeChange(
|
|
907
|
+
path=blk.path, lineno=blk.lineno, status="applied"
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
typer.echo(f"Applied edit at {loc}")
|
|
911
|
+
files = {
|
|
912
|
+
p: Path(p).read_text(encoding="utf-8").split("\n")
|
|
913
|
+
for p in {ln.path for ln in lines}
|
|
914
|
+
}
|
|
915
|
+
for path, updates in edits.items():
|
|
916
|
+
for lineno, count, new_lines in sorted(updates, reverse=True):
|
|
917
|
+
files[path][lineno - 1 : lineno - 1 + count] = new_lines
|
|
918
|
+
added = updated = 0
|
|
919
|
+
if not no_comments:
|
|
920
|
+
# Threads keyed by root, anchored through the root's bookmark
|
|
921
|
+
comments = doc.comments
|
|
922
|
+
by_id = {c.para_id: c for c in comments}
|
|
923
|
+
roots = [c for c in comments if not c.parent_id]
|
|
924
|
+
# Resolve every thread to a block first, then edit each file from
|
|
925
|
+
# the bottom up so earlier line numbers stay valid
|
|
926
|
+
placed: list[tuple[calkit.latex.Block, calkit.latex.TexComment]] = []
|
|
927
|
+
for root in roots:
|
|
928
|
+
thread = [root] + [
|
|
929
|
+
c
|
|
930
|
+
for c in comments
|
|
931
|
+
if c.parent_id and by_id.get(c.parent_id) is root
|
|
932
|
+
]
|
|
933
|
+
tc = calkit.latex.TexComment(
|
|
934
|
+
[
|
|
935
|
+
calkit.latex.Entry(
|
|
936
|
+
c.author, c.text, date=calkit.latex.word_date(c.date)
|
|
937
|
+
)
|
|
938
|
+
for c in thread
|
|
939
|
+
],
|
|
940
|
+
highlight=root.highlight,
|
|
941
|
+
highlight_occ=root.highlight_occ,
|
|
942
|
+
resolved=root.done,
|
|
943
|
+
)
|
|
944
|
+
if root.bookmark is None:
|
|
945
|
+
warn(
|
|
946
|
+
f"Comment by {root.author} has no anchor: {root.text[:60]}"
|
|
947
|
+
)
|
|
948
|
+
continue
|
|
949
|
+
parts = root.bookmark.split("_")
|
|
950
|
+
path, lineno = path_for_hash.get(parts[1], ""), int(parts[2])
|
|
951
|
+
blk = calkit.latex.find_block(
|
|
952
|
+
blks, path, lineno, original.paragraphs.get(root.bookmark, "")
|
|
953
|
+
)
|
|
954
|
+
if blk is None:
|
|
955
|
+
warn(
|
|
956
|
+
f"Can't place a comment by {root.author}: {root.text[:60]}"
|
|
957
|
+
)
|
|
958
|
+
continue
|
|
959
|
+
placed.append((blk, tc))
|
|
960
|
+
for blk, tc in sorted(
|
|
961
|
+
placed, key=lambda x: (x[0].path, x[0].lineno), reverse=True
|
|
962
|
+
):
|
|
963
|
+
content = files[blk.path]
|
|
964
|
+
at = blk.lineno
|
|
965
|
+
existing = next(
|
|
966
|
+
(
|
|
967
|
+
e
|
|
968
|
+
for e in calkit.latex.parse_comments(content)
|
|
969
|
+
if e.author == tc.author and e.text == tc.text
|
|
970
|
+
),
|
|
971
|
+
None,
|
|
972
|
+
)
|
|
973
|
+
if existing is not None:
|
|
974
|
+
if (
|
|
975
|
+
existing.messages() == tc.messages()
|
|
976
|
+
and existing.resolved == tc.resolved
|
|
977
|
+
):
|
|
978
|
+
continue
|
|
979
|
+
# Word knows neither emails nor what the source already
|
|
980
|
+
# recorded, so carry those over for unchanged messages
|
|
981
|
+
known = {(e.author, e.text): e for e in existing.entries}
|
|
982
|
+
for e in tc.entries:
|
|
983
|
+
old_e = known.get((e.author, e.text))
|
|
984
|
+
if old_e is not None:
|
|
985
|
+
e.email = old_e.email
|
|
986
|
+
e.date = old_e.date or e.date
|
|
987
|
+
del content[
|
|
988
|
+
existing.lineno - 1 : existing.lineno - 1 + existing.nlines
|
|
989
|
+
]
|
|
990
|
+
if existing.lineno < at:
|
|
991
|
+
at -= existing.nlines
|
|
992
|
+
content[at - 1 : at - 1] = tc.render()
|
|
993
|
+
added += existing is None
|
|
994
|
+
updated += existing is not None
|
|
995
|
+
for path, content in files.items():
|
|
996
|
+
new = "\n".join(content)
|
|
997
|
+
if new != Path(path).read_text(encoding="utf-8"):
|
|
998
|
+
Path(path).write_text(new, encoding="utf-8")
|
|
999
|
+
rev = None
|
|
1000
|
+
try:
|
|
1001
|
+
rev = calkit.git.get_repo().head.commit.hexsha
|
|
1002
|
+
except Exception:
|
|
1003
|
+
pass
|
|
1004
|
+
seen = {a for p in doc.paragraphs for a in p.authors}
|
|
1005
|
+
seen |= {c.author for c in doc.comments}
|
|
1006
|
+
record = LatexDocxMerge(
|
|
1007
|
+
export_id=original.id,
|
|
1008
|
+
created=datetime.datetime.now(datetime.timezone.utc),
|
|
1009
|
+
docx=Path(docx_path).as_posix(),
|
|
1010
|
+
rev=rev,
|
|
1011
|
+
authors=sorted(seen),
|
|
1012
|
+
last_modified_by=doc.last_modified_by,
|
|
1013
|
+
changes=changes,
|
|
1014
|
+
comments_added=added,
|
|
1015
|
+
comments_updated=updated,
|
|
1016
|
+
files={
|
|
1017
|
+
p: "md5:" + calkit.get_md5(p)
|
|
1018
|
+
for p in sorted(
|
|
1019
|
+
{ln.path for ln in lines} | {Path(docx_path).as_posix()}
|
|
1020
|
+
)
|
|
1021
|
+
},
|
|
1022
|
+
)
|
|
1023
|
+
os.makedirs(calkit.latex.DOCX_MERGES_DIR, exist_ok=True)
|
|
1024
|
+
stamp = record.created.strftime("%Y%m%dT%H%M%S.%fZ")
|
|
1025
|
+
with open(
|
|
1026
|
+
os.path.join(
|
|
1027
|
+
calkit.latex.DOCX_MERGES_DIR, f"{original.id}-{stamp}.json"
|
|
1028
|
+
),
|
|
1029
|
+
"w",
|
|
1030
|
+
encoding="utf-8",
|
|
1031
|
+
newline="\n",
|
|
1032
|
+
) as f:
|
|
1033
|
+
f.write(record.model_dump_json(indent=2))
|
|
1034
|
+
counts = {
|
|
1035
|
+
s: sum(1 for c in changes if c.status == s)
|
|
1036
|
+
for s in ("applied", "already-applied", "pending", "unplaced")
|
|
1037
|
+
}
|
|
1038
|
+
typer.echo(
|
|
1039
|
+
f"Applied {counts['applied']} edits ({counts['already-applied']} "
|
|
1040
|
+
f"already there, {counts['pending']} pending, {counts['unplaced']} "
|
|
1041
|
+
f"unplaced); {added} comments added, {updated} updated"
|
|
1042
|
+
)
|
calkit/cli/new.py
CHANGED
|
@@ -65,22 +65,37 @@ def _person_from_options(
|
|
|
65
65
|
return person.model_dump(exclude_none=True)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def
|
|
69
|
-
template: str,
|
|
70
|
-
) -> tuple[str, str | None]:
|
|
71
|
-
"""
|
|
68
|
+
def _parse_template(
|
|
69
|
+
template: str, hub_url: str
|
|
70
|
+
) -> tuple[str, str | None, str | None]:
|
|
71
|
+
"""Resolve a template to its name, Git URL, and directory in the repo.
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
'owner/project[/dir]', or that under the hub URL, names a project on
|
|
74
|
+
the hub, whose Git URL is looked up later, so it comes back as None.
|
|
75
|
+
An HTTPS or SSH URL works on any host, e.g.,
|
|
76
|
+
'https://github.com/owner/repo/dir'. One repo can hold several
|
|
77
|
+
self-contained example projects, so a template may name a directory
|
|
78
|
+
within it; the first two path segments are the repo. Other URLs,
|
|
79
|
+
e.g., 'file://', are used as is.
|
|
76
80
|
"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
import re
|
|
82
|
+
|
|
83
|
+
hub_url = hub_url.rstrip("/")
|
|
84
|
+
for prefix in (hub_url + "/", hub_url.split("://", 1)[-1] + "/"):
|
|
85
|
+
if template.startswith(prefix):
|
|
86
|
+
template = template.removeprefix(prefix)
|
|
87
|
+
break
|
|
88
|
+
m = re.match(r"^(https?://[^/]+/|[\w.-]+@[\w.-]+:)(.+)$", template)
|
|
89
|
+
if m is None and "://" in template:
|
|
90
|
+
return template, template, None
|
|
91
|
+
base, path = m.groups() if m else (None, template)
|
|
92
|
+
parts = path.strip("/").split("/")
|
|
93
|
+
if len(parts) < 2:
|
|
94
|
+
raise_error(f"Template '{template}' should be 'owner/project'")
|
|
95
|
+
parts[1] = parts[1].removesuffix(".git")
|
|
96
|
+
subdir = "/".join(parts[2:]) or None
|
|
97
|
+
url = base + "/".join(parts[:2]) if base else None
|
|
98
|
+
return "/".join(parts), url, subdir
|
|
84
99
|
|
|
85
100
|
|
|
86
101
|
@new_app.command(name="project", cls=_NewProjectCommand)
|
|
@@ -137,10 +152,12 @@ def new_project(
|
|
|
137
152
|
str | None,
|
|
138
153
|
typer.Option(
|
|
139
154
|
"--template",
|
|
155
|
+
"--from",
|
|
140
156
|
"-t",
|
|
141
157
|
help=(
|
|
142
|
-
"Template from which to derive the project
|
|
143
|
-
"'
|
|
158
|
+
"Template from which to derive the project: a hub project "
|
|
159
|
+
"as 'owner/project' or its hub URL, or a Git URL on any "
|
|
160
|
+
"host, e.g., 'https://github.com/owner/repo/dir'."
|
|
144
161
|
),
|
|
145
162
|
),
|
|
146
163
|
] = None,
|
|
@@ -412,24 +429,22 @@ def new_project(
|
|
|
412
429
|
return
|
|
413
430
|
# If using a template, clone it first
|
|
414
431
|
if template:
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
# For now, assume consistency between hub projects and
|
|
418
|
-
# GitHub repo URLs
|
|
419
|
-
if "github.com" in template:
|
|
420
|
-
template_git_url = template
|
|
421
|
-
template_name = template.split("github.com")[-1][1:].removesuffix(
|
|
422
|
-
".git"
|
|
423
|
-
)
|
|
424
|
-
else:
|
|
425
|
-
template_name = template
|
|
426
|
-
template_git_url = f"https://github.com/{template}"
|
|
427
|
-
# A template can name a directory inside a repo, e.g.
|
|
428
|
-
# 'calkit/calkit/examples/markdown', so one repo can hold several
|
|
429
|
-
# self-contained examples.
|
|
430
|
-
template_git_url, template_subdir = _split_template_subdir(
|
|
431
|
-
template, template_git_url
|
|
432
|
+
template_name, template_git_url, template_subdir = _parse_template(
|
|
433
|
+
template, calkit.hub.get_hub_url()
|
|
432
434
|
)
|
|
435
|
+
if template_git_url is None:
|
|
436
|
+
project = "/".join(template_name.split("/")[:2])
|
|
437
|
+
typer.echo(f"Fetching Git repo URL for {project} from the hub")
|
|
438
|
+
try:
|
|
439
|
+
template_git_url = calkit.hub.get(f"/projects/{project}")[
|
|
440
|
+
"git_repo_url"
|
|
441
|
+
]
|
|
442
|
+
except Exception as e:
|
|
443
|
+
raise_error(
|
|
444
|
+
f"Could not fetch project {project} from the hub ({e}); "
|
|
445
|
+
"for a repo not on the hub, pass its URL, e.g., "
|
|
446
|
+
f"https://github.com/{template_name}"
|
|
447
|
+
)
|
|
433
448
|
if template_subdir is None:
|
|
434
449
|
# Now clone it
|
|
435
450
|
subprocess.run(["git", "clone", template_git_url, abs_path])
|