pyDiffTools 0.1.36__tar.gz → 0.1.37__tar.gz

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.
Files changed (114) hide show
  1. {pydifftools-0.1.36/pyDiffTools.egg-info → pydifftools-0.1.37}/PKG-INFO +10 -1
  2. {pydifftools-0.1.36 → pydifftools-0.1.37}/README.rst +9 -0
  3. {pydifftools-0.1.36 → pydifftools-0.1.37/pyDiffTools.egg-info}/PKG-INFO +10 -1
  4. {pydifftools-0.1.36 → pydifftools-0.1.37}/pyDiffTools.egg-info/SOURCES.txt +2 -0
  5. pydifftools-0.1.37/pydifftools/comment_migration.py +397 -0
  6. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/comment_tags.lua +70 -6
  7. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/comment_tags_margin.lua +66 -6
  8. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/comment_tags_no_comments.lua +22 -0
  9. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/comments.css +10 -5
  10. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/continuous.py +14 -1
  11. {pydifftools-0.1.36 → pydifftools-0.1.37}/pyproject.toml +1 -1
  12. pydifftools-0.1.37/tests/test_comment_migration.py +206 -0
  13. {pydifftools-0.1.36 → pydifftools-0.1.37}/LICENSE.md +0 -0
  14. {pydifftools-0.1.36 → pydifftools-0.1.37}/MANIFEST.in +0 -0
  15. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/_quarto.yml +0 -0
  16. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  17. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  18. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  19. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  20. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  21. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  22. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  23. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  24. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  25. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  26. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  27. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  28. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  29. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  30. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  31. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  32. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  33. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  34. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  35. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  36. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  37. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  38. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/example.qmd +0 -0
  39. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/example.tex +0 -0
  40. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/index.qmd +0 -0
  41. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  42. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  43. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  44. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  45. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/independent.qmd +0 -0
  46. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/index.qmd +0 -0
  47. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  48. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  49. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  50. {pydifftools-0.1.36 → pydifftools-0.1.37}/example_notebook/project1/test_include.qmd +0 -0
  51. {pydifftools-0.1.36 → pydifftools-0.1.37}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  52. {pydifftools-0.1.36 → pydifftools-0.1.37}/pyDiffTools.egg-info/entry_points.txt +0 -0
  53. {pydifftools-0.1.36 → pydifftools-0.1.37}/pyDiffTools.egg-info/requires.txt +0 -0
  54. {pydifftools-0.1.36 → pydifftools-0.1.37}/pyDiffTools.egg-info/top_level.txt +0 -0
  55. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/__init__.py +0 -0
  56. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/bibliography.py +0 -0
  57. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/browser_lifecycle.py +0 -0
  58. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/check_numbers.py +0 -0
  59. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/citation_dialog.py +0 -0
  60. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/command_line.py +0 -0
  61. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/command_registry.py +0 -0
  62. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/comment_functions.py +0 -0
  63. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/comment_toggle.js +0 -0
  64. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/copy_files.py +0 -0
  65. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/diff-doc.js +0 -0
  66. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/doc_contents.py +0 -0
  67. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/__init__.py +0 -0
  68. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/comparison.py +0 -0
  69. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  70. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/graph.py +0 -0
  71. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  72. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/watch_graph.py +0 -0
  73. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/flowchart/wgrph_view.js +0 -0
  74. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/forward_search.py +0 -0
  75. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/git_gd.py +0 -0
  76. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/git_gd_history.py +0 -0
  77. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/git_gd_image.py +0 -0
  78. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/git_gd_qt.py +0 -0
  79. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/html_comments.py +0 -0
  80. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/html_uncomments.py +0 -0
  81. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/log_example.py +0 -0
  82. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/match_spaces.py +0 -0
  83. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/notebook/__init__.py +0 -0
  84. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/notebook/display.py +0 -0
  85. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/notebook/fast_build.py +0 -0
  86. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/notebook/tex_to_qmd.py +0 -0
  87. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/onewordify.py +0 -0
  88. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/onewordify_undo.py +0 -0
  89. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/outline.py +0 -0
  90. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/rearrange_tex.py +0 -0
  91. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/searchacro.py +0 -0
  92. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/separate_comments.py +0 -0
  93. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/split_conflict.py +0 -0
  94. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/unseparate_comments.py +0 -0
  95. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/update_check.py +0 -0
  96. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/wrap_sentences.py +0 -0
  97. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/xml2xlsx.vbs +0 -0
  98. {pydifftools-0.1.36 → pydifftools-0.1.37}/pydifftools/zotero.py +0 -0
  99. {pydifftools-0.1.36 → pydifftools-0.1.37}/setup.cfg +0 -0
  100. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_bibliography.py +0 -0
  101. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_browser_lifecycle.py +0 -0
  102. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_check_single_use.py +0 -0
  103. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_command_line_help.py +0 -0
  104. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_continuous_shutdown.py +0 -0
  105. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_forward_search.py +0 -0
  106. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_git_gd_history.py +0 -0
  107. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_git_gd_image.py +0 -0
  108. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_git_gd_qt.py +0 -0
  109. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_rrng.py +0 -0
  110. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_tex_to_qmd.py +0 -0
  111. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_update_check.py +0 -0
  112. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_wrap_check.py +0 -0
  113. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_wrapping.py +0 -0
  114. {pydifftools-0.1.36 → pydifftools-0.1.37}/tests/test_zotero.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.36
