pyDiffTools 0.1.33__tar.gz → 0.1.35__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 (112) hide show
  1. {pydifftools-0.1.33/pyDiffTools.egg-info → pydifftools-0.1.35}/PKG-INFO +42 -1
  2. {pydifftools-0.1.33 → pydifftools-0.1.35}/README.rst +41 -0
  3. {pydifftools-0.1.33 → pydifftools-0.1.35/pyDiffTools.egg-info}/PKG-INFO +42 -1
  4. {pydifftools-0.1.33 → pydifftools-0.1.35}/pyDiffTools.egg-info/SOURCES.txt +6 -1
  5. pydifftools-0.1.35/pydifftools/bibliography.py +305 -0
  6. pydifftools-0.1.35/pydifftools/citation_dialog.py +124 -0
  7. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/continuous.py +229 -16
  8. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/wrap_sentences.py +70 -66
  9. pydifftools-0.1.35/pydifftools/zotero.py +278 -0
  10. {pydifftools-0.1.33 → pydifftools-0.1.35}/pyproject.toml +1 -1
  11. pydifftools-0.1.35/tests/test_bibliography.py +281 -0
  12. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_continuous_shutdown.py +25 -0
  13. pydifftools-0.1.35/tests/test_zotero.py +354 -0
  14. {pydifftools-0.1.33 → pydifftools-0.1.35}/LICENSE.md +0 -0
  15. {pydifftools-0.1.33 → pydifftools-0.1.35}/MANIFEST.in +0 -0
  16. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/_quarto.yml +0 -0
  17. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  18. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  19. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  20. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  21. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  22. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  23. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  24. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  25. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  26. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  27. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  28. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  29. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  30. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  31. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  32. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  33. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  34. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  35. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  36. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  37. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  38. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  39. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/example.qmd +0 -0
  40. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/example.tex +0 -0
  41. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/index.qmd +0 -0
  42. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  43. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  44. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  45. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  46. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/independent.qmd +0 -0
  47. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/index.qmd +0 -0
  48. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  49. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  50. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  51. {pydifftools-0.1.33 → pydifftools-0.1.35}/example_notebook/project1/test_include.qmd +0 -0
  52. {pydifftools-0.1.33 → pydifftools-0.1.35}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  53. {pydifftools-0.1.33 → pydifftools-0.1.35}/pyDiffTools.egg-info/entry_points.txt +0 -0
  54. {pydifftools-0.1.33 → pydifftools-0.1.35}/pyDiffTools.egg-info/requires.txt +0 -0
  55. {pydifftools-0.1.33 → pydifftools-0.1.35}/pyDiffTools.egg-info/top_level.txt +0 -0
  56. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/__init__.py +0 -0
  57. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/browser_lifecycle.py +0 -0
  58. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/check_numbers.py +0 -0
  59. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/command_line.py +0 -0
  60. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/command_registry.py +0 -0
  61. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/comment_functions.py +0 -0
  62. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/comment_tags.lua +0 -0
  63. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/comment_tags_margin.lua +0 -0
  64. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/comment_tags_no_comments.lua +0 -0
  65. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/comment_toggle.js +0 -0
  66. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/comments.css +0 -0
  67. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/copy_files.py +0 -0
  68. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/diff-doc.js +0 -0
  69. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/doc_contents.py +0 -0
  70. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/__init__.py +0 -0
  71. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/comparison.py +0 -0
  72. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  73. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/graph.py +0 -0
  74. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  75. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/watch_graph.py +0 -0
  76. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/flowchart/wgrph_view.js +0 -0
  77. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/forward_search.py +0 -0
  78. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/git_gd.py +0 -0
  79. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/git_gd_history.py +0 -0
  80. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/git_gd_image.py +0 -0
  81. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/git_gd_qt.py +0 -0
  82. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/html_comments.py +0 -0
  83. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/html_uncomments.py +0 -0
  84. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/log_example.py +0 -0
  85. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/match_spaces.py +0 -0
  86. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/notebook/__init__.py +0 -0
  87. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/notebook/display.py +0 -0
  88. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/notebook/fast_build.py +0 -0
  89. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/notebook/tex_to_qmd.py +0 -0
  90. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/onewordify.py +0 -0
  91. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/onewordify_undo.py +0 -0
  92. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/outline.py +0 -0
  93. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/rearrange_tex.py +0 -0
  94. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/searchacro.py +0 -0
  95. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/separate_comments.py +0 -0
  96. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/split_conflict.py +0 -0
  97. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/unseparate_comments.py +0 -0
  98. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/update_check.py +0 -0
  99. {pydifftools-0.1.33 → pydifftools-0.1.35}/pydifftools/xml2xlsx.vbs +0 -0
  100. {pydifftools-0.1.33 → pydifftools-0.1.35}/setup.cfg +0 -0
  101. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_browser_lifecycle.py +0 -0
  102. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_check_single_use.py +0 -0
  103. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_command_line_help.py +0 -0
  104. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_forward_search.py +0 -0
  105. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_git_gd_history.py +0 -0
  106. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_git_gd_image.py +0 -0
  107. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_git_gd_qt.py +0 -0
  108. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_rrng.py +0 -0
  109. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_tex_to_qmd.py +0 -0
  110. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_update_check.py +0 -0
  111. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_wrap_check.py +0 -0
  112. {pydifftools-0.1.33 → pydifftools-0.1.35}/tests/test_wrapping.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.33
