annotheca 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annotheca/__init__.py +3 -0
- annotheca/__main__.py +5 -0
- annotheca/api.py +194 -0
- annotheca/bibtex.py +193 -0
- annotheca/bundle.py +152 -0
- annotheca/cli.py +122 -0
- annotheca/library.py +409 -0
- annotheca/mcp_server.py +246 -0
- annotheca/models.py +23 -0
- annotheca/sources.py +190 -0
- annotheca/static/app.css +1123 -0
- annotheca/static/favicon.svg +8 -0
- annotheca/static/index.html +39 -0
- annotheca/static/js/api.js +58 -0
- annotheca/static/js/i18n.js +226 -0
- annotheca/static/js/main.js +563 -0
- annotheca/static/js/storage.js +30 -0
- annotheca/static/js/ui.js +163 -0
- annotheca/static/js/viewer.js +591 -0
- annotheca/static/vendor/pdfjs/LICENSE +177 -0
- annotheca/static/vendor/pdfjs/pdf.min.mjs +28 -0
- annotheca/static/vendor/pdfjs/pdf.worker.min.mjs +28 -0
- annotheca-0.1.0.dist-info/METADATA +203 -0
- annotheca-0.1.0.dist-info/RECORD +27 -0
- annotheca-0.1.0.dist-info/WHEEL +4 -0
- annotheca-0.1.0.dist-info/entry_points.txt +2 -0
- annotheca-0.1.0.dist-info/licenses/LICENSE +21 -0
annotheca/__init__.py
ADDED
annotheca/__main__.py
ADDED
annotheca/api.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""HTTP API and static web interface."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import pathlib
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib import parse
|
|
7
|
+
|
|
8
|
+
import fastapi
|
|
9
|
+
import httpx
|
|
10
|
+
import pydantic
|
|
11
|
+
from fastapi import responses
|
|
12
|
+
from fastapi import staticfiles
|
|
13
|
+
|
|
14
|
+
from annotheca import bundle
|
|
15
|
+
from annotheca import library as library_lib
|
|
16
|
+
from annotheca import models
|
|
17
|
+
from annotheca import sources
|
|
18
|
+
|
|
19
|
+
_STATIC_DIR = pathlib.Path(__file__).parent / 'static'
|
|
20
|
+
|
|
21
|
+
# Exception types mapped to an HTTP status and the error code the
|
|
22
|
+
# interface translates for the user.
|
|
23
|
+
_ERRORS = {
|
|
24
|
+
library_lib.NotFoundError: (404, 'not_found'),
|
|
25
|
+
library_lib.ConflictError: (409, 'name_taken'),
|
|
26
|
+
library_lib.InvalidPdfError: (422, 'not_pdf'),
|
|
27
|
+
bundle.InvalidBundleError: (422, 'not_bundle'),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NameBody(pydantic.BaseModel):
|
|
32
|
+
name: models.Name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AddPapersBody(pydantic.BaseModel):
|
|
36
|
+
"""Either pasted references or an already known paper to file."""
|
|
37
|
+
text: str | None = None
|
|
38
|
+
paper_id: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotesBody(pydantic.BaseModel):
|
|
42
|
+
notes: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AnnotationChanges(pydantic.BaseModel):
|
|
46
|
+
color: models.Color | None = None
|
|
47
|
+
geometry: list[Any] | None = None
|
|
48
|
+
body: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_app(library: library_lib.Library,
|
|
52
|
+
client: httpx.Client | None = None) -> fastapi.FastAPI:
|
|
53
|
+
"""Builds the web application.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
library: Where everything is stored.
|
|
57
|
+
client: HTTP client for the scholarly sources; a default one is
|
|
58
|
+
created when omitted.
|
|
59
|
+
"""
|
|
60
|
+
client = client or sources.make_client()
|
|
61
|
+
app = fastapi.FastAPI(title='Annotheca', docs_url=None, redoc_url=None)
|
|
62
|
+
api = fastapi.APIRouter(prefix='/api')
|
|
63
|
+
|
|
64
|
+
for error_type, (status, code) in _ERRORS.items():
|
|
65
|
+
app.add_exception_handler(error_type, _error_handler(status, code))
|
|
66
|
+
|
|
67
|
+
@app.exception_handler(sources.ResolveError)
|
|
68
|
+
def _resolve_error(request, error):
|
|
69
|
+
del request # Unused.
|
|
70
|
+
return responses.JSONResponse({'detail': str(error)}, status_code=422)
|
|
71
|
+
|
|
72
|
+
@api.get('/projects')
|
|
73
|
+
def list_projects():
|
|
74
|
+
return library.list_projects()
|
|
75
|
+
|
|
76
|
+
@api.post('/projects', status_code=201)
|
|
77
|
+
def create_project(body: NameBody):
|
|
78
|
+
return library.create_project(body.name)
|
|
79
|
+
|
|
80
|
+
@api.get('/projects/{project_id}')
|
|
81
|
+
def get_project(project_id: int):
|
|
82
|
+
return library.get_project(project_id)
|
|
83
|
+
|
|
84
|
+
@api.get('/projects/{project_id}/bibtex')
|
|
85
|
+
def export_bibtex(project_id: int):
|
|
86
|
+
name, content = library.export_bibtex(project_id)
|
|
87
|
+
return _download(content, f'{name}.bib', 'application/x-bibtex')
|
|
88
|
+
|
|
89
|
+
@api.get('/projects/{project_id}/bundle')
|
|
90
|
+
def export_bundle(project_id: int):
|
|
91
|
+
"""The project with its notes, annotations and PDFs, to share."""
|
|
92
|
+
name, content = bundle.export_project(library, project_id)
|
|
93
|
+
return _download(content, f'{name}.annotheca', 'application/zip')
|
|
94
|
+
|
|
95
|
+
@api.post('/projects/import', status_code=201)
|
|
96
|
+
async def import_bundle(request: fastapi.Request):
|
|
97
|
+
return bundle.import_project(library, await request.body())
|
|
98
|
+
|
|
99
|
+
@api.post('/projects/{project_id}/folders', status_code=201)
|
|
100
|
+
def create_folder(project_id: int, body: NameBody):
|
|
101
|
+
return library.create_folder(project_id, body.name)
|
|
102
|
+
|
|
103
|
+
@api.patch('/projects/{project_id}', status_code=204)
|
|
104
|
+
def rename_project(project_id: int, body: NameBody):
|
|
105
|
+
library.rename('project', project_id, body.name)
|
|
106
|
+
|
|
107
|
+
@api.delete('/projects/{project_id}', status_code=204)
|
|
108
|
+
def delete_project(project_id: int):
|
|
109
|
+
library.delete('project', project_id)
|
|
110
|
+
|
|
111
|
+
@api.patch('/folders/{folder_id}', status_code=204)
|
|
112
|
+
def rename_folder(folder_id: int, body: NameBody):
|
|
113
|
+
library.rename('folder', folder_id, body.name)
|
|
114
|
+
|
|
115
|
+
@api.delete('/folders/{folder_id}', status_code=204)
|
|
116
|
+
def delete_folder(folder_id: int):
|
|
117
|
+
library.delete('folder', folder_id)
|
|
118
|
+
|
|
119
|
+
@api.post('/folders/{folder_id}/papers')
|
|
120
|
+
def add_papers(folder_id: int, body: AddPapersBody):
|
|
121
|
+
"""Files pasted references, or a known paper, in a folder."""
|
|
122
|
+
if body.paper_id is not None:
|
|
123
|
+
paper_ids = [body.paper_id]
|
|
124
|
+
elif body.text and body.text.strip():
|
|
125
|
+
entries = sources.resolve(body.text, client)
|
|
126
|
+
paper_ids = list(dict.fromkeys(map(library.add_paper, entries)))
|
|
127
|
+
else:
|
|
128
|
+
raise sources.ResolveError('unresolvable')
|
|
129
|
+
added = library.file_papers(folder_id, paper_ids)
|
|
130
|
+
return {'paper_ids': paper_ids, 'added': added,
|
|
131
|
+
'already_there': len(paper_ids) - added}
|
|
132
|
+
|
|
133
|
+
@api.put('/placements/{placement_id}/notes', status_code=204)
|
|
134
|
+
def set_notes(placement_id: int, body: NotesBody):
|
|
135
|
+
library.set_notes(placement_id, body.notes)
|
|
136
|
+
|
|
137
|
+
@api.delete('/placements/{placement_id}', status_code=204)
|
|
138
|
+
def remove_placement(placement_id: int):
|
|
139
|
+
library.remove_placement(placement_id)
|
|
140
|
+
|
|
141
|
+
@api.get('/papers/{paper_id}/pdf')
|
|
142
|
+
def get_pdf(paper_id: int):
|
|
143
|
+
path = library.pdf_path(paper_id)
|
|
144
|
+
if not path.exists():
|
|
145
|
+
raise library_lib.NotFoundError
|
|
146
|
+
return responses.FileResponse(path, media_type='application/pdf')
|
|
147
|
+
|
|
148
|
+
@api.put('/papers/{paper_id}/pdf', status_code=204)
|
|
149
|
+
async def upload_pdf(paper_id: int, request: fastapi.Request):
|
|
150
|
+
library.save_pdf(paper_id, await request.body())
|
|
151
|
+
|
|
152
|
+
@api.post('/papers/{paper_id}/pdf/fetch')
|
|
153
|
+
def fetch_pdf(paper_id: int):
|
|
154
|
+
"""Looks for an open-access PDF; reports whether one was found."""
|
|
155
|
+
pdf = sources.find_pdf(library.get_paper(paper_id), client)
|
|
156
|
+
if pdf:
|
|
157
|
+
library.save_pdf(paper_id, pdf)
|
|
158
|
+
return {'found': pdf is not None}
|
|
159
|
+
|
|
160
|
+
@api.get('/placements/{placement_id}/annotations')
|
|
161
|
+
def list_annotations(placement_id: int):
|
|
162
|
+
return library.list_annotations(placement_id)
|
|
163
|
+
|
|
164
|
+
@api.post('/placements/{placement_id}/annotations', status_code=201)
|
|
165
|
+
def create_annotation(placement_id: int, body: models.Annotation):
|
|
166
|
+
return library.create_annotation(placement_id, body.model_dump())
|
|
167
|
+
|
|
168
|
+
@api.patch('/annotations/{annotation_id}')
|
|
169
|
+
def update_annotation(annotation_id: int, body: AnnotationChanges):
|
|
170
|
+
return library.update_annotation(
|
|
171
|
+
annotation_id, body.model_dump(exclude_none=True))
|
|
172
|
+
|
|
173
|
+
@api.delete('/annotations/{annotation_id}', status_code=204)
|
|
174
|
+
def delete_annotation(annotation_id: int):
|
|
175
|
+
library.delete_annotation(annotation_id)
|
|
176
|
+
|
|
177
|
+
app.include_router(api)
|
|
178
|
+
mimetypes.add_type('text/javascript', '.mjs')
|
|
179
|
+
app.mount('/', staticfiles.StaticFiles(directory=_STATIC_DIR, html=True))
|
|
180
|
+
return app
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _download(content: str | bytes, filename: str,
|
|
184
|
+
media_type: str) -> responses.Response:
|
|
185
|
+
disposition = f"attachment; filename*=UTF-8''{parse.quote(filename)}"
|
|
186
|
+
return responses.Response(content, media_type=media_type,
|
|
187
|
+
headers={'Content-Disposition': disposition})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _error_handler(status: int, code: str):
|
|
191
|
+
def handle(request: fastapi.Request, error: Exception):
|
|
192
|
+
del request, error # Unused.
|
|
193
|
+
return responses.JSONResponse({'detail': code}, status_code=status)
|
|
194
|
+
return handle
|
annotheca/bibtex.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""BibTeX entries: parsing, formatting, citation keys and identifiers."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_DOI_RE = re.compile(r'\b(10\.\d{4,9}/[^\s"<>{}]+)', re.IGNORECASE)
|
|
9
|
+
_ARXIV_NEW_RE = re.compile(r'\b(\d{4}\.\d{4,5})(?:v\d+)?\b')
|
|
10
|
+
_ARXIV_OLD_RE = re.compile(
|
|
11
|
+
r'arxiv(?:\.org/(?:abs|pdf)/|:)\s*([a-z-]+(?:\.[a-z]{2})?/\d{7})',
|
|
12
|
+
re.IGNORECASE)
|
|
13
|
+
_ARXIV_DOI_PREFIX = '10.48550/arxiv.'
|
|
14
|
+
_ENTRY_RE = re.compile(r'@\s*([a-zA-Z]+)\s*\{')
|
|
15
|
+
_FIELD_NAME_RE = re.compile(r'\s*([^\s=,{}]+)\s*=\s*')
|
|
16
|
+
_BARE_VALUE_RE = re.compile(r'[^\s,#}]*')
|
|
17
|
+
_CONCATENATION_RE = re.compile(r'\s*#\s*')
|
|
18
|
+
_SEPARATOR_RE = re.compile(r'\s*,')
|
|
19
|
+
_IGNORED_TYPES = frozenset(('comment', 'preamble', 'string'))
|
|
20
|
+
_LATEX_ACCENT_RE = re.compile(
|
|
21
|
+
r'\\([`\'^"~]|c(?=[\s{]))\s*\{?([a-zA-Z])\}?')
|
|
22
|
+
_COMBINING_ACCENTS = {
|
|
23
|
+
'`': '\u0300', "'": '\u0301', '^': '\u0302', '~': '\u0303',
|
|
24
|
+
'"': '\u0308', 'c': '\u0327'}
|
|
25
|
+
|
|
26
|
+
# Fields printed first, in this order; the others follow alphabetically.
|
|
27
|
+
_FIELD_ORDER = ('author', 'title', 'journal', 'booktitle', 'year')
|
|
28
|
+
# Fields naming where a paper was published, by preference.
|
|
29
|
+
_VENUE_FIELDS = (
|
|
30
|
+
'journal', 'booktitle', 'publisher', 'howpublished', 'archiveprefix')
|
|
31
|
+
_TITLE_STOPWORDS = frozenset(
|
|
32
|
+
('a', 'an', 'the', 'on', 'of', 'for', 'in', 'to', 'and', 'with'))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass
|
|
36
|
+
class Entry:
|
|
37
|
+
"""A single BibTeX entry.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
entry_type: Lowercase entry type, e.g. 'article'.
|
|
41
|
+
citekey: The citation key.
|
|
42
|
+
fields: Field values keyed by lowercase field name.
|
|
43
|
+
"""
|
|
44
|
+
entry_type: str
|
|
45
|
+
citekey: str
|
|
46
|
+
fields: dict[str, str]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def doi(self) -> str | None:
|
|
50
|
+
"""The normalized DOI, if the entry has one."""
|
|
51
|
+
return find_doi(self.fields.get('doi', ''))
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def arxiv_id(self) -> str | None:
|
|
55
|
+
"""The arXiv identifier, from the DOI, eprint, URL or journal."""
|
|
56
|
+
doi = self.doi
|
|
57
|
+
if doi and doi.startswith(_ARXIV_DOI_PREFIX):
|
|
58
|
+
return doi.removeprefix(_ARXIV_DOI_PREFIX)
|
|
59
|
+
sources = ('eprint', 'url', 'journal', 'note')
|
|
60
|
+
return find_arxiv_id(
|
|
61
|
+
' '.join(self.fields.get(name, '') for name in sources))
|
|
62
|
+
|
|
63
|
+
def summary(self) -> dict[str, Any]:
|
|
64
|
+
"""The citation key, title, authors, year and venue, as plain text."""
|
|
65
|
+
venue = next((self.fields[name] for name in _VENUE_FIELDS
|
|
66
|
+
if name in self.fields), '')
|
|
67
|
+
return {
|
|
68
|
+
'citekey': self.citekey,
|
|
69
|
+
'title': plain(self.fields.get('title', self.citekey)),
|
|
70
|
+
'authors': split_authors(self.fields.get('author', '')),
|
|
71
|
+
'year': self.fields.get('year', ''),
|
|
72
|
+
'venue': plain(venue),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def title_key(self) -> str | None:
|
|
77
|
+
"""The title as lowercase alphanumerics, used to spot duplicates."""
|
|
78
|
+
title = plain(self.fields.get('title', '')).lower()
|
|
79
|
+
return re.sub(r'[^a-z0-9]', '', title) or None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_doi(text: str) -> str | None:
|
|
83
|
+
"""Returns the first DOI found in `text`, lowercased, or None."""
|
|
84
|
+
match = _DOI_RE.search(text)
|
|
85
|
+
return match.group(1).rstrip('.,;)]').lower() if match else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_arxiv_id(text: str) -> str | None:
|
|
89
|
+
"""Returns the first arXiv identifier found in `text`, or None."""
|
|
90
|
+
match = _ARXIV_NEW_RE.search(text) or _ARXIV_OLD_RE.search(text)
|
|
91
|
+
return match.group(1) if match else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse(text: str) -> list[Entry]:
|
|
95
|
+
"""Parses every entry of a BibTeX string.
|
|
96
|
+
|
|
97
|
+
Macros are kept verbatim rather than expanded, e.g. `month = jun` gives
|
|
98
|
+
'jun'. Entries without a citation key are skipped.
|
|
99
|
+
"""
|
|
100
|
+
entries = []
|
|
101
|
+
position = 0
|
|
102
|
+
while match := _ENTRY_RE.search(text, position):
|
|
103
|
+
end = _closing(text, match.end(), '}')
|
|
104
|
+
citekey, comma, body = text[match.end():end].partition(',')
|
|
105
|
+
entry_type = match.group(1).lower()
|
|
106
|
+
if comma and citekey.strip() and entry_type not in _IGNORED_TYPES:
|
|
107
|
+
entries.append(Entry(entry_type, citekey.strip(), _fields(body)))
|
|
108
|
+
position = end + 1
|
|
109
|
+
return entries
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def format_entry(entry: Entry) -> str:
|
|
113
|
+
"""Formats an entry as BibTeX source."""
|
|
114
|
+
rank = {name: i for i, name in enumerate(_FIELD_ORDER)}
|
|
115
|
+
names = sorted(entry.fields, key=lambda n: (rank.get(n, len(rank)), n))
|
|
116
|
+
lines = [f'@{entry.entry_type}{{{entry.citekey},']
|
|
117
|
+
lines += [f' {name} = {{{entry.fields[name]}}},' for name in names]
|
|
118
|
+
lines.append('}')
|
|
119
|
+
return '\n'.join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def make_citekey(fields: dict[str, str]) -> str:
|
|
123
|
+
"""Builds a Google Scholar style key such as 'vaswani2017attention'."""
|
|
124
|
+
authors = split_authors(fields.get('author', ''))
|
|
125
|
+
last_name = ''
|
|
126
|
+
if authors:
|
|
127
|
+
first = authors[0]
|
|
128
|
+
last_name = first.split(',')[0] if ',' in first else first.split()[-1]
|
|
129
|
+
title_words = re.findall(
|
|
130
|
+
r'[a-z0-9]+', _ascii(plain(fields.get('title', ''))))
|
|
131
|
+
word = next((w for w in title_words if w not in _TITLE_STOPWORDS), '')
|
|
132
|
+
key = _ascii(last_name) + fields.get('year', '').strip() + word
|
|
133
|
+
return re.sub(r'[^a-z0-9]', '', key) or 'ref'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def split_authors(author_field: str) -> list[str]:
|
|
137
|
+
"""Splits a BibTeX author field into plain author names."""
|
|
138
|
+
names = re.split(r'\s+and\s+', plain(author_field))
|
|
139
|
+
return [name.strip() for name in names if name.strip()]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def plain(text: str) -> str:
|
|
143
|
+
"""Strips the LaTeX braces, accents and escapes common in fields."""
|
|
144
|
+
text = _LATEX_ACCENT_RE.sub(
|
|
145
|
+
lambda m: m.group(2) + _COMBINING_ACCENTS[m.group(1)], text)
|
|
146
|
+
text = unicodedata.normalize('NFC', re.sub(r'\\([&%$#_])', r'\1', text))
|
|
147
|
+
return ' '.join(text.replace('{', '').replace('}', '').split())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _fields(text: str) -> dict[str, str]:
|
|
151
|
+
"""Parses the `name = value` pairs of an entry body."""
|
|
152
|
+
fields = {}
|
|
153
|
+
position = 0
|
|
154
|
+
while match := _FIELD_NAME_RE.match(text, position):
|
|
155
|
+
parts = []
|
|
156
|
+
position = match.end()
|
|
157
|
+
while True:
|
|
158
|
+
if text.startswith(('{', '"'), position):
|
|
159
|
+
end = _closing(text, position + 1, '}' if text[position] == '{'
|
|
160
|
+
else '"')
|
|
161
|
+
parts.append(text[position + 1:end])
|
|
162
|
+
position = end + 1
|
|
163
|
+
else:
|
|
164
|
+
bare = _BARE_VALUE_RE.match(text, position)
|
|
165
|
+
parts.append(bare.group())
|
|
166
|
+
position = bare.end()
|
|
167
|
+
concatenation = _CONCATENATION_RE.match(text, position)
|
|
168
|
+
if not concatenation:
|
|
169
|
+
break
|
|
170
|
+
position = concatenation.end()
|
|
171
|
+
fields[match.group(1).lower()] = ' '.join(''.join(parts).split())
|
|
172
|
+
if separator := _SEPARATOR_RE.match(text, position):
|
|
173
|
+
position = separator.end()
|
|
174
|
+
return fields
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _closing(text: str, start: int, closer: str) -> int:
|
|
178
|
+
"""Index of the first `closer` outside braces from `start`, or the end."""
|
|
179
|
+
depth = 0
|
|
180
|
+
for index in range(start, len(text)):
|
|
181
|
+
char = text[index]
|
|
182
|
+
if char == closer and depth == 0:
|
|
183
|
+
return index
|
|
184
|
+
if char == '{':
|
|
185
|
+
depth += 1
|
|
186
|
+
elif char == '}':
|
|
187
|
+
depth -= 1
|
|
188
|
+
return len(text)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _ascii(text: str) -> str:
|
|
192
|
+
decomposed = unicodedata.normalize('NFKD', text)
|
|
193
|
+
return decomposed.encode('ascii', 'ignore').decode().lower()
|
annotheca/bundle.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Project bundles: a whole project in one file, to share it.
|
|
2
|
+
|
|
3
|
+
A bundle is a zip archive holding `project.json`, which describes the
|
|
4
|
+
folders, papers, notes and annotations, and the PDFs as `pdfs/<citekey>.pdf`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import itertools
|
|
9
|
+
import json
|
|
10
|
+
import zipfile
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import pydantic
|
|
14
|
+
|
|
15
|
+
from annotheca import bibtex
|
|
16
|
+
from annotheca import library as library_lib
|
|
17
|
+
from annotheca import models
|
|
18
|
+
|
|
19
|
+
_MANIFEST = 'project.json'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidBundleError(ValueError):
|
|
23
|
+
"""The file is not an Annotheca project bundle."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Paper(pydantic.BaseModel):
|
|
27
|
+
citekey: str
|
|
28
|
+
entry_type: str
|
|
29
|
+
fields: dict[str, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Placement(pydantic.BaseModel):
|
|
33
|
+
citekey: str
|
|
34
|
+
notes: str = ''
|
|
35
|
+
added_by: str = ''
|
|
36
|
+
annotations: list[models.Annotation] = []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Folder(pydantic.BaseModel):
|
|
40
|
+
name: models.Name
|
|
41
|
+
placements: list[_Placement]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Manifest(pydantic.BaseModel):
|
|
45
|
+
format: Literal['annotheca']
|
|
46
|
+
version: Literal[1]
|
|
47
|
+
name: models.Name
|
|
48
|
+
papers: list[_Paper]
|
|
49
|
+
folders: list[_Folder] = pydantic.Field(min_length=1)
|
|
50
|
+
|
|
51
|
+
@pydantic.model_validator(mode='after')
|
|
52
|
+
def _check_references(self) -> '_Manifest':
|
|
53
|
+
"""Rejects bundles that could only be imported halfway."""
|
|
54
|
+
citekeys = {paper.citekey for paper in self.papers}
|
|
55
|
+
names = [folder.name for folder in self.folders]
|
|
56
|
+
if len(set(names)) < len(names) or any(
|
|
57
|
+
placement.citekey not in citekeys
|
|
58
|
+
for folder in self.folders for placement in folder.placements):
|
|
59
|
+
raise ValueError('Inconsistent bundle.')
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def export_project(library: library_lib.Library,
|
|
64
|
+
project_id: int) -> tuple[str, bytes]:
|
|
65
|
+
"""Returns the name of a project and its bundle."""
|
|
66
|
+
project = library.get_project(project_id)
|
|
67
|
+
papers = {}
|
|
68
|
+
folders = []
|
|
69
|
+
for folder in project['folders']:
|
|
70
|
+
placements = []
|
|
71
|
+
for placement in folder['placements']:
|
|
72
|
+
paper_id = placement['paper_id']
|
|
73
|
+
if paper_id not in papers:
|
|
74
|
+
entry = library.get_paper(paper_id)
|
|
75
|
+
papers[paper_id] = _Paper(
|
|
76
|
+
citekey=entry.citekey, entry_type=entry.entry_type,
|
|
77
|
+
fields=entry.fields)
|
|
78
|
+
placements.append(_Placement(
|
|
79
|
+
citekey=papers[paper_id].citekey, notes=placement['notes'],
|
|
80
|
+
added_by=placement['added_by'],
|
|
81
|
+
annotations=library.list_annotations(placement['id'])))
|
|
82
|
+
folders.append(_Folder(name=folder['name'], placements=placements))
|
|
83
|
+
manifest = _Manifest(format='annotheca', version=1, name=project['name'],
|
|
84
|
+
papers=list(papers.values()), folders=folders)
|
|
85
|
+
|
|
86
|
+
buffer = io.BytesIO()
|
|
87
|
+
with zipfile.ZipFile(buffer, 'w') as archive:
|
|
88
|
+
archive.writestr(_MANIFEST, manifest.model_dump_json(indent=1),
|
|
89
|
+
compress_type=zipfile.ZIP_DEFLATED)
|
|
90
|
+
for paper_id, paper in papers.items():
|
|
91
|
+
if (path := library.pdf_path(paper_id)).exists():
|
|
92
|
+
archive.write(path, _pdf_name(paper.citekey))
|
|
93
|
+
return project['name'], buffer.getvalue()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def import_project(library: library_lib.Library,
|
|
97
|
+
data: bytes) -> dict[str, Any]:
|
|
98
|
+
"""Creates a project from a bundle and returns its id and name.
|
|
99
|
+
|
|
100
|
+
Papers already in the library are reused, keeping their PDF. The project
|
|
101
|
+
name gets a number appended if it is already taken.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
InvalidBundleError: `data` is not a valid bundle.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
108
|
+
manifest = _Manifest.model_validate(
|
|
109
|
+
json.loads(archive.read(_MANIFEST)))
|
|
110
|
+
except (zipfile.BadZipFile, KeyError, ValueError) as error:
|
|
111
|
+
raise InvalidBundleError from error
|
|
112
|
+
|
|
113
|
+
paper_ids = {}
|
|
114
|
+
for paper in manifest.papers:
|
|
115
|
+
paper_id = library.add_paper(
|
|
116
|
+
bibtex.Entry(paper.entry_type, paper.citekey, paper.fields))
|
|
117
|
+
paper_ids[paper.citekey] = paper_id
|
|
118
|
+
pdf_name = _pdf_name(paper.citekey)
|
|
119
|
+
if (pdf_name in archive.namelist()
|
|
120
|
+
and not library.pdf_path(paper_id).exists()):
|
|
121
|
+
library.save_pdf(paper_id, archive.read(pdf_name))
|
|
122
|
+
|
|
123
|
+
project = _create_project(library, manifest.name)
|
|
124
|
+
default_folder = library.get_project(project['id'])['folders'][0]
|
|
125
|
+
for index, folder in enumerate(manifest.folders):
|
|
126
|
+
if index == 0:
|
|
127
|
+
folder_id = default_folder['id']
|
|
128
|
+
library.rename('folder', folder_id, folder.name)
|
|
129
|
+
else:
|
|
130
|
+
folder_id = library.create_folder(project['id'], folder.name)['id']
|
|
131
|
+
for placement in folder.placements:
|
|
132
|
+
placement_id = library.file_paper(
|
|
133
|
+
folder_id, paper_ids[placement.citekey], placement.added_by)
|
|
134
|
+
library.set_notes(placement_id, placement.notes)
|
|
135
|
+
for annotation in placement.annotations:
|
|
136
|
+
library.create_annotation(placement_id, annotation.model_dump())
|
|
137
|
+
return project
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _create_project(library: library_lib.Library,
|
|
141
|
+
name: str) -> dict[str, Any]:
|
|
142
|
+
"""Creates a project named `name`, or `name (2)`, `name (3)`..."""
|
|
143
|
+
for number in itertools.count(1):
|
|
144
|
+
try:
|
|
145
|
+
return library.create_project(
|
|
146
|
+
name if number == 1 else f'{name} ({number})')
|
|
147
|
+
except library_lib.ConflictError:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _pdf_name(citekey: str) -> str:
|
|
152
|
+
return f'pdfs/{citekey}.pdf'
|
annotheca/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
`annotheca` opens the interface; `annotheca mcp` serves the tools for AI
|
|
4
|
+
assistants; `annotheca connect` makes those tools available to Claude.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import webbrowser
|
|
18
|
+
|
|
19
|
+
import uvicorn
|
|
20
|
+
|
|
21
|
+
from annotheca import __version__
|
|
22
|
+
from annotheca import api
|
|
23
|
+
from annotheca import library
|
|
24
|
+
from annotheca import mcp_server
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] | None = None) -> None:
|
|
28
|
+
"""Runs the requested command."""
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog='annotheca',
|
|
31
|
+
description='Manage the bibliography of your research projects.')
|
|
32
|
+
parser.add_argument('--data-dir', type=pathlib.Path,
|
|
33
|
+
help='where the library is stored (default: '
|
|
34
|
+
f'{_default_data_dir()})')
|
|
35
|
+
parser.add_argument('--port', type=int, default=8737,
|
|
36
|
+
help='local port of the interface (default: '
|
|
37
|
+
'%(default)s)')
|
|
38
|
+
parser.add_argument('--no-browser', action='store_true',
|
|
39
|
+
help='do not open the interface in a browser')
|
|
40
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
41
|
+
commands = parser.add_subparsers(dest='command', metavar='COMMAND')
|
|
42
|
+
commands.add_parser(
|
|
43
|
+
'mcp', help='serve the tools for AI assistants over MCP (stdio)')
|
|
44
|
+
commands.add_parser(
|
|
45
|
+
'connect', help='make the tools available to Claude Code and Claude '
|
|
46
|
+
'Desktop, and show how to add them to other assistants')
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
data_dir = args.data_dir or _default_data_dir()
|
|
50
|
+
if args.command == 'mcp':
|
|
51
|
+
mcp_server.create_server(library.Library(data_dir)).run()
|
|
52
|
+
elif args.command == 'connect':
|
|
53
|
+
_connect(args.data_dir)
|
|
54
|
+
else:
|
|
55
|
+
_serve(data_dir, args.port, open_browser=not args.no_browser)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serve(data_dir: pathlib.Path, port: int, open_browser: bool) -> None:
|
|
59
|
+
url = f'http://127.0.0.1:{port}'
|
|
60
|
+
if _port_in_use(port):
|
|
61
|
+
print(f'Port {port} is busy: opening the running instance.'
|
|
62
|
+
' Use --port if another program holds it.')
|
|
63
|
+
if open_browser:
|
|
64
|
+
webbrowser.open(url)
|
|
65
|
+
return
|
|
66
|
+
app = api.create_app(library.Library(data_dir))
|
|
67
|
+
print(f'Annotheca is running at {url} (library: {data_dir}).'
|
|
68
|
+
' Press Ctrl+C to stop.')
|
|
69
|
+
if open_browser:
|
|
70
|
+
threading.Timer(1.0, webbrowser.open, [url]).start()
|
|
71
|
+
uvicorn.run(app, host='127.0.0.1', port=port, log_level='warning')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _connect(data_dir: pathlib.Path | None) -> None:
|
|
75
|
+
"""Registers `annotheca mcp` with the Claude applications found."""
|
|
76
|
+
data_args = ['--data-dir', str(data_dir.resolve())] if data_dir else []
|
|
77
|
+
command = [sys.executable, '-m', 'annotheca', *data_args, 'mcp']
|
|
78
|
+
server = {'command': command[0], 'args': command[1:]}
|
|
79
|
+
|
|
80
|
+
if claude := shutil.which('claude'):
|
|
81
|
+
subprocess.run([claude, 'mcp', 'remove', '--scope', 'user',
|
|
82
|
+
'annotheca'], capture_output=True, check=False)
|
|
83
|
+
subprocess.run([claude, 'mcp', 'add', '--scope', 'user', 'annotheca',
|
|
84
|
+
'--', *command], capture_output=True, check=True)
|
|
85
|
+
print('Connected to Claude Code (all projects).')
|
|
86
|
+
|
|
87
|
+
config_path = _claude_desktop_config()
|
|
88
|
+
if config_path.parent.is_dir():
|
|
89
|
+
config = (json.loads(config_path.read_text())
|
|
90
|
+
if config_path.exists() else {})
|
|
91
|
+
config.setdefault('mcpServers', {})['annotheca'] = server
|
|
92
|
+
config_path.write_text(json.dumps(config, indent=2) + '\n')
|
|
93
|
+
print('Connected to Claude Desktop; restart it to load the tools.')
|
|
94
|
+
|
|
95
|
+
print('\nFor other assistants (Cursor, Codex, Gemini CLI...), add this '
|
|
96
|
+
'MCP server to their configuration:\n'
|
|
97
|
+
+ json.dumps({'mcpServers': {'annotheca': server}}, indent=2))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _claude_desktop_config() -> pathlib.Path:
|
|
101
|
+
if platform.system() == 'Darwin':
|
|
102
|
+
root = pathlib.Path.home() / 'Library' / 'Application Support'
|
|
103
|
+
elif platform.system() == 'Windows':
|
|
104
|
+
root = pathlib.Path(os.environ['APPDATA'])
|
|
105
|
+
else:
|
|
106
|
+
root = pathlib.Path(os.environ.get('XDG_CONFIG_HOME')
|
|
107
|
+
or pathlib.Path.home() / '.config')
|
|
108
|
+
return root / 'Claude' / 'claude_desktop_config.json'
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _default_data_dir() -> pathlib.Path:
|
|
112
|
+
"""$ANNOTHECA_HOME, else the XDG data directory."""
|
|
113
|
+
if home := os.environ.get('ANNOTHECA_HOME'):
|
|
114
|
+
return pathlib.Path(home)
|
|
115
|
+
xdg = os.environ.get('XDG_DATA_HOME')
|
|
116
|
+
return pathlib.Path(xdg or pathlib.Path.home() / '.local' / 'share',
|
|
117
|
+
'annotheca')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _port_in_use(port: int) -> bool:
|
|
121
|
+
with socket.socket() as probe:
|
|
122
|
+
return probe.connect_ex(('127.0.0.1', port)) == 0
|