3
+ Version: 0.1.37
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -109,6 +109,15 @@ included are (listed in order of fun/utility):
109
109
  Python file operations and Qt dialogs. Its automated runtime checks have
110
110
  been performed on Linux, not on Windows.
111
111
 
112
+ Comment bubbles use author tags such as ``<JFcom>…</JFcom>`` and
113
+ ``<JFcom-left>…</JFcom-left>``. Block comments can use Markdown Div classes
114
+ such as ``::: {.JFcom-left}``. Add a quoted six-digit hex color to the YAML
115
+ header (for example, ``JFcolor: '#5aa0ff'``) to enable that author's tags.
116
+ Before the first build, ``cpb`` offers to migrate legacy ``<comment>`` tags
117
+ and ``.comment-left/right`` blocks, and resolves author tags without a
118
+ matching color. New authors choose a hue in a Qt picker; ``cpb`` adds their
119
+ ``XXcolor`` field to the header.
120
+
112
121
  Declare a single local ``.bib`` file with ``bibliography: references.bib``
113
122
  in the Markdown YAML header; relative paths are resolved beside the
114
123
  Markdown file. Without that declaration, ``cpb`` uses a single adjacent
@@ -54,6 +54,15 @@ included are (listed in order of fun/utility):
54
54
  Python file operations and Qt dialogs. Its automated runtime checks have
55
55
  been performed on Linux, not on Windows.
56
56
 
57
+ Comment bubbles use author tags such as ``<JFcom>…</JFcom>`` and
58
+ ``<JFcom-left>…</JFcom-left>``. Block comments can use Markdown Div classes
59
+ such as ``::: {.JFcom-left}``. Add a quoted six-digit hex color to the YAML
60
+ header (for example, ``JFcolor: '#5aa0ff'``) to enable that author's tags.
61
+ Before the first build, ``cpb`` offers to migrate legacy ``<comment>`` tags
62
+ and ``.comment-left/right`` blocks, and resolves author tags without a
63
+ matching color. New authors choose a hue in a Qt picker; ``cpb`` adds their
64
+ ``XXcolor`` field to the header.
65
+
57
66
  Declare a single local ``.bib`` file with ``bibliography: references.bib``
58
67
  in the Markdown YAML header; relative paths are resolved beside the
59
68
  Markdown file. Without that declaration, ``cpb`` uses a single adjacent
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.36
3
+ Version: 0.1.37
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -109,6 +109,15 @@ included are (listed in order of fun/utility):
109
109
  Python file operations and Qt dialogs. Its automated runtime checks have
110
110
  been performed on Linux, not on Windows.
111
111
 