3
+ Version: 0.1.35
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -96,6 +96,47 @@ included are (listed in order of fun/utility):
96
96
  (currently tested on windows with *version 3.5* of the former,
97
97
  *not the latest installer*,
98
98
  since crossref isn't built with the most recent version).
99
+
100
+ When Pandoc reports missing citations, ``cpb`` retrieves their BibTeX
101
+ entries from the running local Zotero application, updates the active
102
+ bibliography, and rebuilds once. Install and enable
103
+ `Better BibTeX <https://retorque.re/zotero-better-bibtex/installation/>`_
104
+ in Zotero. No API key or Python dependency is needed. The integration uses
105
+ Better BibTeX's documented
106
+ `local JSON-RPC API <https://retorque.re/zotero-better-bibtex/exporting/json-rpc/>`_
107
+ at ``127.0.0.1:23119`` and searches the default personal library.
108
+ Zotero supports Windows, macOS, and Linux; this integration uses portable
109
+ Python file operations and Qt dialogs. Its automated runtime checks have
110
+ been performed on Linux, not on Windows.
111
+
112
+ Declare a single local ``.bib`` file with ``bibliography: references.bib``
113
+ in the Markdown YAML header; relative paths are resolved beside the
114
+ Markdown file. Without that declaration, ``cpb`` uses a single adjacent
115
+ ``.bib`` file. Multiple or unsupported bibliographies are rendered normally
116
+ but are not automatically edited. Missing keys, unavailable Zotero, and
117
+ failed updates produce warnings; rendering continues. If Zotero or Better
118
+ BibTeX is unavailable at startup, an informational Qt window explains the
119
+ feature without blocking the preview.
120
+
121
+ Before importing a possible duplicate, a Qt dialog offers keeping the
122
+ existing entry, keeping the Zotero entry, or merging selected fields.
123
+ Matching uses DOI, book ISBN/edition, or a similar title with the same
124
+ first author and year. Conflicting DOIs are not treated as duplicates.
125
+ This follows the identifier and field comparison approach used by
126
+ `JabRef <https://docs.jabref.org/finding-sorting-and-cleaning-entries/mergeentries>`_.
127
+ The merge defaults to existing values and the existing key, filling absent
128
+ fields from Zotero. You can also keep both entries or skip an import.
129
+ After resolving a duplicate, ``@UNCHOSEN`` becomes ``@CHOSEN`` in the active
130
+ Markdown document. Code, links, ordinary text, and other documents are not
131
+ rewritten. Untouched bibliography entries retain their original text.
132
+ Edits made while a lookup or dialog is in progress abort the update.
133
+
134
+ Tests use recorded Zotero responses and real Pandoc. For the optional local
135
+ smoke tests, run ``PYDIFFTOOLS_ZOTERO_LIVE=1 python -m pytest -q
136
+ tests/test_zotero.py``. This additionally checks the live Hoult export and
137
+ builds a temporary copy of ``~/notebook/papers/eigenmode``, verifying that
138
+ the original files are unchanged. The ordinary suite requires neither
139
+ Zotero nor this notebook directory.
99
140
  - `pydifft wgrph <graph.yaml>` watches a YAML flowchart description,
100
141
  rebuilds the DOT/SVG output using GraphViz, and keeps a browser window
101
142
  refreshed as you edit the file. This wraps the former
@@ -41,6 +41,47 @@ included are (listed in order of fun/utility):
41
41
  (currently tested on windows with *version 3.5* of the former,
42
42
  *not the latest installer*,
43
43
  since crossref isn't built with the most recent version).
44
+
45
+ When Pandoc reports missing citations, ``cpb`` retrieves their BibTeX
46
+ entries from the running local Zotero application, updates the active
47
+ bibliography, and rebuilds once. Install and enable
48
+ `Better BibTeX <https://retorque.re/zotero-better-bibtex/installation/>`_
49
+ in Zotero. No API key or Python dependency is needed. The integration uses
50
+ Better BibTeX's documented
51
+ `local JSON-RPC API <https://retorque.re/zotero-better-bibtex/exporting/json-rpc/>`_
52
+ at ``127.0.0.1:23119`` and searches the default personal library.
53
+ Zotero supports Windows, macOS, and Linux; this integration uses portable
54
+ Python file operations and Qt dialogs. Its automated runtime checks have
55
+ been performed on Linux, not on Windows.
56
+
57
+ Declare a single local ``.bib`` file with ``bibliography: references.bib``
58
+ in the Markdown YAML header; relative paths are resolved beside the
59
+ Markdown file. Without that declaration, ``cpb`` uses a single adjacent
60
+ ``.bib`` file. Multiple or unsupported bibliographies are rendered normally
61
+ but are not automatically edited. Missing keys, unavailable Zotero, and
62
+ failed updates produce warnings; rendering continues. If Zotero or Better
63
+ BibTeX is unavailable at startup, an informational Qt window explains the
64
+ feature without blocking the preview.
65
+
66
+ Before importing a possible duplicate, a Qt dialog offers keeping the
67
+ existing entry, keeping the Zotero entry, or merging selected fields.
68
+ Matching uses DOI, book ISBN/edition, or a similar title with the same
69
+ first author and year. Conflicting DOIs are not treated as duplicates.
70
+ This follows the identifier and field comparison approach used by
71
+ `JabRef <https://docs.jabref.org/finding-sorting-and-cleaning-entries/mergeentries>`_.
72
+ The merge defaults to existing values and the existing key, filling absent
73
+ fields from Zotero. You can also keep both entries or skip an import.
74
+ After resolving a duplicate, ``@UNCHOSEN`` becomes ``@CHOSEN`` in the active
75
+ Markdown document. Code, links, ordinary text, and other documents are not
76
+ rewritten. Untouched bibliography entries retain their original text.
77
+ Edits made while a lookup or dialog is in progress abort the update.
78
+
79
+ Tests use recorded Zotero responses and real Pandoc. For the optional local
80
+ smoke tests, run ``PYDIFFTOOLS_ZOTERO_LIVE=1 python -m pytest -q
81
+ tests/test_zotero.py``. This additionally checks the live Hoult export and
82
+ builds a temporary copy of ``~/notebook/papers/eigenmode``, verifying that
83
+ the original files are unchanged. The ordinary suite requires neither
84
+ Zotero nor this notebook directory.
44
85
  - `pydifft wgrph <graph.yaml>` watches a YAML flowchart description,
45
86
  rebuilds the DOT/SVG output using GraphViz, and keeps a browser window
46
87
  refreshed as you edit the file. This wraps the former
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.33
3
+ Version: 0.1.35
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -96,6 +96,47 @@ included are (listed in order of fun/utility):
96
96
  (currently tested on windows with *version 3.5* of the former,
97
97
  *not the latest installer*,
98
98
  since crossref isn't built with the most recent version).
99
+
100
+ When Pandoc reports missing citations, ``cpb`` retrieves their BibTeX
101
+ entries from the running local Zotero application, updates the active
102
+ bibliography, and rebuilds once. Install and enable
103
+ `Better BibTeX <https://retorque.re/zotero-better-bibtex/installation/>`_
104
+ in Zotero. No API key or Python dependency is needed. The integration uses
105
+ Better BibTeX's documented
106
+ `local JSON-RPC API <https://retorque.re/zotero-better-bibtex/exporting/json-rpc/>`_
107
+ at ``127.0.0.1:23119`` and searches the default personal library.
108
+ Zotero supports Windows, macOS, and Linux; this integration uses portable
109
+ Python file operations and Qt dialogs. Its automated runtime checks have
110
+ been performed on Linux, not on Windows.
111
+
112
+ Declare a single local ``.bib`` file with ``bibliography: references.bib``
113
+ in the Markdown YAML header; relative paths are resolved beside the
114
+ Markdown file. Without that declaration, ``cpb`` uses a single adjacent
115
+ ``.bib`` file. Multiple or unsupported bibliographies are rendered normally
116
+ but are not automatically edited. Missing keys, unavailable Zotero, and
117
+ failed updates produce warnings; rendering continues. If Zotero or Better
118
+ BibTeX is unavailable at startup, an informational Qt window explains the
119
+ feature without blocking the preview.
120
+
121
+ Before importing a possible duplicate, a Qt dialog offers keeping the
122
+ existing entry, keeping the Zotero entry, or merging selected fields.
123
+ Matching uses DOI, book ISBN/edition, or a similar title with the same
124
+ first author and year. Conflicting DOIs are not treated as duplicates.
125
+ This follows the identifier and field comparison approach used by
126
+ `JabRef <https://docs.jabref.org/finding-sorting-and-cleaning-entries/mergeentries>`_.
127
+ The merge defaults to existing values and the existing key, filling absent
128
+ fields from Zotero. You can also keep both entries or skip an import.
129
+ After resolving a duplicate, ``@UNCHOSEN`` becomes ``@CHOSEN`` in the active
130
+ Markdown document. Code, links, ordinary text, and other documents are not
131
+ rewritten. Untouched bibliography entries retain their original text.
132
+ Edits made while a lookup or dialog is in progress abort the update.
133
+
134
+ Tests use recorded Zotero responses and real Pandoc. For the optional local
135
+ smoke tests, run ``PYDIFFTOOLS_ZOTERO_LIVE=1 python -m pytest -q
136
+ tests/test_zotero.py``. This additionally checks the live Hoult export and
137
+ builds a temporary copy of ``~/notebook/papers/eigenmode``, verifying that
138
+ the original files are unchanged. The ordinary suite requires neither
139
+ Zotero nor this notebook directory.
99
140
  - `pydifft wgrph <graph.yaml>` watches a YAML flowchart description,
100
141
  rebuilds the DOT/SVG output using GraphViz, and keeps a browser window
101
142
  refreshed as you edit the file. This wraps the former
@@ -45,8 +45,10 @@ pyDiffTools.egg-info/entry_points.txt
45
45
  pyDiffTools.egg-info/requires.txt
46
46
  pyDiffTools.egg-info/top_level.txt
47
47
  pydifftools/__init__.py
48
+ pydifftools/bibliography.py
48
49
  pydifftools/browser_lifecycle.py
49
50
  pydifftools/check_numbers.py
51
+ pydifftools/citation_dialog.py
50
52
  pydifftools/command_line.py
51
53
  pydifftools/command_registry.py
52
54
  pydifftools/comment_functions.py
@@ -79,6 +81,7 @@ pydifftools/unseparate_comments.py
79
81
  pydifftools/update_check.py
80
82
  pydifftools/wrap_sentences.py
81
83
  pydifftools/xml2xlsx.vbs
84
+ pydifftools/zotero.py
82
85
  pydifftools/flowchart/__init__.py
83
86
  pydifftools/flowchart/comparison.py
84
87
  pydifftools/flowchart/dot_to_yaml.py
@@ -90,6 +93,7 @@ pydifftools/notebook/__init__.py
90
93
  pydifftools/notebook/display.py
91
94
  pydifftools/notebook/fast_build.py
92
95
  pydifftools/notebook/tex_to_qmd.py
96
+ tests/test_bibliography.py
93
97
  tests/test_browser_lifecycle.py
94
98
  tests/test_check_single_use.py
95
99
  tests/test_command_line_help.py
@@ -102,4 +106,5 @@ tests/test_rrng.py
102
106
  tests/test_tex_to_qmd.py
103
107
  tests/test_update_check.py
104
108
  tests/test_wrap_check.py
105
- tests/test_wrapping.py
109
+ tests/test_wrapping.py
110
+ tests/test_zotero.py
@@ -0,0 +1,305 @@
1
+ """Lossless BibTeX edits and citation-aware Markdown key changes.
2
+
3
+ Pandoc remains the semantic parser. The small scanner here only locates raw
4
+ entry/field boundaries, preserving macros, custom fields and untouched text.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from difflib import SequenceMatcher
9
+ import html
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import unicodedata
19
+
20
+
21
+ def scan_bibtex(text, start, stops):
22
+ """Locate an unquoted delimiter outside balanced braces."""
23
+ depth = 0
24
+ quoted = False
25
+ position = start
26
+ while position < len(text):
27
+ char = text[position]
28
+ if char == "\\":
29
+ position += 2
30
+ continue
31
+ if char == "%" and depth == 0 and not quoted:
32
+ newline = text.find("\n", position)
33
+ position = len(text) if newline < 0 else newline + 1
34
+ continue
35
+ if depth == 0 and not quoted and char in stops:
36
+ return position
37
+ if char == '"' and depth == 0:
38
+ quoted = not quoted
39
+ elif char == "{":
40
+ depth += 1
41
+ elif char == "}":
42
+ depth -= 1
43
+ if depth < 0:
44
+ raise ValueError("unbalanced BibTeX braces")
45
+ position += 1
46
+ if depth or quoted:
47
+ raise ValueError("unterminated BibTeX value")
48
+ return position
49
+
50
+
51
+ @dataclass
52
+ class BibEntry:
53
+ start: int
54
+ end: int
55
+ kind: str
56
+ key: str
57
+ fields: dict
58
+
59
+ def render(self, key=None, fields=None):
60
+ selected = self.fields if fields is None else fields
61
+ body = ",\n".join(
62
+ f" {name} = {value}" for name, value in selected.items()
63
+ )
64
+ return f"@{self.kind}{{{key or self.key},\n{body}\n}}"
65
+
66
+
67
+ def bib_entries(text):
68
+ """Index entry spans and raw fields without reserializing them."""
69
+ entries = {}
70
+ position = 0
71
+ while position < len(text):
72
+ position = scan_bibtex(text, position, "@")
73
+ if position == len(text):
74
+ break
75
+ header = re.match(r"@([\w-]+)\s*([{(])", text[position:])
76
+ if not header:
77
+ position += 1
78
+ continue
79
+ start = position
80
+ kind = header[1].lower()
81
+ position += header.end()
82
+ closing = "}" if header[2] == "{" else ")"
83
+ end = scan_bibtex(text, position, closing)
84
+ if end == len(text):
85
+ raise ValueError("unterminated BibTeX entry")
86
+ body = text[position:end]
87
+ position = end + 1
88
+ if kind in {"comment", "preamble", "string"}:
89
+ continue
90
+ key_end = scan_bibtex(body, 0, ",")
91
+ key = body[:key_end].strip()
92
+ fields = {}
93
+ offset = key_end + 1
94
+ while offset < len(body):
95
+ whitespace = re.match(r"(?:\s|%[^\n]*(?:\n|$))*", body[offset:])
96
+ offset += whitespace.end()
97
+ if offset == len(body):
98
+ break
99
+ field = re.match(r"([\w-]+)\s*=\s*", body[offset:])
100
+ if not field:
101
+ raise ValueError(f"cannot safely edit BibTeX fields in {key}")
102
+ offset += field.end()
103
+ boundary = scan_bibtex(body, offset, ",")
104
+ name = field[1].lower()
105
+ if name in fields:
106
+ raise ValueError(f"duplicate BibTeX field {name} in {key}")
107
+ fields[name] = body[offset:boundary].strip()
108
+ offset = boundary + 1
109
+ if key in entries:
110
+ raise ValueError(f"duplicate BibTeX key {key}")
111
+ entries[key] = BibEntry(start, end + 1, kind, key, fields)
112
+ return entries
113
+
114
+
115
+ def normalized_text(value):
116
+ text = html.unescape(re.sub(r"<[^>]+>", "", str(value)))
117
+ text = unicodedata.normalize("NFKD", text).casefold()
118
+ return "".join(char for char in text if char.isalnum())
119
+
120
+
121
+ def duplicate_reason(existing, incoming):
122
+ """Identify likely duplicates for human review using Pandoc CSL records.
123
+
124
+ Inspired by JabRef's identifier-first, bibliographic-field comparison:
125
+ https://github.com/JabRef/jabref/blob/main/jablib/src/main/java/org/jabref/
126
+ logic/database/DuplicateCheck.java. No Java code is copied.
127
+ """
128
+ identifiers = []
129
+ for record in (existing, incoming):
130
+ doi = str(record.get("DOI", "")).strip().casefold()
131
+ identifiers.append(
132
+ re.sub(r"^(?:https?://(?:dx\.)?doi.org/|doi:\s*)", "", doi)
133
+ )
134
+ if all(identifiers):
135
+ return "Same DOI" if identifiers[0] == identifiers[1] else None
136
+ if existing.get("type") != incoming.get("type"):
137
+ return None
138
+ for field in ("edition", "chapter-number"):
139
+ if existing.get(field) and incoming.get(field):
140
+ if normalized_text(existing[field]) != normalized_text(
141
+ incoming[field]
142
+ ):
143
+ return None
144
+ if existing.get("type") == "chapter":
145
+ if normalized_text(existing.get("page")) != normalized_text(
146
+ incoming.get("page")
147
+ ):
148
+ return None
149
+ if existing.get("type") == "book":
150
+ isbn = normalized_text(existing.get("ISBN", ""))
151
+ if isbn and isbn == normalized_text(incoming.get("ISBN", "")):
152
+ return "Same ISBN and edition"
153
+ titles = [
154
+ normalized_text(record.get("title", ""))
155
+ for record in (existing, incoming)
156
+ ]
157
+ authors = [
158
+ record.get("author", record.get("editor", []))
159
+ for record in (existing, incoming)
160
+ ]
161
+ years = [
162
+ record.get("issued", {}).get("date-parts", [[]])[0][:1]
163
+ for record in (existing, incoming)
164
+ ]
165
+ if not all(titles) or not all(authors) or not all(years):
166
+ return None
167
+ surnames = [
168
+ normalized_text(names[0].get("family", names[0].get("literal", "")))
169
+ for names in authors
170
+ ]
171
+ similar_title = titles[0] == titles[1] or (
172
+ min(map(len, titles)) >= 20
173
+ and SequenceMatcher(None, *titles).ratio() >= 0.92
174
+ )
175
+ if (
176
+ similar_title
177
+ and surnames[0]
178
+ and surnames[0] == surnames[1]
179
+ and years[0] == years[1]
180
+ ):
181
+ return "Similar title, same first author and year"
182
+ return None
183
+
184
+
185
+ def markdown_ast(text, renames=None):
186
+ """Parse Markdown, optionally normalizing citation IDs for comparison."""
187
+ completed = subprocess.run(
188
+ ["pandoc", "--from=markdown-auto_identifiers", "--to=json"],
189
+ input=text.encode("utf-8"),
190
+ capture_output=True,
191
+ )
192
+ if completed.returncode:
193
+ raise ValueError("cannot parse Markdown to update citation keys")
194
+ tree = json.loads(completed.stdout)
195
+ if renames is not None:
196
+ pending = [tree]
197
+ while pending:
198
+ node = pending.pop()
199
+ if isinstance(node, dict):
200
+ if node.get("t") == "Cite":
201
+ # Pandoc stores both parsed citations and their source
202
+ # spelling; spelling changes when a key is renamed.
203
+ node["c"][1] = []
204
+ for citation in node["c"][0]:
205
+ old = citation["citationId"]
206
+ citation["citationId"] = renames.get(old, old)
207
+ pending.extend(node.values())
208
+ elif isinstance(node, list):
209
+ pending.extend(node)
210
+ return tree
211
+
212
+
213
+ def rewrite_citations(markdown, renames):
214
+ """Replace citation tokens, accepting edits only when Pandoc confirms them.
215
+
216
+ AST comparison protects code, links, email addresses, comments, ordinary
217
+ text and keys with a shared prefix, while retaining original formatting.
218
+ """
219
+ text = markdown.decode("utf-8")
220
+ if not renames:
221
+ return markdown
222
+ expected = markdown_ast(text, renames)
223
+ keys = sorted(renames, key=len, reverse=True)
224
+ pattern = re.compile(
225
+ r"@(?:\{("
226
+ + "|".join(map(re.escape, keys))
227
+ + r")\}|("
228
+ + "|".join(map(re.escape, keys))
229
+ + r"))"
230
+ )
231
+ for match in reversed(list(pattern.finditer(text))):
232
+ old = match[1] or match[2]
233
+ replacement = (
234
+ "@{" + renames[old] + "}" if match[1] else "@" + renames[old]
235
+ )
236
+ candidate = text[: match.start()] + replacement + text[match.end() :]
237
+ if markdown_ast(candidate, renames) == expected:
238
+ text = candidate
239
+ if markdown_ast(text, {}) != expected:
240
+ raise ValueError("could not safely update all Markdown citation keys")
241
+ return text.encode("utf-8")
242
+
243
+
244
+ def replace_bibliography_files(changes):
245
+ """Stage validated replacements and roll back if a later replacement fails.
246
+
247
+ Changes map paths to (original bytes, replacement bytes). Files are closed
248
+ before replacement for Windows. A crash across two replacements cannot be
249
+ atomic; ordinary write failures are rolled back without overwriting edits.
250
+ """
251
+ staged = {}
252
+ backups = {}
253
+ committed = []
254
+ temporaries = []
255
+ try:
256
+ for path, (original, replacement) in changes.items():
257
+ path = Path(path)
258
+ if replacement == original:
259
+ continue
260
+ mode = stat.S_IMODE(path.stat().st_mode)
261
+ if not mode & 0o222 or not os.access(path, os.W_OK):
262
+ raise PermissionError(f"file is not writable: {path}")
263
+ for content, targets in [
264
+ (original, backups),
265
+ (replacement, staged),
266
+ ]:
267
+ with tempfile.NamedTemporaryFile(
268
+ dir=path.parent, prefix=f".{path.name}.", delete=False
269
+ ) as output:
270
+ temporary = Path(output.name)
271
+ temporaries.append(temporary)
272
+ output.write(content)
273
+ output.flush()
274
+ os.fsync(output.fileno())
275
+ os.chmod(temporary, mode)
276
+ targets[path] = temporary
277
+ for path, (original, _) in changes.items():
278
+ if Path(path).read_bytes() != original:
279
+ raise ValueError(f"file changed during Zotero lookup: {path}")
280
+ for path in staged:
281
+ if path.read_bytes() != changes[path][0]:
282
+ raise ValueError(f"file changed during Zotero lookup: {path}")
283
+ os.replace(staged[path], path)
284
+ committed.append(path)
285
+ except (OSError, ValueError):
286
+ for path in reversed(committed):
287
+ if path.read_bytes() == changes[path][1]:
288
+ try:
289
+ os.replace(backups[path], path)
290
+ except OSError as exc:
291
+ # Keep a recovery copy if even rollback is prevented.
292
+ temporaries.remove(backups[path])
293
+ raise OSError(
294
+ f"could not restore {path}; original saved at "
295
+ f"{backups[path]}"
296
+ ) from exc
297
+ raise
298
+ finally:
299
+ for temporary in temporaries:
300
+ try:
301
+ temporary.unlink(missing_ok=True)
302
+ except OSError as exc:
303
+ print(
304
+ f"cpb: cannot remove {temporary}: {exc}", file=sys.stderr
305
+ )
@@ -0,0 +1,124 @@
1
+ """Qt review of a possible duplicate citation, run in a separate process."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ from PySide6.QtCore import Qt
7
+ from PySide6.QtWidgets import (
8
+ QApplication,
9
+ QComboBox,
10
+ QDialog,
11
+ QHBoxLayout,
12
+ QHeaderView,
13
+ QLabel,
14
+ QPushButton,
15
+ QTableWidget,
16
+ QTableWidgetItem,
17
+ QVBoxLayout,
18
+ )
19
+
20
+
21
+ class DuplicateDialog(QDialog):
22
+ """Choose an entry or merge fields; default to existing values and key."""
23
+
24
+ def __init__(self, matches, incoming, source):
25
+ super().__init__()
26
+ self.matches = matches
27
+ self.incoming = incoming
28
+ self.decision = None
29
+ self.setWindowTitle("pydifft cpb — Possible duplicate citation")
30
+ self.resize(1000, 650)
31
+ layout = QVBoxLayout(self)
32
+ explanation = QLabel(
33
+ f"Review the imported citation for {source}.\n"
34
+ "Keeping or merging a duplicate also replaces @UNCHOSEN with "
35
+ "@CHOSEN in this Markdown file. Other documents are not edited.\n"
36
+ "For a merge, choose each field below, including the citation key."
37
+ )
38
+ explanation.setWordWrap(True)
39
+ layout.addWidget(explanation)
40
+ self.match_selector = QComboBox()
41
+ for match in matches:
42
+ self.match_selector.addItem(f"{match['key']} — {match['reason']}")
43
+ layout.addWidget(self.match_selector)
44
+ self.table = QTableWidget()
45
+ self.table.setColumnCount(4)
46
+ self.table.setHorizontalHeaderLabels(
47
+ ["Field", "Existing bibliography", "Zotero", "Merge uses"]
48
+ )
49
+ self.table.horizontalHeader().setSectionResizeMode(
50
+ QHeaderView.ResizeMode.Stretch
51
+ )
52
+ layout.addWidget(self.table)
53
+ buttons = QHBoxLayout()
54
+ self.buttons = {}
55
+ for label, action in [
56
+ ("Keep existing", "existing"),
57
+ ("Keep Zotero", "incoming"),
58
+ ("Merge selected fields", "merge"),
59
+ ("Not a duplicate: keep both", "both"),
60
+ ("Skip this import", "skip"),
61
+ ]:
62
+ button = QPushButton(label)
63
+ button.setAutoDefault(False)
64
+ button.clicked.connect(
65
+ lambda _checked=False, choice=action: self.choose(choice)
66
+ )
67
+ self.buttons[action] = button
68
+ buttons.addWidget(button)
69
+ layout.addLayout(buttons)
70
+ self.match_selector.currentIndexChanged.connect(self.populate)
71
+ self.populate()
72
+
73
+ def populate(self):
74
+ existing = self.matches[self.match_selector.currentIndex()]
75
+ self.left = {
76
+ "citation key": existing["key"],
77
+ "entry type": existing["kind"],
78
+ **existing["fields"],
79
+ }
80
+ self.right = {
81
+ "citation key": self.incoming["key"],
82
+ "entry type": self.incoming["kind"],
83
+ **self.incoming["fields"],
84
+ }
85
+ self.names = list(dict.fromkeys([*self.left, *self.right]))
86
+ self.table.setRowCount(len(self.names))
87
+ self.selectors = {}
88
+ for row, name in enumerate(self.names):
89
+ for column, value in enumerate(
90
+ [name, self.left.get(name, ""), self.right.get(name, "")]
91
+ ):
92
+ cell = QTableWidgetItem(value)
93
+ cell.setFlags(cell.flags() & ~Qt.ItemFlag.ItemIsEditable)
94
+ self.table.setItem(row, column, cell)
95
+ selector = QComboBox()
96
+ if name in self.left:
97
+ selector.addItem("Existing", self.left[name])
98
+ if name in self.right:
99
+ selector.addItem("Zotero", self.right[name])
100
+ self.table.setCellWidget(row, 3, selector)
101
+ self.selectors[name] = selector
102
+ self.table.resizeRowsToContents()
103
+
104
+ def choose(self, action):
105
+ existing = self.matches[self.match_selector.currentIndex()]
106
+ self.decision = {"action": action, "existing_key": existing["key"]}
107
+ if action == "merge":
108
+ values = {
109
+ name: selector.currentData()
110
+ for name, selector in self.selectors.items()
111
+ }
112
+ self.decision.update(
113
+ key=values.pop("citation key"),
114
+ kind=values.pop("entry type"),
115
+ fields=values,
116
+ )
117
+ self.accept()
118
+
119
+
120
+ if __name__ == "__main__":
121
+ app = QApplication(sys.argv[:1])
122
+ dialog = DuplicateDialog(**json.load(sys.stdin))
123
+ dialog.exec()
124
+ print(json.dumps(dialog.decision))