pyDiffTools 0.1.36__tar.gz → 0.1.38__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.38}/PKG-INFO +10 -1
  2. {pydifftools-0.1.36 → pydifftools-0.1.38}/README.rst +9 -0
  3. {pydifftools-0.1.36 → pydifftools-0.1.38/pyDiffTools.egg-info}/PKG-INFO +10 -1
  4. {pydifftools-0.1.36 → pydifftools-0.1.38}/pyDiffTools.egg-info/SOURCES.txt +2 -0
  5. pydifftools-0.1.38/pydifftools/comment_migration.py +401 -0
  6. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/comment_tags.lua +70 -6
  7. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/comment_tags_margin.lua +66 -6
  8. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/comment_tags_no_comments.lua +22 -0
  9. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/comments.css +10 -5
  10. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/continuous.py +14 -1
  11. {pydifftools-0.1.36 → pydifftools-0.1.38}/pyproject.toml +1 -1
  12. pydifftools-0.1.38/tests/test_comment_migration.py +215 -0
  13. {pydifftools-0.1.36 → pydifftools-0.1.38}/LICENSE.md +0 -0
  14. {pydifftools-0.1.36 → pydifftools-0.1.38}/MANIFEST.in +0 -0
  15. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/_quarto.yml +0 -0
  16. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  17. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  18. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  19. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  20. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  21. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  22. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  23. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  24. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  25. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  26. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  27. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  28. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  29. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  30. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  31. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  32. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  33. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  34. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  35. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  36. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  37. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  38. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/example.qmd +0 -0
  39. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/example.tex +0 -0
  40. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/index.qmd +0 -0
  41. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  42. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  43. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  44. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  45. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/independent.qmd +0 -0
  46. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/index.qmd +0 -0
  47. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  48. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  49. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  50. {pydifftools-0.1.36 → pydifftools-0.1.38}/example_notebook/project1/test_include.qmd +0 -0
  51. {pydifftools-0.1.36 → pydifftools-0.1.38}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  52. {pydifftools-0.1.36 → pydifftools-0.1.38}/pyDiffTools.egg-info/entry_points.txt +0 -0
  53. {pydifftools-0.1.36 → pydifftools-0.1.38}/pyDiffTools.egg-info/requires.txt +0 -0
  54. {pydifftools-0.1.36 → pydifftools-0.1.38}/pyDiffTools.egg-info/top_level.txt +0 -0
  55. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/__init__.py +0 -0
  56. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/bibliography.py +0 -0
  57. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/browser_lifecycle.py +0 -0
  58. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/check_numbers.py +0 -0
  59. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/citation_dialog.py +0 -0
  60. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/command_line.py +0 -0
  61. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/command_registry.py +0 -0
  62. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/comment_functions.py +0 -0
  63. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/comment_toggle.js +0 -0
  64. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/copy_files.py +0 -0
  65. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/diff-doc.js +0 -0
  66. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/doc_contents.py +0 -0
  67. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/__init__.py +0 -0
  68. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/comparison.py +0 -0
  69. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  70. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/graph.py +0 -0
  71. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  72. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/watch_graph.py +0 -0
  73. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/flowchart/wgrph_view.js +0 -0
  74. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/forward_search.py +0 -0
  75. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/git_gd.py +0 -0
  76. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/git_gd_history.py +0 -0
  77. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/git_gd_image.py +0 -0
  78. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/git_gd_qt.py +0 -0
  79. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/html_comments.py +0 -0
  80. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/html_uncomments.py +0 -0
  81. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/log_example.py +0 -0
  82. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/match_spaces.py +0 -0
  83. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/notebook/__init__.py +0 -0
  84. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/notebook/display.py +0 -0
  85. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/notebook/fast_build.py +0 -0
  86. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/notebook/tex_to_qmd.py +0 -0
  87. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/onewordify.py +0 -0
  88. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/onewordify_undo.py +0 -0
  89. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/outline.py +0 -0
  90. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/rearrange_tex.py +0 -0
  91. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/searchacro.py +0 -0
  92. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/separate_comments.py +0 -0
  93. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/split_conflict.py +0 -0
  94. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/unseparate_comments.py +0 -0
  95. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/update_check.py +0 -0
  96. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/wrap_sentences.py +0 -0
  97. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/xml2xlsx.vbs +0 -0
  98. {pydifftools-0.1.36 → pydifftools-0.1.38}/pydifftools/zotero.py +0 -0
  99. {pydifftools-0.1.36 → pydifftools-0.1.38}/setup.cfg +0 -0
  100. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_bibliography.py +0 -0
  101. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_browser_lifecycle.py +0 -0
  102. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_check_single_use.py +0 -0
  103. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_command_line_help.py +0 -0
  104. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_continuous_shutdown.py +0 -0
  105. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_forward_search.py +0 -0
  106. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_git_gd_history.py +0 -0
  107. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_git_gd_image.py +0 -0
  108. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_git_gd_qt.py +0 -0
  109. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_rrng.py +0 -0
  110. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_tex_to_qmd.py +0 -0
  111. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_update_check.py +0 -0
  112. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_wrap_check.py +0 -0
  113. {pydifftools-0.1.36 → pydifftools-0.1.38}/tests/test_wrapping.py +0 -0
  114. {pydifftools-0.1.36 → pydifftools-0.1.38}/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.38
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.38
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,401 @@
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(filename, 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
+ with open(sys.argv[2], encoding="utf-8", newline="") as fp:
99
+ text = fp.read()
100
+ colors = payload["colors"]
101
+ app = QApplication(sys.argv[:1])
102
+ result = {"accepted": True, "replacements": {}, "colors": {}}
103
+
104
+ def color_hue(color):
105
+ rgb = [int(color[i:i+2], 16) / 255 for i in (1, 3, 5)]
106
+ return colorsys.rgb_to_hsv(*rgb)[0] * 360
107
+
108
+ def most_distant_hue(used_colors):
109
+ if not used_colors:
110
+ return color_hue("#5aa0ff")
111
+ hues = sorted(set(color_hue(color) % 360 for color in used_colors))
112
+ gaps = [
113
+ ((hues[i + 1] if i + 1 < len(hues) else hues[0] + 360) - hue, hue)
114
+ for i, hue in enumerate(hues)
115
+ ]
116
+ gap, start = max(gaps, key=lambda item: (item[0], -item[1]))
117
+ return (start + gap / 2) % 360
118
+
119
+ class HueBar(QWidget):
120
+ hueChanged = Signal(int)
121
+ def __init__(self, hue):
122
+ super().__init__()
123
+ self.hue = int(round(hue)) % 360
124
+ self.setMinimumWidth(300)
125
+ self.setMinimumHeight(34)
126
+ def paintEvent(self, event):
127
+ painter = QPainter(self)
128
+ gradient = QLinearGradient(0, 0, self.width(), 0)
129
+ for hue in range(0, 361, 30):
130
+ gradient.setColorAt(hue / 360, QColor.fromHsv(hue % 360, 165, 255))
131
+ painter.fillRect(0, 7, self.width(), 20, gradient)
132
+ x = round(self.hue / 359 * (self.width() - 1))
133
+ painter.setPen(QPen(Qt.GlobalColor.black, 2))
134
+ painter.drawLine(x, 2, x, 31)
135
+ def mousePressEvent(self, event):
136
+ self.set_hue(event.position().x() / max(1, self.width() - 1) * 359)
137
+ def mouseMoveEvent(self, event):
138
+ if event.buttons() & Qt.MouseButton.LeftButton:
139
+ self.set_hue(event.position().x() / max(1, self.width() - 1) * 359)
140
+ def set_hue(self, hue):
141
+ self.hue = max(0, min(359, int(round(hue))))
142
+ self.update()
143
+ self.hueChanged.emit(self.hue)
144
+
145
+ def ask_hue(initial):
146
+ dlg = QDialog()
147
+ dlg.setWindowTitle("Create comment user")
148
+ layout = QVBoxLayout(dlg)
149
+ layout.addWidget(QLabel("Choose a hue for this comment user."))
150
+ bar = HueBar(initial)
151
+ layout.addWidget(bar)
152
+ preview = QLabel()
153
+ preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
154
+ preview.setMinimumHeight(48)
155
+ layout.addWidget(preview)
156
+ def update_preview():
157
+ # Match the saturation and opacity of the existing hard-coded blue.
158
+ color = QColor.fromHsv(bar.hue, 165, 255, 255).name()
159
+ preview.setText(color)
160
+ preview.setStyleSheet("background-color: " + color)
161
+ bar.update()
162
+ bar.hueChanged.connect(update_preview)
163
+ update_preview()
164
+ buttons = QDialogButtonBox(
165
+ QDialogButtonBox.StandardButton.Ok
166
+ | QDialogButtonBox.StandardButton.Cancel
167
+ )
168
+ buttons.accepted.connect(dlg.accept)
169
+ buttons.rejected.connect(dlg.reject)
170
+ layout.addWidget(buttons)
171
+ if dlg.exec() != QDialog.DialogCode.Accepted:
172
+ return None
173
+ return QColor.fromHsv(bar.hue, 165, 255, 255).name()
174
+
175
+ def ask_legacy_initials():
176
+ while True:
177
+ dlg = QDialog()
178
+ dlg.setWindowTitle("Migrate legacy comments")
179
+ layout = QVBoxLayout(dlg)
180
+ layout.addWidget(QLabel(
181
+ "Legacy comment tags and blocks were found. Enter the two "
182
+ "initials to use for all of them."
183
+ ))
184
+ field = QLineEdit()
185
+ field.setMaxLength(2)
186
+ field.setPlaceholderText("JF")
187
+ layout.addWidget(field)
188
+ buttons = QDialogButtonBox(
189
+ QDialogButtonBox.StandardButton.Ok
190
+ | QDialogButtonBox.StandardButton.Cancel
191
+ )
192
+ buttons.accepted.connect(dlg.accept)
193
+ buttons.rejected.connect(dlg.reject)
194
+ layout.addWidget(buttons)
195
+ if dlg.exec() != QDialog.DialogCode.Accepted:
196
+ return None
197
+ initials = field.text().upper()
198
+ if re.fullmatch(r"[A-Z]{2}", initials):
199
+ return initials
200
+ QMessageBox.warning(None, "Invalid initials", "Enter two letters.")
201
+
202
+ legacy = payload["legacy"]
203
+ if legacy:
204
+ initials = ask_legacy_initials()
205
+ legacy_occurrences = len(re.findall(
206
+ r"</?comment(?:-(?:left|right))?>", text, re.I
207
+ )) + len(re.findall(
208
+ r"\.comment-(?:left|right)(?=[\s}])", text, re.I
209
+ ))
210
+ if initials is None:
211
+ result["accepted"] = False
212
+ elif not QMessageBox.question(
213
+ None, "Replace legacy comments",
214
+ "Replace " + str(legacy_occurrences)
215
+ + " legacy tag or block-class occurrences with "
216
+ + initials + "com markup?",
217
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
218
+ QMessageBox.StandardButton.No,
219
+ ) == QMessageBox.StandardButton.Yes:
220
+ result["accepted"] = False
221
+ else:
222
+ result["replacements"]["legacy"] = initials
223
+
224
+ if result["accepted"]:
225
+ authors = sorted(set(
226
+ initials.upper() for initials in re.findall(
227
+ r"</?([A-Za-z]{2})com(?:-(?:left|right))?>", text, re.I
228
+ )
229
+ ) | set(
230
+ initials.upper() for initials in re.findall(
231
+ r"\.([A-Za-z]{2})com(?:-(?:left|right))?(?=[\s}])",
232
+ text, re.I
233
+ )
234
+ ))
235
+ if result["replacements"].get("legacy"):
236
+ authors.append(result["replacements"]["legacy"])
237
+ authors = sorted(set(authors))
238
+ for author in authors:
239
+ if author in colors:
240
+ continue
241
+ box = QMessageBox()
242
+ box.setWindowTitle("Unknown comment author")
243
+ box.setText(
244
+ author + "com tags or block classes have no matching "
245
+ + author + "color field."
246
+ )
247
+ selector = QComboBox()
248
+ for existing in sorted(colors):
249
+ selector.addItem(existing + " " + colors[existing], existing)
250
+ swatch = QPixmap(14, 14)
251
+ swatch.fill(QColor(colors[existing]))
252
+ selector.setItemIcon(selector.count() - 1, QIcon(swatch))
253
+ create = "Create " + author + " as a new comment user"
254
+ selector.addItem(create, "__create__")
255
+ selector.setCurrentIndex(selector.count() - 1)
256
+ box.layout().addWidget(selector, 1, 0, 1, box.layout().columnCount())
257
+ box.setStandardButtons(
258
+ QMessageBox.StandardButton.Cancel
259
+ | QMessageBox.StandardButton.Ok
260
+ )
261
+ box.setDefaultButton(QMessageBox.StandardButton.Ok)
262
+ if box.exec() != QMessageBox.StandardButton.Ok:
263
+ result["accepted"] = False
264
+ break
265
+ target = selector.currentData()
266
+ if target == "__create__":
267
+ seed = colors.get(author)
268
+ if seed is None:
269
+ used = [*colors.values(), *result["colors"].values()]
270
+ seed_hue = most_distant_hue(used)
271
+ seed = QColor.fromHsv(round(seed_hue), 165, 255).name()
272
+ color = ask_hue(QColor(seed).hue())
273
+ if color is None:
274
+ result["accepted"] = False
275
+ break
276
+ result["colors"][author] = color
277
+ colors[author] = color
278
+ elif target:
279
+ result["replacements"][author] = target
280
+
281
+ print(json.dumps(result))
282
+ """
283
+ completed = subprocess.run(
284
+ # the dialog reads the document from its file, since the text of a
285
+ # large document exceeds the operating system's argument limit
286
+ [sys.executable, "-c", textwrap.dedent(script),
287
+ json.dumps({"colors": colors, "legacy": legacy}), filename],
288
+ capture_output=True, text=True,
289
+ )
290
+ if completed.returncode:
291
+ detail = completed.stderr.strip()
292
+ message = "cpb comment migration dialog failed"
293
+ if detail:
294
+ message += f": {detail}"
295
+ else:
296
+ message += "."
297
+ raise RuntimeError(message)
298
+ try:
299
+ return json.loads(completed.stdout)
300
+ except json.JSONDecodeError as exc:
301
+ raise RuntimeError(
302
+ "cpb comment migration dialog returned invalid data"
303
+ ) from exc
304
+
305
+
306
+ def prepare_comment_source(filename):
307
+ """Migrate comment tags and author colors before a build, if requested."""
308
+ with open(filename, encoding="utf-8", newline="") as fp:
309
+ original = fp.read()
310
+ has_legacy = bool(
311
+ _LEGACY_TAG_RE.search(original) or _LEGACY_CLASS_RE.search(original)
312
+ )
313
+ if not (has_legacy or _AUTHOR_TAG_RE.search(original)
314
+ or _AUTHOR_CLASS_RE.search(original)):
315
+ return original
316
+ _, _, colors = _front_matter(original)
317
+ authors = {
318
+ initials.upper()
319
+ for initials in _AUTHOR_TAG_RE.findall(original)
320
+ } | {
321
+ initials.upper()
322
+ for initials in _AUTHOR_CLASS_RE.findall(original)
323
+ }
324
+ if not has_legacy and authors.issubset(colors):
325
+ return original
326
+ decisions = _migration_dialog(filename, colors, has_legacy)
327
+ if not decisions.get("accepted"):
328
+ raise RuntimeError("cpb comment migration was canceled")
329
+ updated = original
330
+ for tag_author, target in decisions.get("replacements", {}).items():
331
+ if tag_author == "legacy":
332
+ updated = _LEGACY_TAG_RE.sub(
333
+ lambda found: (
334
+ found.group("closing") + target + "com"
335
+ + (found.group("side") or "") + ">"
336
+ ),
337
+ updated,
338
+ )
339
+ updated = _LEGACY_CLASS_RE.sub(
340
+ lambda found: target + "com-" + found.group(1), updated
341
+ )
342
+ else:
343
+ pattern = re.compile(
344
+ r"(?P<closing></?)" + re.escape(tag_author)
345
+ + r"com(?P<side>-(?:left|right))?>",
346
+ re.I,
347
+ )
348
+ updated = pattern.sub(
349
+ lambda found: (
350
+ found.group("closing") + target + "com"
351
+ + (found.group("side") or "") + ">"
352
+ ),
353
+ updated,
354
+ )
355
+ class_pattern = re.compile(
356
+ r"\." + re.escape(tag_author)
357
+ + r"com(?P<side>-(?:left|right))?(?=[\s}])", re.I
358
+ )
359
+ updated = class_pattern.sub(
360
+ lambda found: "." + target + "com"
361
+ + (found.group("side") or ""), updated
362
+ )
363
+ additions = decisions.get("colors", {})
364
+ if additions:
365
+ # {{{ add chosen author colors to the YAML front matter
366
+ match, _, _ = _front_matter(updated)
367
+ color_lines = [
368
+ f'{author}color: "{color}"'
369
+ for author, color in sorted(additions.items())
370
+ ]
371
+ if match:
372
+ body = match.group(2)
373
+ newline = "\r\n" if "\r\n" in updated else "\n"
374
+ if body and not body.endswith("\n"):
375
+ body += newline
376
+ body += newline.join(color_lines)
377
+ updated = (
378
+ updated[:match.start(2)] + body + updated[match.end(2):]
379
+ )
380
+ else:
381
+ newline = "\r\n" if "\r\n" in updated else "\n"
382
+ header = (
383
+ "---" + newline + newline.join(color_lines)
384
+ + newline + "---" + newline
385
+ )
386
+ updated = header + updated
387
+ # }}}
388
+ if updated != original:
389
+ directory = os.path.dirname(os.path.abspath(filename))
390
+ fd, temp_path = tempfile.mkstemp(
391
+ prefix=".cpb-comments-", dir=directory
392
+ )
393
+ try:
394
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fp:
395
+ fp.write(updated)
396
+ os.chmod(temp_path, os.stat(filename).st_mode)
397
+ os.replace(temp_path, filename)
398
+ finally:
399
+ if os.path.exists(temp_path):
400
+ os.unlink(temp_path)
401
+ 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