112
+ Comment bubbles use author tags such as ``<JFcom>…</JFcom>`` and
113
+ ``<JFcom-left>…</JFcom-left>``. Block comments can use Markdown Div classes
114
+ such as ``::: {.JFcom-left}``. Add a quoted six-digit hex color to the YAML
115
+ header (for example, ``JFcolor: '#5aa0ff'``) to enable that author's tags.
116
+ Before the first build, ``cpb`` offers to migrate legacy ``<comment>`` tags
117
+ and ``.comment-left/right`` blocks, and resolves author tags without a
118
+ matching color. New authors choose a hue in a Qt picker; ``cpb`` adds their
119
+ ``XXcolor`` field to the header.
120
+
112
121
  Declare a single local ``.bib`` file with ``bibliography: references.bib``
113
122
  in the Markdown YAML header; relative paths are resolved beside the
114
123
  Markdown file. Without that declaration, ``cpb`` uses a single adjacent
@@ -52,6 +52,7 @@ pydifftools/citation_dialog.py
52
52
  pydifftools/command_line.py
53
53
  pydifftools/command_registry.py
54
54
  pydifftools/comment_functions.py
55
+ pydifftools/comment_migration.py
55
56
  pydifftools/comment_tags.lua
56
57
  pydifftools/comment_tags_margin.lua
57
58
  pydifftools/comment_tags_no_comments.lua
@@ -97,6 +98,7 @@ tests/test_bibliography.py
97
98
  tests/test_browser_lifecycle.py
98
99
  tests/test_check_single_use.py
99
100
  tests/test_command_line_help.py
101
+ tests/test_comment_migration.py
100
102
  tests/test_continuous_shutdown.py
101
103
  tests/test_forward_search.py
102
104
  tests/test_git_gd_history.py
