precis-mcp 0.2.0__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.
- precis_mcp-0.2.0/.github/workflows/publish.yml +33 -0
- precis_mcp-0.2.0/.gitignore +35 -0
- precis_mcp-0.2.0/CHANGELOG.md +11 -0
- precis_mcp-0.2.0/LICENSE +14 -0
- precis_mcp-0.2.0/PKG-INFO +90 -0
- precis_mcp-0.2.0/README.md +58 -0
- precis_mcp-0.2.0/pyproject.toml +76 -0
- precis_mcp-0.2.0/src/precis/__init__.py +3 -0
- precis_mcp-0.2.0/src/precis/citations.py +176 -0
- precis_mcp-0.2.0/src/precis/config.py +39 -0
- precis_mcp-0.2.0/src/precis/formatting.py +114 -0
- precis_mcp-0.2.0/src/precis/grep.py +48 -0
- precis_mcp-0.2.0/src/precis/nodes.py +218 -0
- precis_mcp-0.2.0/src/precis/parser/__init__.py +15 -0
- precis_mcp-0.2.0/src/precis/parser/base.py +60 -0
- precis_mcp-0.2.0/src/precis/parser/docx.py +656 -0
- precis_mcp-0.2.0/src/precis/parser/latex.py +706 -0
- precis_mcp-0.2.0/src/precis/py.typed +0 -0
- precis_mcp-0.2.0/src/precis/rake.py +12 -0
- precis_mcp-0.2.0/src/precis/server.py +125 -0
- precis_mcp-0.2.0/src/precis/tools.py +543 -0
- precis_mcp-0.2.0/tests/conftest.py +124 -0
- precis_mcp-0.2.0/tests/test_citations.py +625 -0
- precis_mcp-0.2.0/tests/test_formatting.py +90 -0
- precis_mcp-0.2.0/tests/test_grep.py +42 -0
- precis_mcp-0.2.0/tests/test_nodes.py +141 -0
- precis_mcp-0.2.0/tests/test_parser_docx.py +163 -0
- precis_mcp-0.2.0/tests/test_parser_latex.py +415 -0
- precis_mcp-0.2.0/tests/test_rake.py +124 -0
- precis_mcp-0.2.0/tests/test_tools.py +540 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write # trusted publishing (OIDC)
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v4
|
|
17
|
+
with:
|
|
18
|
+
version: "latest"
|
|
19
|
+
- run: uv venv
|
|
20
|
+
- run: uv pip install -e ".[dev]"
|
|
21
|
+
- run: uv run --no-sync pytest
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
needs: test
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
environment: pypi
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: astral-sh/setup-uv@v4
|
|
30
|
+
with:
|
|
31
|
+
version: "latest"
|
|
32
|
+
- run: uv build
|
|
33
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
*.egg
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
|
|
14
|
+
# Testing
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
*~
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# UV
|
|
31
|
+
uv.lock
|
|
32
|
+
|
|
33
|
+
# Package-specific
|
|
34
|
+
*.docx.tmp
|
|
35
|
+
*.tex.tmp
|
precis_mcp-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Copyright (c) 2026 Reto Stamm and Acatome Contributors
|
|
2
|
+
|
|
3
|
+
This program is free software: you can redistribute it and/or modify
|
|
4
|
+
it under the terms of the GNU General Public License as published by
|
|
5
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
6
|
+
(at your option) any later version.
|
|
7
|
+
|
|
8
|
+
This program is distributed in the hope that it will be useful,
|
|
9
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
10
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
11
|
+
GNU General Public License for more details.
|
|
12
|
+
|
|
13
|
+
You should have received a copy of the GNU General Public License
|
|
14
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: precis-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for compressed structured context on .docx and .tex documents
|
|
5
|
+
Project-URL: Homepage, https://github.com/acatome/precis-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/acatome/precis-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/acatome/precis-mcp/issues
|
|
8
|
+
Author-email: Reto Stamm <reto@retostamm.com>
|
|
9
|
+
License-Expression: GPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: document,docx,keyword-extraction,latex,llm,mcp,precis
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: lxml>=5.0
|
|
21
|
+
Requires-Dist: mcp[cli]>=1.0
|
|
22
|
+
Requires-Dist: platformdirs>=4.0
|
|
23
|
+
Requires-Dist: precis-summary>=0.1.0
|
|
24
|
+
Requires-Dist: python-docx>=1.1
|
|
25
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# precis-mcp
|
|
34
|
+
|
|
35
|
+
A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives LLMs compressed, structured context for `.docx` and `.tex` documents. It maintains a heading tree with RAKE keyword summaries so the LLM can navigate and edit documents without flooding context.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **5 tools** — `activate`, `toc`, `get`, `put`, `move`
|
|
40
|
+
- **Dual addressing** — 5-char content-hash slugs + positional heading paths
|
|
41
|
+
- **RAKE keyword extraction** — stateless, zero-dependency precis generation (<5ms)
|
|
42
|
+
- **DOCX citations** — `[@key]` round-trip with styled hyperlinks and bibliography entries
|
|
43
|
+
- **Track changes** — `put()` writes Word revision markup by default
|
|
44
|
+
- **LaTeX support** — `\input`/`\include` resolution, `\label{}` aliases, `.bib` parsing
|
|
45
|
+
- **Atomic I/O** — every call reads fresh from disk, no stale state
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv pip install -e ".[dev]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Run as an MCP server:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
precis
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or use with an MCP client:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from precis.tools import Session, activate, toc, get, put
|
|
65
|
+
|
|
66
|
+
session = Session()
|
|
67
|
+
await activate(session, "paper.docx")
|
|
68
|
+
await toc(session)
|
|
69
|
+
await get(session, id="KR8M2")
|
|
70
|
+
await put(session, id="KR8M2", text="Updated text.", mode="replace")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
`~/.config/precis/precis.toml`:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[precis]
|
|
79
|
+
author = "precis" # track-changes author name (DOCX only)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Testing
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv run python -m pytest tests/ -v
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
GPL-3.0-or-later — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# precis-mcp
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives LLMs compressed, structured context for `.docx` and `.tex` documents. It maintains a heading tree with RAKE keyword summaries so the LLM can navigate and edit documents without flooding context.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **5 tools** — `activate`, `toc`, `get`, `put`, `move`
|
|
8
|
+
- **Dual addressing** — 5-char content-hash slugs + positional heading paths
|
|
9
|
+
- **RAKE keyword extraction** — stateless, zero-dependency precis generation (<5ms)
|
|
10
|
+
- **DOCX citations** — `[@key]` round-trip with styled hyperlinks and bibliography entries
|
|
11
|
+
- **Track changes** — `put()` writes Word revision markup by default
|
|
12
|
+
- **LaTeX support** — `\input`/`\include` resolution, `\label{}` aliases, `.bib` parsing
|
|
13
|
+
- **Atomic I/O** — every call reads fresh from disk, no stale state
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv pip install -e ".[dev]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Run as an MCP server:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
precis
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or use with an MCP client:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from precis.tools import Session, activate, toc, get, put
|
|
33
|
+
|
|
34
|
+
session = Session()
|
|
35
|
+
await activate(session, "paper.docx")
|
|
36
|
+
await toc(session)
|
|
37
|
+
await get(session, id="KR8M2")
|
|
38
|
+
await put(session, id="KR8M2", text="Updated text.", mode="replace")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
`~/.config/precis/precis.toml`:
|
|
44
|
+
|
|
45
|
+
```toml
|
|
46
|
+
[precis]
|
|
47
|
+
author = "precis" # track-changes author name (DOCX only)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Testing
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run python -m pytest tests/ -v
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
GPL-3.0-or-later — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "precis-mcp"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "MCP server for compressed structured context on .docx and .tex documents"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "GPL-3.0-or-later"
|
|
11
|
+
authors = [{name = "Reto Stamm", email = "reto@retostamm.com"}]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
keywords = ["mcp", "docx", "latex", "llm", "document", "precis", "keyword-extraction"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Text Processing :: Markup",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
dependencies = [
|
|
25
|
+
"mcp[cli]>=1.0",
|
|
26
|
+
"python-docx>=1.1",
|
|
27
|
+
"lxml>=5.0",
|
|
28
|
+
"platformdirs>=4.0",
|
|
29
|
+
"precis-summary>=0.1.0",
|
|
30
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/acatome/precis-mcp"
|
|
35
|
+
Repository = "https://github.com/acatome/precis-mcp"
|
|
36
|
+
Issues = "https://github.com/acatome/precis-mcp/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.0",
|
|
41
|
+
"pytest-asyncio>=0.24",
|
|
42
|
+
"black>=24.0",
|
|
43
|
+
"ruff>=0.5",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
precis = "precis.server:main"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/precis"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
asyncio_mode = "auto"
|
|
55
|
+
|
|
56
|
+
[tool.black]
|
|
57
|
+
line-length = 88
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 88
|
|
61
|
+
|
|
62
|
+
[tool.bumpversion]
|
|
63
|
+
current_version = "0.2.0"
|
|
64
|
+
commit = true
|
|
65
|
+
tag = true
|
|
66
|
+
tag_name = "v{new_version}"
|
|
67
|
+
|
|
68
|
+
[[tool.bumpversion.files]]
|
|
69
|
+
filename = "pyproject.toml"
|
|
70
|
+
search = 'version = "{current_version}"'
|
|
71
|
+
replace = 'version = "{new_version}"'
|
|
72
|
+
|
|
73
|
+
[[tool.bumpversion.files]]
|
|
74
|
+
filename = "src/precis/__init__.py"
|
|
75
|
+
search = '__version__ = "{current_version}"'
|
|
76
|
+
replace = '__version__ = "{new_version}"'
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Citation system — style engine, bookmark/link helpers, DOCX style creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Markdown citation patterns
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
# Inline citation: [@key]
|
|
14
|
+
CITE_RE = re.compile(r"\[@([^\]\s]+)\]")
|
|
15
|
+
|
|
16
|
+
# Orphaned citation (lost bookmark, style survived): [@?:displayed text]
|
|
17
|
+
ORPHAN_CITE_RE = re.compile(r"\[@\?:([^\]]+)\]")
|
|
18
|
+
|
|
19
|
+
# Bibliography definition: [@key]: full reference text
|
|
20
|
+
BIB_DEF_RE = re.compile(r"^\[@([^\]\s]+)\]:\s*(.+)$")
|
|
21
|
+
|
|
22
|
+
ORPHAN_PREFIX = "?:"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_orphan_key(key: str) -> bool:
|
|
26
|
+
"""True if the key is an orphaned citation (lost bookmark, style survived)."""
|
|
27
|
+
return key.startswith(ORPHAN_PREFIX)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def orphan_text(key: str) -> str:
|
|
31
|
+
"""Extract the displayed text from an orphan key."""
|
|
32
|
+
return key[len(ORPHAN_PREFIX) :]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Bookmark naming (for bibliography definitions only)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
REF_BOOKMARK_RE = re.compile(r"^ref_(.+)$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ref_bookmark_name(key: str) -> str:
|
|
43
|
+
"""Generate bookmark name for a bibliography entry."""
|
|
44
|
+
return f"ref_{key}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_ref_bookmark(name: str) -> str | None:
|
|
48
|
+
"""Parse ref_key → key or None."""
|
|
49
|
+
m = REF_BOOKMARK_RE.match(name)
|
|
50
|
+
if m:
|
|
51
|
+
return m.group(1)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Citation styles
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
STYLES = ("author-year", "numbered", "superscript")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class BibEntry:
|
|
64
|
+
"""A bibliography entry with parsed metadata for rendering."""
|
|
65
|
+
|
|
66
|
+
key: str
|
|
67
|
+
text: str # full formatted reference
|
|
68
|
+
author_short: str = "" # "Smith" or "Smith & Jones" or "Smith et al."
|
|
69
|
+
year: str = ""
|
|
70
|
+
|
|
71
|
+
def render_author_year(self) -> str:
|
|
72
|
+
if self.author_short and self.year:
|
|
73
|
+
return f"({self.author_short} {self.year})"
|
|
74
|
+
return f"[@{self.key}]"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class CitationIndex:
|
|
79
|
+
"""Tracks citation order and bibliography for a document."""
|
|
80
|
+
|
|
81
|
+
style: str = "author-year"
|
|
82
|
+
entries: dict[str, BibEntry] = field(default_factory=dict)
|
|
83
|
+
cite_order: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def register_bib(self, key: str, text: str) -> None:
|
|
86
|
+
"""Register a bibliography entry."""
|
|
87
|
+
author_short, year = _parse_author_year(text)
|
|
88
|
+
self.entries[key] = BibEntry(
|
|
89
|
+
key=key, text=text, author_short=author_short, year=year
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def cite(self, key: str) -> int:
|
|
93
|
+
"""Record a citation occurrence. Returns the 1-based citation number."""
|
|
94
|
+
if key not in self.cite_order:
|
|
95
|
+
self.cite_order.append(key)
|
|
96
|
+
return self.cite_order.index(key) + 1
|
|
97
|
+
|
|
98
|
+
def render_inline(self, key: str) -> tuple[str, bool]:
|
|
99
|
+
"""Render an inline citation. Returns (text, is_superscript)."""
|
|
100
|
+
num = self.cite(key)
|
|
101
|
+
if self.style == "numbered":
|
|
102
|
+
return f"[{num}]", False
|
|
103
|
+
elif self.style == "superscript":
|
|
104
|
+
return str(num), True
|
|
105
|
+
else: # author-year
|
|
106
|
+
entry = self.entries.get(key)
|
|
107
|
+
if entry:
|
|
108
|
+
return entry.render_author_year(), False
|
|
109
|
+
return f"[@{key}]", False
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"style": self.style,
|
|
114
|
+
"entries": {
|
|
115
|
+
k: {"text": e.text, "author_short": e.author_short, "year": e.year}
|
|
116
|
+
for k, e in self.entries.items()
|
|
117
|
+
},
|
|
118
|
+
"cite_order": self.cite_order,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_dict(cls, data: dict[str, Any]) -> CitationIndex:
|
|
123
|
+
idx = cls(style=data.get("style", "author-year"))
|
|
124
|
+
idx.cite_order = data.get("cite_order", [])
|
|
125
|
+
for k, v in data.get("entries", {}).items():
|
|
126
|
+
idx.entries[k] = BibEntry(
|
|
127
|
+
key=k,
|
|
128
|
+
text=v.get("text", ""),
|
|
129
|
+
author_short=v.get("author_short", ""),
|
|
130
|
+
year=v.get("year", ""),
|
|
131
|
+
)
|
|
132
|
+
return idx
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Author/year extraction heuristic
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# Matches "(2020)" or ", 2020" or "(2020)." at the end-ish of a reference
|
|
140
|
+
_YEAR_RE = re.compile(r"\b((?:19|20)\d{2})\b")
|
|
141
|
+
# First word(s) before a comma — typically the author surname
|
|
142
|
+
_AUTHOR_RE = re.compile(r"^([A-Z][a-z]+(?:\s*(?:&|and)\s*[A-Z][a-z]+)?)")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_author_year(text: str) -> tuple[str, str]:
|
|
146
|
+
"""Best-effort extraction of (author_short, year) from a reference string.
|
|
147
|
+
|
|
148
|
+
Returns ("", "") if nothing found.
|
|
149
|
+
"""
|
|
150
|
+
year = ""
|
|
151
|
+
author = ""
|
|
152
|
+
|
|
153
|
+
ym = _YEAR_RE.search(text)
|
|
154
|
+
if ym:
|
|
155
|
+
year = ym.group(1)
|
|
156
|
+
|
|
157
|
+
am = _AUTHOR_RE.match(text.strip())
|
|
158
|
+
if am:
|
|
159
|
+
author = am.group(1)
|
|
160
|
+
# Check for et al. — if there's more text after the matched authors
|
|
161
|
+
# before the year, assume et al.
|
|
162
|
+
rest = text.strip()[am.end() :].lstrip(",").strip()
|
|
163
|
+
if rest and not rest[0].isdigit() and "&" not in am.group(1):
|
|
164
|
+
# More authors follow — use et al.
|
|
165
|
+
if "," in rest.split("(")[0] if "(" in rest else rest:
|
|
166
|
+
author = f"{author} et al."
|
|
167
|
+
|
|
168
|
+
return author, year
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# DOCX style names
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
CITATION_REF_STYLE = "CitationRef"
|
|
176
|
+
BIB_ENTRY_STYLE = "BibEntry"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Configuration from ~/.config/precis/precis.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
import tomllib
|
|
11
|
+
else:
|
|
12
|
+
import tomli as tomllib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "precis"
|
|
16
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "precis.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PrecisConfig:
|
|
21
|
+
"""Precis config — just track-changes author name."""
|
|
22
|
+
|
|
23
|
+
author: str = "precis"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def load(cls, path: Path | None = None) -> PrecisConfig:
|
|
27
|
+
"""Load config from TOML file, falling back to defaults."""
|
|
28
|
+
path = path or DEFAULT_CONFIG_FILE
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return cls()
|
|
31
|
+
try:
|
|
32
|
+
with open(path, "rb") as f:
|
|
33
|
+
data = tomllib.load(f)
|
|
34
|
+
section = data.get("precis", {})
|
|
35
|
+
return cls(
|
|
36
|
+
author=section.get("author", cls.author),
|
|
37
|
+
)
|
|
38
|
+
except Exception:
|
|
39
|
+
return cls()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Markdown ↔ DOCX run formatting conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FormattedRun:
|
|
11
|
+
"""A text run with formatting properties."""
|
|
12
|
+
|
|
13
|
+
text: str
|
|
14
|
+
bold: bool = False
|
|
15
|
+
italic: bool = False
|
|
16
|
+
superscript: bool = False
|
|
17
|
+
subscript: bool = False
|
|
18
|
+
strike: bool = False
|
|
19
|
+
url: str = "" # non-empty = hyperlink
|
|
20
|
+
cite_key: str = "" # non-empty = citation reference [@key]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def runs_to_markdown(runs: list[FormattedRun]) -> str:
|
|
24
|
+
"""Convert formatted runs to Markdown text."""
|
|
25
|
+
parts = []
|
|
26
|
+
for r in runs:
|
|
27
|
+
t = r.text
|
|
28
|
+
if r.cite_key:
|
|
29
|
+
t = f"[@{r.cite_key}]"
|
|
30
|
+
elif r.url:
|
|
31
|
+
t = f"[{t}]({r.url})"
|
|
32
|
+
if r.superscript:
|
|
33
|
+
t = f"<sup>{t}</sup>"
|
|
34
|
+
if r.subscript:
|
|
35
|
+
t = f"<sub>{t}</sub>"
|
|
36
|
+
if r.bold and r.italic:
|
|
37
|
+
t = f"***{t}***"
|
|
38
|
+
elif r.bold:
|
|
39
|
+
t = f"**{t}**"
|
|
40
|
+
elif r.italic:
|
|
41
|
+
t = f"*{t}*"
|
|
42
|
+
if r.strike:
|
|
43
|
+
t = f"~~{t}~~"
|
|
44
|
+
parts.append(t)
|
|
45
|
+
return "".join(parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Patterns for parsing Markdown back to runs
|
|
49
|
+
_PATTERNS = [
|
|
50
|
+
# Bold+italic
|
|
51
|
+
(re.compile(r"\*\*\*(.+?)\*\*\*"), {"bold": True, "italic": True}),
|
|
52
|
+
# Bold
|
|
53
|
+
(re.compile(r"\*\*(.+?)\*\*"), {"bold": True}),
|
|
54
|
+
# Italic
|
|
55
|
+
(re.compile(r"\*(.+?)\*"), {"italic": True}),
|
|
56
|
+
# Strikethrough
|
|
57
|
+
(re.compile(r"~~(.+?)~~"), {"strike": True}),
|
|
58
|
+
# Superscript
|
|
59
|
+
(re.compile(r"<sup>(.+?)</sup>"), {"superscript": True}),
|
|
60
|
+
# Subscript
|
|
61
|
+
(re.compile(r"<sub>(.+?)</sub>"), {"subscript": True}),
|
|
62
|
+
# Hyperlink
|
|
63
|
+
(re.compile(r"\[(.+?)\]\((.+?)\)"), {"url": True}),
|
|
64
|
+
# Citation: [@key]
|
|
65
|
+
(re.compile(r"\[@([^\]\s]+)\]"), {"cite_key": True}),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def markdown_to_runs(text: str) -> list[FormattedRun]:
|
|
70
|
+
"""Parse Markdown text into FormattedRun objects.
|
|
71
|
+
|
|
72
|
+
Handles bold, italic, superscript, subscript, strikethrough, and hyperlinks.
|
|
73
|
+
Unrecognized text becomes plain runs.
|
|
74
|
+
"""
|
|
75
|
+
if not text:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
# Use a token-based approach: find all formatting spans, sort by position
|
|
79
|
+
spans: list[tuple[int, int, FormattedRun]] = []
|
|
80
|
+
|
|
81
|
+
for pattern, props in _PATTERNS:
|
|
82
|
+
for m in pattern.finditer(text):
|
|
83
|
+
if "url" in props:
|
|
84
|
+
run = FormattedRun(text=m.group(1), url=m.group(2))
|
|
85
|
+
elif "cite_key" in props:
|
|
86
|
+
key = m.group(1)
|
|
87
|
+
run = FormattedRun(text=f"[@{key}]", cite_key=key)
|
|
88
|
+
else:
|
|
89
|
+
run = FormattedRun(text=m.group(1), **props)
|
|
90
|
+
spans.append((m.start(), m.end(), run))
|
|
91
|
+
|
|
92
|
+
if not spans:
|
|
93
|
+
return [FormattedRun(text=text)]
|
|
94
|
+
|
|
95
|
+
# Sort by start position, resolve overlaps (first match wins)
|
|
96
|
+
spans.sort(key=lambda s: s[0])
|
|
97
|
+
result: list[FormattedRun] = []
|
|
98
|
+
pos = 0
|
|
99
|
+
|
|
100
|
+
for start, end, run in spans:
|
|
101
|
+
if start < pos:
|
|
102
|
+
continue # overlapping span, skip
|
|
103
|
+
if start > pos:
|
|
104
|
+
plain = text[pos:start]
|
|
105
|
+
if plain:
|
|
106
|
+
result.append(FormattedRun(text=plain))
|
|
107
|
+
result.append(run)
|
|
108
|
+
pos = end
|
|
109
|
+
|
|
110
|
+
# Trailing plain text
|
|
111
|
+
if pos < len(text):
|
|
112
|
+
result.append(FormattedRun(text=text[pos:]))
|
|
113
|
+
|
|
114
|
+
return result
|