@@ -0,0 +1,397 @@
1
+ """Pre-build migration of legacy and unregistered comment authors."""
2
+
3
+ import colorsys
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import textwrap
11
+
12
+ import yaml
13
+
14
+ _FRONT_MATTER_RE = re.compile(
15
+ r"\A(\ufeff?---[ \t]*\r?\n)(.*?)(\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$))",
16
+ re.DOTALL,
17
+ )
18
+ _LEGACY_TAG_RE = re.compile(
19
+ r"(?P<closing></?)comment(?P<side>-(?:left|right))?>", re.I
20
+ )
21
+ _LEGACY_CLASS_RE = re.compile(r"(?<=\.)comment-(left|right)(?=[\s}])")
22
+ _AUTHOR_TAG_RE = re.compile(
23
+ r"</?([A-Za-z]{2})com(?:-(?:left|right))?>", re.I
24
+ )
25
+ _AUTHOR_CLASS_RE = re.compile(
26
+ r"\.([A-Za-z]{2})com(?:-(?:left|right))?(?=[\s}])", re.I
27
+ )
28
+ _HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
29
+ _SEED_COLOR = "#5aa0ff"
30
+
31
+
32
+ def _front_matter(text):
33
+ match = _FRONT_MATTER_RE.match(text)
34
+ if not match:
35
+ if re.match(r"\A\ufeff?---[ \t]*(?:\r?\n|$)", text):
36
+ raise ValueError(
37
+ "YAML front matter starts with '---' but has no closing "
38
+ "delimiter"
39
+ )
40
+ return None, {}, {}
41
+ try:
42
+ metadata = yaml.safe_load(match.group(2)) or {}
43
+ except yaml.YAMLError as exc:
44
+ raise ValueError(f"invalid YAML front matter: {exc}") from exc
45
+ if not isinstance(metadata, dict):
46
+ raise ValueError("YAML front matter must contain a mapping")
47
+ colors = {}
48
+ for key, value in metadata.items():
49
+ initials = re.fullmatch(r"([A-Z]{2})color", str(key))
50
+ if not initials:
51
+ continue
52
+ if not isinstance(value, str) or not _HEX_RE.fullmatch(value):
53
+ raise ValueError(f"{key} must be a six-digit hex color")
54
+ colors[initials.group(1)] = value.lower()
55
+ return match, metadata, colors
56
+
57
+
58
+ def _most_distant_hue(colors):
59
+ """Return the midpoint of the largest gap between existing hues."""
60
+ def hue(color):
61
+ red, green, blue = (
62
+ int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)
63
+ )
64
+ return colorsys.rgb_to_hsv(red, green, blue)[0] * 360
65
+
66
+ if not colors:
67
+ return hue(_SEED_COLOR)
68
+ hues = sorted({hue(color) % 360 for color in colors})
69
+ gaps = [
70
+ (
71
+ (hues[index + 1] if index + 1 < len(hues) else hues[0] + 360)
72
+ - hue,
73
+ hue,
74
+ )
75
+ for index, hue in enumerate(hues)
76
+ ]
77
+ gap_size, start = max(gaps, key=lambda gap: (gap[0], -gap[1]))
78
+ return (start + gap_size / 2) % 360
79
+
80
+
81
+ def _migration_dialog(text, colors, legacy):
82
+ """Ask Qt for all migrations and return decisions without editing files."""
83
+ script = r"""
84
+ import colorsys
85
+ import json
86
+ import re
87
+ import sys
88
+ from PySide6.QtCore import Qt, Signal
89
+ from PySide6.QtGui import (
90
+ QColor, QIcon, QLinearGradient, QPainter, QPen, QPixmap,
91
+ )
92
+ from PySide6.QtWidgets import (
93
+ QApplication, QComboBox, QDialog, QDialogButtonBox, QLabel,
94
+ QLineEdit, QMessageBox, QVBoxLayout, QWidget,
95
+ )
96
+
97
+ payload = json.loads(sys.argv[1])
98
+ colors = payload["colors"]
99
+ app = QApplication(sys.argv[:1])
100
+ result = {"accepted": True, "replacements": {}, "colors": {}}
101
+
102
+ def color_hue(color):
103
+ rgb = [int(color[i:i+2], 16) / 255 for i in (1, 3, 5)]
104
+ return colorsys.rgb_to_hsv(*rgb)[0] * 360
105
+
106
+ def most_distant_hue(used_colors):
107
+ if not used_colors:
108
+ return color_hue("#5aa0ff")
109
+ hues = sorted(set(color_hue(color) % 360 for color in used_colors))
110
+ gaps = [
111
+ ((hues[i + 1] if i + 1 < len(hues) else hues[0] + 360) - hue, hue)
112
+ for i, hue in enumerate(hues)
113
+ ]
114
+ gap, start = max(gaps, key=lambda item: (item[0], -item[1]))
115
+ return (start + gap / 2) % 360
116
+
117
+ class HueBar(QWidget):
118
+ hueChanged = Signal(int)
119
+ def __init__(self, hue):
120
+ super().__init__()
121
+ self.hue = int(round(hue)) % 360
122
+ self.setMinimumWidth(300)
123
+ self.setMinimumHeight(34)
124
+ def paintEvent(self, event):
125
+ painter = QPainter(self)
126
+ gradient = QLinearGradient(0, 0, self.width(), 0)
127
+ for hue in range(0, 361, 30):
128
+ gradient.setColorAt(hue / 360, QColor.fromHsv(hue % 360, 165, 255))
129
+ painter.fillRect(0, 7, self.width(), 20, gradient)
130
+ x = round(self.hue / 359 * (self.width() - 1))
131
+ painter.setPen(QPen(Qt.GlobalColor.black, 2))
132
+ painter.drawLine(x, 2, x, 31)
133
+ def mousePressEvent(self, event):
134
+ self.set_hue(event.position().x() / max(1, self.width() - 1) * 359)
135
+ def mouseMoveEvent(self, event):
136
+ if event.buttons() & Qt.MouseButton.LeftButton:
137
+ self.set_hue(event.position().x() / max(1, self.width() - 1) * 359)
138
+ def set_hue(self, hue):
139
+ self.hue = max(0, min(359, int(round(hue))))
140
+ self.update()
141
+ self.hueChanged.emit(self.hue)
142
+
143
+ def ask_hue(initial):
144
+ dlg = QDialog()
145
+ dlg.setWindowTitle("Create comment user")
146
+ layout = QVBoxLayout(dlg)
147
+ layout.addWidget(QLabel("Choose a hue for this comment user."))
148
+ bar = HueBar(initial)
149
+ layout.addWidget(bar)
150
+ preview = QLabel()
151
+ preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
152
+ preview.setMinimumHeight(48)
153
+ layout.addWidget(preview)
154
+ def update_preview():
155
+ # Match the saturation and opacity of the existing hard-coded blue.
156
+ color = QColor.fromHsv(bar.hue, 165, 255, 255).name()
157
+ preview.setText(color)
158
+ preview.setStyleSheet("background-color: " + color)
159
+ bar.update()
160
+ bar.hueChanged.connect(update_preview)
161
+ update_preview()
162
+ buttons = QDialogButtonBox(
163
+ QDialogButtonBox.StandardButton.Ok
164
+ | QDialogButtonBox.StandardButton.Cancel
165
+ )
166
+ buttons.accepted.connect(dlg.accept)
167
+ buttons.rejected.connect(dlg.reject)
168
+ layout.addWidget(buttons)
169
+ if dlg.exec() != QDialog.DialogCode.Accepted:
170
+ return None
171
+ return QColor.fromHsv(bar.hue, 165, 255, 255).name()
172
+
173
+ def ask_legacy_initials():
174
+ while True:
175
+ dlg = QDialog()
176
+ dlg.setWindowTitle("Migrate legacy comments")
177
+ layout = QVBoxLayout(dlg)
178
+ layout.addWidget(QLabel(
179
+ "Legacy comment tags and blocks were found. Enter the two "
180
+ "initials to use for all of them."
181
+ ))
182
+ field = QLineEdit()
183
+ field.setMaxLength(2)
184
+ field.setPlaceholderText("JF")
185
+ layout.addWidget(field)
186
+ buttons = QDialogButtonBox(
187
+ QDialogButtonBox.StandardButton.Ok
188
+ | QDialogButtonBox.StandardButton.Cancel
189
+ )
190
+ buttons.accepted.connect(dlg.accept)
191
+ buttons.rejected.connect(dlg.reject)
192
+ layout.addWidget(buttons)
193
+ if dlg.exec() != QDialog.DialogCode.Accepted:
194
+ return None
195
+ initials = field.text().upper()
196
+ if re.fullmatch(r"[A-Z]{2}", initials):
197
+ return initials
198
+ QMessageBox.warning(None, "Invalid initials", "Enter two letters.")
199
+
200
+ legacy = payload["legacy"]
201
+ if legacy:
202
+ initials = ask_legacy_initials()
203
+ legacy_occurrences = len(re.findall(
204
+ r"</?comment(?:-(?:left|right))?>", payload["text"], re.I
205
+ )) + len(re.findall(
206
+ r"\.comment-(?:left|right)(?=[\s}])", payload["text"], re.I
207
+ ))
208
+ if initials is None:
209
+ result["accepted"] = False
210
+ elif not QMessageBox.question(
211
+ None, "Replace legacy comments",
212
+ "Replace " + str(legacy_occurrences)
213
+ + " legacy tag or block-class occurrences with "
214
+ + initials + "com markup?",
215
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
216
+ QMessageBox.StandardButton.No,
217
+ ) == QMessageBox.StandardButton.Yes:
218
+ result["accepted"] = False
219
+ else:
220
+ result["replacements"]["legacy"] = initials
221
+
222
+ if result["accepted"]:
223
+ authors = sorted(set(
224
+ initials.upper() for initials in re.findall(
225
+ r"</?([A-Za-z]{2})com(?:-(?:left|right))?>", payload["text"], re.I
226
+ )
227
+ ) | set(
228
+ initials.upper() for initials in re.findall(
229
+ r"\.([A-Za-z]{2})com(?:-(?:left|right))?(?=[\s}])",
230
+ payload["text"], re.I
231
+ )
232
+ ))
233
+ if result["replacements"].get("legacy"):
234
+ authors.append(result["replacements"]["legacy"])
235
+ authors = sorted(set(authors))
236
+ for author in authors:
237
+ if author in colors:
238
+ continue
239
+ box = QMessageBox()
240
+ box.setWindowTitle("Unknown comment author")
241
+ box.setText(
242
+ author + "com tags or block classes have no matching "
243
+ + author + "color field."
244
+ )
245
+ selector = QComboBox()
246
+ for existing in sorted(colors):
247
+ selector.addItem(existing + " " + colors[existing], existing)
248
+ swatch = QPixmap(14, 14)
249
+ swatch.fill(QColor(colors[existing]))
250
+ selector.setItemIcon(selector.count() - 1, QIcon(swatch))
251
+ create = "Create " + author + " as a new comment user"
252
+ selector.addItem(create, "__create__")
253
+ selector.setCurrentIndex(selector.count() - 1)
254
+ box.layout().addWidget(selector, 1, 0, 1, box.layout().columnCount())
255
+ box.setStandardButtons(
256
+ QMessageBox.StandardButton.Cancel
257
+ | QMessageBox.StandardButton.Ok
258
+ )
259
+ box.setDefaultButton(QMessageBox.StandardButton.Ok)
260
+ if box.exec() != QMessageBox.StandardButton.Ok:
261
+ result["accepted"] = False
262
+ break
263
+ target = selector.currentData()
264
+ if target == "__create__":
265
+ seed = colors.get(author)
266
+ if seed is None:
267
+ used = [*colors.values(), *result["colors"].values()]
268
+ seed_hue = most_distant_hue(used)
269
+ seed = QColor.fromHsv(round(seed_hue), 165, 255).name()
270
+ color = ask_hue(QColor(seed).hue())
271
+ if color is None:
272
+ result["accepted"] = False
273
+ break
274
+ result["colors"][author] = color
275
+ colors[author] = color
276
+ elif target:
277
+ result["replacements"][author] = target
278
+
279
+ print(json.dumps(result))
280
+ """
281
+ completed = subprocess.run(
282
+ [sys.executable, "-c", textwrap.dedent(script),
283
+ json.dumps({"text": text, "colors": colors, "legacy": legacy})],
284
+ capture_output=True, text=True,
285
+ )
286
+ if completed.returncode:
287
+ detail = completed.stderr.strip()
288
+ message = "cpb comment migration dialog failed"
289
+ if detail:
290
+ message += f": {detail}"
291
+ else:
292
+ message += "."
293
+ raise RuntimeError(message)
294
+ try:
295
+ return json.loads(completed.stdout)
296
+ except json.JSONDecodeError as exc:
297
+ raise RuntimeError(
298
+ "cpb comment migration dialog returned invalid data"
299
+ ) from exc
300
+
301
+
302
+ def prepare_comment_source(filename):
303
+ """Migrate comment tags and author colors before a build, if requested."""
304
+ with open(filename, encoding="utf-8", newline="") as fp:
305
+ original = fp.read()
306
+ has_legacy = bool(
307
+ _LEGACY_TAG_RE.search(original) or _LEGACY_CLASS_RE.search(original)
308
+ )
309
+ if not (has_legacy or _AUTHOR_TAG_RE.search(original)
310
+ or _AUTHOR_CLASS_RE.search(original)):
311
+ return original
312
+ _, _, colors = _front_matter(original)
313
+ authors = {
314
+ initials.upper()
315
+ for initials in _AUTHOR_TAG_RE.findall(original)
316
+ } | {
317
+ initials.upper()
318
+ for initials in _AUTHOR_CLASS_RE.findall(original)
319
+ }
320
+ if not has_legacy and authors.issubset(colors):
321
+ return original
322
+ decisions = _migration_dialog(original, colors, has_legacy)
323
+ if not decisions.get("accepted"):
324
+ raise RuntimeError("cpb comment migration was canceled")
325
+ updated = original
326
+ for tag_author, target in decisions.get("replacements", {}).items():
327
+ if tag_author == "legacy":
328
+ updated = _LEGACY_TAG_RE.sub(
329
+ lambda found: (
330
+ found.group("closing") + target + "com"
331
+ + (found.group("side") or "") + ">"
332
+ ),
333
+ updated,
334
+ )
335
+ updated = _LEGACY_CLASS_RE.sub(
336
+ lambda found: target + "com-" + found.group(1), updated
337
+ )
338
+ else:
339
+ pattern = re.compile(
340
+ r"(?P<closing></?)" + re.escape(tag_author)
341
+ + r"com(?P<side>-(?:left|right))?>",
342
+ re.I,
343
+ )
344
+ updated = pattern.sub(
345
+ lambda found: (
346
+ found.group("closing") + target + "com"
347
+ + (found.group("side") or "") + ">"
348
+ ),
349
+ updated,
350
+ )
351
+ class_pattern = re.compile(
352
+ r"\." + re.escape(tag_author)
353
+ + r"com(?P<side>-(?:left|right))?(?=[\s}])", re.I
354
+ )
355
+ updated = class_pattern.sub(
356
+ lambda found: "." + target + "com"
357
+ + (found.group("side") or ""), updated
358
+ )
359
+ additions = decisions.get("colors", {})
360
+ if additions:
361
+ # {{{ add chosen author colors to the YAML front matter
362
+ match, _, _ = _front_matter(updated)
363
+ color_lines = [
364
+ f'{author}color: "{color}"'
365
+ for author, color in sorted(additions.items())
366
+ ]
367
+ if match:
368
+ body = match.group(2)
369
+ newline = "\r\n" if "\r\n" in updated else "\n"
370
+ if body and not body.endswith("\n"):
371
+ body += newline
372
+ body += newline.join(color_lines)
373
+ updated = (
374
+ updated[:match.start(2)] + body + updated[match.end(2):]
375
+ )
376
+ else:
377
+ newline = "\r\n" if "\r\n" in updated else "\n"
378
+ header = (
379
+ "---" + newline + newline.join(color_lines)
380
+ + newline + "---" + newline
381
+ )
382
+ updated = header + updated
383
+ # }}}
384
+ if updated != original:
385
+ directory = os.path.dirname(os.path.abspath(filename))
386
+ fd, temp_path = tempfile.mkstemp(
387
+ prefix=".cpb-comments-", dir=directory
388
+ )
389
+ try:
390
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fp:
391
+ fp.write(updated)
392
+ os.chmod(temp_path, os.stat(filename).st_mode)
393
+ os.replace(temp_path, filename)
394
+ finally:
395
+ if os.path.exists(temp_path):
396
+ os.unlink(temp_path)
397
+ return updated
@@ -32,6 +32,7 @@ local OPENERS = {
32
32
  ["<comment-right>"] = { side = "comment-right", close = "</comment-right>" },
33
33
  ["<comment-left>"] = { side = "comment-left", close = "</comment-left>" },
34
34
  }
35
+ local AUTHOR_COLORS = {}
35
36
 
36
37
  local comment_id = 0
37
38
  local function next_id()
@@ -39,8 +40,15 @@ local function next_id()
39
40
  return "c" .. tostring(comment_id)
40
41
  end
41
42
 
42
- local function make_inline_comment(side, content_inlines)
43
- local inner = pandoc.Span(content_inlines, pandoc.Attr("", { side }, {}))
43
+ local function make_inline_comment(side, content_inlines, spec)
44
+ local attributes = {}
45
+ if spec and spec.color then
46
+ attributes["style"] = "--comment-accent-color: " .. spec.color .. ";"
47
+ attributes["data-comment-author"] = spec.author
48
+ end
49
+ local inner = pandoc.Span(
50
+ content_inlines, pandoc.Attr("", { side }, attributes)
51
+ )
44
52
  return pandoc.Span(pandoc.List({ inner }), pandoc.Attr("", { "comment-pin" }, {}))
45
53
  end
46
54
 
@@ -50,11 +58,16 @@ local function make_block_anchor(id)
50
58
  '<span class="comment-pin comment-pin-block" data-comment-id="' .. id .. '"></span>')
51
59
  end
52
60
 
53
- local function make_block_overlay(side, id, content_blocks)
61
+ local function make_block_overlay(side, id, content_blocks, spec)
54
62
  -- Div can contain BulletList/Para/etc. JS will position this overlay.
55
63
  return pandoc.Div(
56
64
  content_blocks,
57
- pandoc.Attr("", { "comment-overlay", side }, { ["data-comment-id"] = id })
65
+ pandoc.Attr("", { "comment-overlay", side }, {
66
+ ["data-comment-id"] = id,
67
+ ["data-comment-author"] = spec and spec.author or nil,
68
+ ["style"] = spec and spec.color
69
+ and ("--comment-accent-color: " .. spec.color .. ";") or nil,
70
+ })
58
71
  )
59
72
  end
60
73
 
@@ -263,7 +276,7 @@ function Inlines(inlines)
263
276
  end
264
277
 
265
278
  if found then
266
- out:insert(make_inline_comment(spec.side, buf))
279
+ out:insert(make_inline_comment(spec.side, buf, spec))
267
280
  i = j + 1
268
281
  else
269
282
  -- unmatched: leave literal
@@ -353,7 +366,7 @@ function Blocks(blocks)
353
366
  if not merged_with_inline_anchor then
354
367
  out:insert(make_block_anchor(id))
355
368
  end
356
- out:insert(make_block_overlay(spec.side, id, buf))
369
+ out:insert(make_block_overlay(spec.side, id, buf, spec))
357
370
 
358
371
  -- Keep trailing content after the closer in main flow.
359
372
  for after_index = 1, #after_close_blocks do
@@ -370,3 +383,54 @@ function Blocks(blocks)
370
383
 
371
384
  return out
372
385
  end
386
+
387
+ function Div(el)
388
+ for index, class_name in ipairs(el.classes) do
389
+ local author, side = class_name:match("^([A-Za-z][A-Za-z])com%-(left)$")
390
+ if not author then
391
+ author, side = class_name:match("^([A-Za-z][A-Za-z])com%-(right)$")
392
+ end
393
+ if not author then
394
+ author = class_name:match("^([A-Za-z][A-Za-z])com$")
395
+ side = "right"
396
+ end
397
+ author = author and author:upper() or nil
398
+ local color = author and AUTHOR_COLORS[author] or nil
399
+ if color then
400
+ el.classes[index] = "comment-" .. side
401
+ local style = el.attributes.style or ""
402
+ if #style > 0 and not style:match(";%s*$") then
403
+ style = style .. ";"
404
+ end
405
+ el.attributes.style = style .. "--comment-accent-color: " .. color .. ";"
406
+ el.attributes["data-comment-author"] = author
407
+ end
408
+ end
409
+ return el
410
+ end
411
+
412
+ function Pandoc(doc)
413
+ for key, value in pairs(doc.meta) do
414
+ local initials = tostring(key):match("^([A-Za-z][A-Za-z])color$")
415
+ local color = pandoc.utils.stringify(value)
416
+ if initials and color:match("^#%x%x%x%x%x%x$") then
417
+ initials = initials:upper()
418
+ color = color:lower()
419
+ AUTHOR_COLORS[initials] = color
420
+ for suffix, side in pairs({
421
+ ["com"] = "comment-right",
422
+ ["com-left"] = "comment-left",
423
+ ["com-right"] = "comment-right",
424
+ }) do
425
+ local tag = "<" .. initials:lower() .. suffix .. ">"
426
+ OPENERS[tag] = {
427
+ side = side,
428
+ close = "</" .. initials:lower() .. suffix .. ">",
429
+ color = color,
430
+ author = initials,
431
+ }
432
+ end
433
+ end
434
+ end
435
+ return doc:walk({ Inlines = Inlines, Blocks = Blocks, Div = Div })
436
+ end