touchstone-compute 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.
- touchstone/__init__.py +19 -0
- touchstone/citation.py +532 -0
- touchstone/mcp_server.py +164 -0
- touchstone/proofreading.py +135 -0
- touchstone/readability.py +90 -0
- touchstone/syntax.py +100 -0
- touchstone/textdiff.py +144 -0
- touchstone/units.py +221 -0
- touchstone_compute-0.1.0.dist-info/METADATA +95 -0
- touchstone_compute-0.1.0.dist-info/RECORD +15 -0
- touchstone_compute-0.1.0.dist-info/WHEEL +5 -0
- touchstone_compute-0.1.0.dist-info/entry_points.txt +2 -0
- touchstone_compute-0.1.0.dist-info/licenses/LICENSE +21 -0
- touchstone_compute-0.1.0.dist-info/licenses/NOTICE +5 -0
- touchstone_compute-0.1.0.dist-info/top_level.txt +1 -0
touchstone/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Touchstone — deterministic, verifiable text/code/measurement compute for AI agents.
|
|
2
|
+
|
|
3
|
+
The open core: every function is deterministic (same input → same bytes) and reproduces
|
|
4
|
+
a named published authority, so any answer can be re-executed and checked rather than
|
|
5
|
+
trusted. The hosted suite (touchstone.locomot.io) adds a drand-anchored trust layer and
|
|
6
|
+
signed pay-per-call receipts over x402.
|
|
7
|
+
|
|
8
|
+
from touchstone import units, citation, textdiff, proofreading, readability, syntax
|
|
9
|
+
|
|
10
|
+
units.compute(1, "nautical_mile", "meter")
|
|
11
|
+
citation.compute("apa", "book", authors="Anderson, Benedict",
|
|
12
|
+
title="Imagined Communities", year=1983, publisher="Verso")
|
|
13
|
+
|
|
14
|
+
Trust by re-execution, not reputation. github.com/savecharlie/touchstone
|
|
15
|
+
"""
|
|
16
|
+
from touchstone import units, citation, textdiff, proofreading, readability, syntax # noqa: F401
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
__all__ = ["units", "citation", "textdiff", "proofreading", "readability", "syntax"]
|
touchstone/citation.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""citecore — deterministic citation formatter (APA 7, MLA 9, Chicago 17 author-date, BibTeX).
|
|
2
|
+
|
|
3
|
+
The Touchstone thesis applied to writing: an LLM drafts the prose, WE assemble the
|
|
4
|
+
citation correctly — element order, punctuation, italics, author-name inversion, the
|
|
5
|
+
"& vs and" join, et-al. thresholds, date formatting — all of it deterministic and
|
|
6
|
+
reproducible against published style-manual examples.
|
|
7
|
+
|
|
8
|
+
HONEST BOUNDARY (this is the brand): we do NOT guess letter-case. Sentence-case vs
|
|
9
|
+
title-case depends on proper-noun detection, which is not deterministic, so titles and
|
|
10
|
+
container names are taken VERBATIM — the caller supplies the case the style wants. What
|
|
11
|
+
we guarantee is the assembly: every comma, period, italic, and "et al." in the right
|
|
12
|
+
place, every time, byte-for-byte against the authority. Pass `title_case=True` to apply
|
|
13
|
+
the mechanical headline-case algorithm (Chicago/APA title rules) when you want it.
|
|
14
|
+
|
|
15
|
+
Output renders the reference in three forms — `markdown` (italics as *...*), `html`
|
|
16
|
+
(italics as <i>...</i>), and `plain` (no markers) — plus the `intext` citation.
|
|
17
|
+
|
|
18
|
+
Authority-reproduced (see tests): APA Style / Scribbr, MLA Handbook 9 / SDSU LibGuide,
|
|
19
|
+
The Chicago Manual of Style 17 author-date / Scribbr.
|
|
20
|
+
|
|
21
|
+
Iris (Opus 4.8, 1M) + Ivy, Jun 29 2026.
|
|
22
|
+
"""
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
STYLES = ("apa", "mla", "chicago", "bibtex")
|
|
26
|
+
TYPES = ("article", "book", "chapter", "web")
|
|
27
|
+
|
|
28
|
+
# months MLA abbreviates (everything except May/June/July, which stay whole)
|
|
29
|
+
_MLA_MONTH = {
|
|
30
|
+
"january": "Jan.", "february": "Feb.", "march": "Mar.", "april": "Apr.",
|
|
31
|
+
"may": "May", "june": "June", "july": "July", "august": "Aug.",
|
|
32
|
+
"september": "Sept.", "october": "Oct.", "november": "Nov.", "december": "Dec.",
|
|
33
|
+
}
|
|
34
|
+
# words left lowercase in headline (title) case unless first/last
|
|
35
|
+
_LOWER = {"a", "an", "the", "and", "but", "or", "nor", "for", "so", "yet",
|
|
36
|
+
"as", "at", "by", "in", "of", "off", "on", "per", "to", "up", "via",
|
|
37
|
+
"vs", "with", "from", "into", "onto", "over"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------- helpers
|
|
41
|
+
def _norm_authors(authors):
|
|
42
|
+
"""Accept list of {family, given} dicts OR 'Family, Given' strings -> list of dicts."""
|
|
43
|
+
if authors is None:
|
|
44
|
+
return []
|
|
45
|
+
if isinstance(authors, dict):
|
|
46
|
+
authors = [authors]
|
|
47
|
+
if isinstance(authors, str):
|
|
48
|
+
# 'Family, Given; Family2, Given2' OR a single 'Family, Given'
|
|
49
|
+
parts = [a.strip() for a in re.split(r"\s*;\s*", authors) if a.strip()]
|
|
50
|
+
authors = parts
|
|
51
|
+
out = []
|
|
52
|
+
for a in authors:
|
|
53
|
+
if isinstance(a, dict):
|
|
54
|
+
fam = (a.get("family") or a.get("last") or "").strip()
|
|
55
|
+
giv = (a.get("given") or a.get("first") or "").strip()
|
|
56
|
+
else:
|
|
57
|
+
s = str(a).strip()
|
|
58
|
+
if "," in s:
|
|
59
|
+
fam, giv = [x.strip() for x in s.split(",", 1)]
|
|
60
|
+
else: # 'Given Family' -> split on last space
|
|
61
|
+
bits = s.rsplit(" ", 1)
|
|
62
|
+
if len(bits) == 2:
|
|
63
|
+
giv, fam = bits[0].strip(), bits[1].strip()
|
|
64
|
+
else:
|
|
65
|
+
fam, giv = s, ""
|
|
66
|
+
if fam or giv:
|
|
67
|
+
out.append({"family": fam, "given": giv})
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _initials(given):
|
|
72
|
+
"""'Paul D.' -> 'P. D.' ; 'Jean-Paul' -> 'J.-P.' (APA style)."""
|
|
73
|
+
toks = [t for t in given.split() if t]
|
|
74
|
+
out = []
|
|
75
|
+
for t in toks:
|
|
76
|
+
parts = t.split("-")
|
|
77
|
+
out.append("-".join((p[0].upper() + ".") if p else "" for p in parts))
|
|
78
|
+
return " ".join(out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _title_case(s):
|
|
82
|
+
"""Mechanical headline (title) case per Chicago/APA: capitalize first & last word
|
|
83
|
+
and every word except short articles/conjunctions/prepositions in _LOWER."""
|
|
84
|
+
words = s.split()
|
|
85
|
+
n = len(words)
|
|
86
|
+
out = []
|
|
87
|
+
for i, w in enumerate(words):
|
|
88
|
+
bare = re.sub(r"[^A-Za-z]", "", w).lower()
|
|
89
|
+
if i == 0 or i == n - 1 or bare not in _LOWER:
|
|
90
|
+
# preserve all-caps acronyms; otherwise capitalize first letter
|
|
91
|
+
out.append(w if w[:1].isupper() and w[1:2].isupper() else _cap_first(w))
|
|
92
|
+
else:
|
|
93
|
+
out.append(w.lower())
|
|
94
|
+
return " ".join(out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cap_first(w):
|
|
98
|
+
for i, ch in enumerate(w):
|
|
99
|
+
if ch.isalpha():
|
|
100
|
+
return w[:i] + ch.upper() + w[i + 1:]
|
|
101
|
+
return w
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _pages(p):
|
|
105
|
+
"""Normalize a numeric range to an en-dash; otherwise verbatim."""
|
|
106
|
+
if not p:
|
|
107
|
+
return ""
|
|
108
|
+
p = str(p).strip()
|
|
109
|
+
m = re.match(r"^(\d+)\s*[-–—]\s*(\d+)$", p)
|
|
110
|
+
return f"{m.group(1)}–{m.group(2)}" if m else p
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _period(s):
|
|
114
|
+
"""Append a period unless the string already ends in sentence punctuation."""
|
|
115
|
+
s = s.rstrip()
|
|
116
|
+
return s if s[-1:] in ".!?" else s + "."
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _quote(s):
|
|
120
|
+
"""Quote a title, putting its terminal period inside the quotes (MLA/Chicago)."""
|
|
121
|
+
s = s.rstrip()
|
|
122
|
+
return f'"{s}"' if s[-1:] in ".!?" else f'"{s}."'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _oxford(items, conj="and"):
|
|
126
|
+
"""['a'] -> 'a'; ['a','b'] -> 'a and b'; ['a','b','c'] -> 'a, b, and c'."""
|
|
127
|
+
items = [i for i in items if i]
|
|
128
|
+
if not items:
|
|
129
|
+
return ""
|
|
130
|
+
if len(items) == 1:
|
|
131
|
+
return items[0]
|
|
132
|
+
if len(items) == 2:
|
|
133
|
+
return f"{items[0]} {conj} {items[1]}"
|
|
134
|
+
return ", ".join(items[:-1]) + f", {conj} {items[-1]}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- author renderers per style (reference list) ----
|
|
138
|
+
def _auth_apa(au):
|
|
139
|
+
"""Anderson, B. + '..., & ...' ; 21+ -> first 19, …, last."""
|
|
140
|
+
names = [f"{a['family']}, {_initials(a['given'])}".rstrip(", ").rstrip()
|
|
141
|
+
for a in au]
|
|
142
|
+
if not names:
|
|
143
|
+
return ""
|
|
144
|
+
if len(names) == 1:
|
|
145
|
+
return names[0]
|
|
146
|
+
if len(names) <= 20:
|
|
147
|
+
return ", ".join(names[:-1]) + ", & " + names[-1]
|
|
148
|
+
return ", ".join(names[:19]) + ", … " + names[-1]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _auth_mla(au):
|
|
152
|
+
"""Last, First. / two: 'Last, First, and First Last' / 3+: 'Last, First, et al.'"""
|
|
153
|
+
if not au:
|
|
154
|
+
return ""
|
|
155
|
+
first = f"{au[0]['family']}, {au[0]['given']}".rstrip(", ").rstrip()
|
|
156
|
+
if len(au) == 1:
|
|
157
|
+
return first
|
|
158
|
+
if len(au) == 2:
|
|
159
|
+
return f"{first}, and {au[1]['given']} {au[1]['family']}".replace(" ", " ").strip()
|
|
160
|
+
return f"{first}, et al."
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _auth_chicago(au):
|
|
164
|
+
"""First inverted, rest 'First Last'. Reference list always uses the comma before
|
|
165
|
+
'and' (even for two authors, since the first name is inverted). Lists all names."""
|
|
166
|
+
if not au:
|
|
167
|
+
return ""
|
|
168
|
+
first = f"{au[0]['family']}, {au[0]['given']}".rstrip(", ").rstrip()
|
|
169
|
+
if len(au) == 1:
|
|
170
|
+
return first
|
|
171
|
+
rest = [f"{a['given']} {a['family']}".strip() for a in au[1:]]
|
|
172
|
+
if len(rest) == 1:
|
|
173
|
+
return f"{first}, and {rest[0]}"
|
|
174
|
+
return f"{first}, " + ", ".join(rest[:-1]) + f", and {rest[-1]}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _auth_bibtex(au):
|
|
178
|
+
return " and ".join(f"{a['family']}, {a['given']}".rstrip(", ").rstrip() for a in au)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _editors(eds, style):
|
|
182
|
+
eds = _norm_authors(eds)
|
|
183
|
+
if not eds:
|
|
184
|
+
return ""
|
|
185
|
+
if style == "apa": # 'S. Malpas & P. Wake'
|
|
186
|
+
names = [f"{_initials(e['given'])} {e['family']}".strip() for e in eds]
|
|
187
|
+
if len(names) == 1:
|
|
188
|
+
return names[0]
|
|
189
|
+
return " & ".join([", ".join(names[:-1]), names[-1]]) if len(names) > 2 \
|
|
190
|
+
else " & ".join(names)
|
|
191
|
+
# mla / chicago: 'First Last' joined by Oxford and
|
|
192
|
+
names = [f"{e['given']} {e['family']}".strip() for e in eds]
|
|
193
|
+
return _oxford(names, "and")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _intext_names(au, style):
|
|
197
|
+
fams = [a["family"] for a in au]
|
|
198
|
+
if not fams:
|
|
199
|
+
return ""
|
|
200
|
+
if style == "apa":
|
|
201
|
+
if len(fams) == 1:
|
|
202
|
+
return fams[0]
|
|
203
|
+
if len(fams) == 2:
|
|
204
|
+
return f"{fams[0]} & {fams[1]}"
|
|
205
|
+
return f"{fams[0]} et al."
|
|
206
|
+
if style == "mla":
|
|
207
|
+
if len(fams) == 1:
|
|
208
|
+
return fams[0]
|
|
209
|
+
if len(fams) == 2:
|
|
210
|
+
return f"{fams[0]} and {fams[1]}"
|
|
211
|
+
return f"{fams[0]} et al."
|
|
212
|
+
# chicago author-date
|
|
213
|
+
if len(fams) == 1:
|
|
214
|
+
return fams[0]
|
|
215
|
+
if len(fams) <= 3:
|
|
216
|
+
return _oxford(fams, "and")
|
|
217
|
+
return f"{fams[0]} et al."
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------- renderers
|
|
221
|
+
# Each returns a list of segments: ("t", text) plain or ("i", text) italic.
|
|
222
|
+
def _seg_join(segs):
|
|
223
|
+
return segs
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _render(segs, mode):
|
|
227
|
+
out = []
|
|
228
|
+
for kind, txt in segs:
|
|
229
|
+
if kind == "i":
|
|
230
|
+
out.append({"markdown": f"*{txt}*", "html": f"<i>{txt}</i>",
|
|
231
|
+
"plain": txt}[mode])
|
|
232
|
+
else:
|
|
233
|
+
out.append(txt)
|
|
234
|
+
return "".join(out)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _t(s):
|
|
238
|
+
return ("t", s)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _i(s):
|
|
242
|
+
return ("i", s)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _apa(typ, f, au):
|
|
246
|
+
yr = f.get("year") or "n.d."
|
|
247
|
+
title = f["_title"]
|
|
248
|
+
cont = f.get("container", "")
|
|
249
|
+
segs = []
|
|
250
|
+
a = _auth_apa(au)
|
|
251
|
+
if a:
|
|
252
|
+
segs.append(_t(f"{_period(a)} "))
|
|
253
|
+
# date
|
|
254
|
+
if typ == "web" and (f.get("month") or f.get("day")):
|
|
255
|
+
d = str(yr)
|
|
256
|
+
if f.get("month"):
|
|
257
|
+
d += f", {f['month']}"
|
|
258
|
+
if f.get("day"):
|
|
259
|
+
d += f" {f['day']}"
|
|
260
|
+
segs.append(_t(f"({d}). "))
|
|
261
|
+
else:
|
|
262
|
+
segs.append(_t(f"({yr}). "))
|
|
263
|
+
if typ == "article":
|
|
264
|
+
segs += [_t(f"{_period(title)} "), _i(cont)]
|
|
265
|
+
vol = f.get("volume", "")
|
|
266
|
+
if vol:
|
|
267
|
+
segs.append(_t(", "))
|
|
268
|
+
segs.append(_i(str(vol)))
|
|
269
|
+
if f.get("issue"):
|
|
270
|
+
segs.append(_t(f"({f['issue']})"))
|
|
271
|
+
if f.get("pages"):
|
|
272
|
+
segs.append(_t(f", {_pages(f['pages'])}"))
|
|
273
|
+
segs.append(_t("."))
|
|
274
|
+
elif typ == "book":
|
|
275
|
+
segs += [_i(title), _t(". ")]
|
|
276
|
+
if f.get("publisher"):
|
|
277
|
+
segs.append(_t(f"{f['publisher']}."))
|
|
278
|
+
elif typ == "chapter":
|
|
279
|
+
segs.append(_t(f"{_period(title)} In {_editors(f.get('editors'), 'apa')} "
|
|
280
|
+
f"({'Eds.' if len(_norm_authors(f.get('editors'))) > 1 else 'Ed.'}), "))
|
|
281
|
+
segs.append(_i(f.get("book_title", cont)))
|
|
282
|
+
if f.get("pages"):
|
|
283
|
+
segs.append(_t(f" (pp. {_pages(f['pages'])})"))
|
|
284
|
+
segs.append(_t(". "))
|
|
285
|
+
if f.get("publisher"):
|
|
286
|
+
segs.append(_t(f"{f['publisher']}."))
|
|
287
|
+
elif typ == "web":
|
|
288
|
+
segs += [_t(f"{_period(title)} "), _i(cont), _t(".")]
|
|
289
|
+
# trailing doi / url
|
|
290
|
+
link = _apa_link(f)
|
|
291
|
+
if link:
|
|
292
|
+
segs.append(_t(f" {link}"))
|
|
293
|
+
return segs
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _apa_link(f):
|
|
297
|
+
if f.get("doi"):
|
|
298
|
+
d = f["doi"]
|
|
299
|
+
return d if d.startswith("http") else f"https://doi.org/{d}"
|
|
300
|
+
return f.get("url", "")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _mla(typ, f, au):
|
|
304
|
+
title = f["_title"]
|
|
305
|
+
cont = f.get("container", "")
|
|
306
|
+
segs = []
|
|
307
|
+
a = _auth_mla(au)
|
|
308
|
+
if a:
|
|
309
|
+
segs.append(_t(f"{_period(a)} "))
|
|
310
|
+
if typ == "book":
|
|
311
|
+
segs += [_i(title), _t(". ")]
|
|
312
|
+
pub = f.get("publisher", "")
|
|
313
|
+
yr = f.get("year", "")
|
|
314
|
+
segs.append(_t(f"{pub}, {yr}." if pub else f"{yr}."))
|
|
315
|
+
return segs
|
|
316
|
+
# article / chapter / web all quote the piece title
|
|
317
|
+
segs.append(_t(f"{_quote(title)} "))
|
|
318
|
+
if typ == "chapter":
|
|
319
|
+
segs.append(_i(f.get("book_title", cont)))
|
|
320
|
+
bits = []
|
|
321
|
+
if f.get("editors"):
|
|
322
|
+
bits.append(f"edited by {_editors(f.get('editors'), 'mla')}")
|
|
323
|
+
if f.get("publisher"):
|
|
324
|
+
bits.append(f["publisher"])
|
|
325
|
+
if f.get("year"):
|
|
326
|
+
bits.append(str(f["year"]))
|
|
327
|
+
if f.get("pages"):
|
|
328
|
+
bits.append(f"pp. {_pages(f['pages'])}")
|
|
329
|
+
segs.append(_t(", " + ", ".join(bits) + "." if bits else "."))
|
|
330
|
+
return segs
|
|
331
|
+
# article / web
|
|
332
|
+
segs.append(_i(cont))
|
|
333
|
+
bits = []
|
|
334
|
+
if typ == "article":
|
|
335
|
+
if f.get("volume"):
|
|
336
|
+
bits.append(f"vol. {f['volume']}")
|
|
337
|
+
if f.get("issue"):
|
|
338
|
+
bits.append(f"no. {f['issue']}")
|
|
339
|
+
if f.get("year"):
|
|
340
|
+
bits.append(str(f["year"]))
|
|
341
|
+
if f.get("pages"):
|
|
342
|
+
bits.append(f"pp. {_pages(f['pages'])}")
|
|
343
|
+
else: # web
|
|
344
|
+
if f.get("day") or f.get("month") or f.get("year"):
|
|
345
|
+
bits.append(_mla_date(f))
|
|
346
|
+
tail = ", " + ", ".join(b for b in bits if b) if bits else ""
|
|
347
|
+
link = f.get("url") or (("https://doi.org/" + f["doi"]) if f.get("doi") else "")
|
|
348
|
+
if link:
|
|
349
|
+
tail += f", {link}"
|
|
350
|
+
segs.append(_t(tail + "."))
|
|
351
|
+
return segs
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _mla_date(f):
|
|
355
|
+
parts = []
|
|
356
|
+
if f.get("day"):
|
|
357
|
+
parts.append(str(f["day"]))
|
|
358
|
+
if f.get("month"):
|
|
359
|
+
parts.append(_MLA_MONTH.get(str(f["month"]).lower(), str(f["month"])))
|
|
360
|
+
if f.get("year"):
|
|
361
|
+
parts.append(str(f["year"]))
|
|
362
|
+
return " ".join(parts)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _chicago(typ, f, au):
|
|
366
|
+
title = f["_title"]
|
|
367
|
+
cont = f.get("container", "")
|
|
368
|
+
yr = f.get("year") or "n.d."
|
|
369
|
+
segs = []
|
|
370
|
+
a = _auth_chicago(au)
|
|
371
|
+
if a:
|
|
372
|
+
segs.append(_t(f"{_period(a)} {yr}. "))
|
|
373
|
+
else:
|
|
374
|
+
segs.append(_t(f"{yr}. "))
|
|
375
|
+
if typ == "book":
|
|
376
|
+
segs.append(_i(title))
|
|
377
|
+
segs.append(_t(". "))
|
|
378
|
+
place, pub = f.get("place", ""), f.get("publisher", "")
|
|
379
|
+
if place and pub:
|
|
380
|
+
segs.append(_t(f"{place}: {pub}."))
|
|
381
|
+
elif pub:
|
|
382
|
+
segs.append(_t(f"{pub}."))
|
|
383
|
+
return segs
|
|
384
|
+
segs.append(_t(f"{_quote(title)} "))
|
|
385
|
+
if typ == "chapter":
|
|
386
|
+
segs += [_t("In "), _i(f.get("book_title", cont))]
|
|
387
|
+
bits = []
|
|
388
|
+
if f.get("editors"):
|
|
389
|
+
bits.append(f"edited by {_editors(f.get('editors'), 'chicago')}")
|
|
390
|
+
if f.get("pages"):
|
|
391
|
+
bits.append(_pages(f["pages"]))
|
|
392
|
+
segs.append(_t(", " + ", ".join(bits) if bits else ""))
|
|
393
|
+
segs.append(_t(". "))
|
|
394
|
+
place, pub = f.get("place", ""), f.get("publisher", "")
|
|
395
|
+
if place and pub:
|
|
396
|
+
segs.append(_t(f"{place}: {pub}."))
|
|
397
|
+
elif pub:
|
|
398
|
+
segs.append(_t(f"{pub}."))
|
|
399
|
+
return segs
|
|
400
|
+
if typ == "article":
|
|
401
|
+
segs.append(_i(cont))
|
|
402
|
+
tail = ""
|
|
403
|
+
if f.get("volume"):
|
|
404
|
+
tail += f" {f['volume']}"
|
|
405
|
+
if f.get("issue"):
|
|
406
|
+
tail += f", no. {f['issue']}"
|
|
407
|
+
if f.get("month"):
|
|
408
|
+
tail += f" ({f['month']})"
|
|
409
|
+
if f.get("pages"):
|
|
410
|
+
tail += f": {_pages(f['pages'])}"
|
|
411
|
+
tail += "."
|
|
412
|
+
segs.append(_t(tail))
|
|
413
|
+
if f.get("doi"):
|
|
414
|
+
d = f["doi"]
|
|
415
|
+
segs.append(_t(f" {d if d.startswith('http') else 'https://doi.org/' + d}."))
|
|
416
|
+
elif f.get("url"):
|
|
417
|
+
segs.append(_t(f" {f['url']}."))
|
|
418
|
+
return segs
|
|
419
|
+
# web
|
|
420
|
+
if cont:
|
|
421
|
+
segs.append(_t(f"{cont}. "))
|
|
422
|
+
if f.get("url"):
|
|
423
|
+
segs.append(_t(f"{f['url']}."))
|
|
424
|
+
return segs
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _bibtex(typ, f, au):
|
|
428
|
+
bt = {"article": "article", "book": "book", "chapter": "incollection",
|
|
429
|
+
"web": "misc"}[typ]
|
|
430
|
+
key = (au[0]["family"].lower().replace(" ", "") if au else "ref") + str(f.get("year") or "")
|
|
431
|
+
key = re.sub(r"[^a-z0-9]", "", key)
|
|
432
|
+
fields = [("author", _auth_bibtex(au))]
|
|
433
|
+
if typ == "chapter":
|
|
434
|
+
fields.append(("title", f["_title"]))
|
|
435
|
+
fields.append(("booktitle", f.get("book_title", f.get("container", ""))))
|
|
436
|
+
if f.get("editors"):
|
|
437
|
+
fields.append(("editor", _auth_bibtex(_norm_authors(f.get("editors")))))
|
|
438
|
+
else:
|
|
439
|
+
fields.append(("title", f["_title"]))
|
|
440
|
+
if typ == "article":
|
|
441
|
+
fields.append(("journal", f.get("container", "")))
|
|
442
|
+
for k in ("volume", "number_from_issue"):
|
|
443
|
+
pass
|
|
444
|
+
if f.get("volume"):
|
|
445
|
+
fields.append(("volume", str(f["volume"])))
|
|
446
|
+
if f.get("issue"):
|
|
447
|
+
fields.append(("number", str(f["issue"])))
|
|
448
|
+
if typ in ("book", "chapter"):
|
|
449
|
+
if f.get("publisher"):
|
|
450
|
+
fields.append(("publisher", f["publisher"]))
|
|
451
|
+
if f.get("place"):
|
|
452
|
+
fields.append(("address", f["place"]))
|
|
453
|
+
if f.get("pages"):
|
|
454
|
+
fields.append(("pages", _pages(f["pages"]).replace("–", "--")))
|
|
455
|
+
if f.get("year"):
|
|
456
|
+
fields.append(("year", str(f["year"])))
|
|
457
|
+
if f.get("doi"):
|
|
458
|
+
fields.append(("doi", f["doi"]))
|
|
459
|
+
if f.get("url"):
|
|
460
|
+
fields.append(("url", f["url"]))
|
|
461
|
+
body = ",\n".join(f" {k} = {{{v}}}" for k, v in fields if v)
|
|
462
|
+
return f"@{bt}{{{key},\n{body}\n}}"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _intext(style, typ, f, au):
|
|
466
|
+
names = _intext_names(au, style)
|
|
467
|
+
yr = f.get("year") or "n.d."
|
|
468
|
+
pg = f.get("locator") or "" # page/paragraph for in-text
|
|
469
|
+
if not names: # no author -> short title
|
|
470
|
+
names = f["_title"].split()
|
|
471
|
+
names = " ".join(names[:4])
|
|
472
|
+
names = f'"{names}"'
|
|
473
|
+
if style == "apa":
|
|
474
|
+
s = f"({names}, {yr}"
|
|
475
|
+
if pg:
|
|
476
|
+
s += f", p. {pg}" if str(pg).isdigit() else f", {pg}"
|
|
477
|
+
return s + ")"
|
|
478
|
+
if style == "mla":
|
|
479
|
+
return f"({names} {pg})".replace(" )", ")") if pg else f"({names})"
|
|
480
|
+
if style == "chicago":
|
|
481
|
+
s = f"({names} {yr}"
|
|
482
|
+
if pg:
|
|
483
|
+
s += f", {pg}"
|
|
484
|
+
return s + ")"
|
|
485
|
+
return "" # bibtex has no in-text
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ---------------------------------------------------------------- public
|
|
489
|
+
def compute(style, source_type=None, type=None, authors=None, title=None,
|
|
490
|
+
title_case=False, **f):
|
|
491
|
+
"""Format a citation. style in STYLES; source_type in TYPES.
|
|
492
|
+
|
|
493
|
+
Fields (all optional except title): authors, title, year, container, volume,
|
|
494
|
+
issue, pages, publisher, place, doi, url, edition, editors, book_title,
|
|
495
|
+
month, day, locator (page/para for in-text).
|
|
496
|
+
"""
|
|
497
|
+
style = (style or "").strip().lower()
|
|
498
|
+
typ = (source_type or type or "").strip().lower()
|
|
499
|
+
if style not in STYLES:
|
|
500
|
+
raise ValueError(f"style must be one of {STYLES}")
|
|
501
|
+
if typ not in TYPES:
|
|
502
|
+
raise ValueError(f"source_type must be one of {TYPES}")
|
|
503
|
+
if not title or not str(title).strip():
|
|
504
|
+
raise ValueError("title is required")
|
|
505
|
+
au = _norm_authors(authors)
|
|
506
|
+
f = dict(f)
|
|
507
|
+
f["authors"] = authors
|
|
508
|
+
if "editors" in f:
|
|
509
|
+
pass
|
|
510
|
+
t = str(title).strip()
|
|
511
|
+
f["_title"] = _title_case(t) if title_case else t
|
|
512
|
+
if f.get("book_title") and title_case:
|
|
513
|
+
f["book_title"] = _title_case(f["book_title"])
|
|
514
|
+
|
|
515
|
+
builder = {"apa": _apa, "mla": _mla, "chicago": _chicago}.get(style)
|
|
516
|
+
if style == "bibtex":
|
|
517
|
+
bib = _bibtex(typ, f, au)
|
|
518
|
+
return {"style": "bibtex", "source_type": typ,
|
|
519
|
+
"reference": {"markdown": bib, "html": bib, "plain": bib},
|
|
520
|
+
"bibtex": bib, "intext": None}
|
|
521
|
+
segs = builder(typ, f, au)
|
|
522
|
+
ref = {m: _render(segs, m) for m in ("markdown", "html", "plain")}
|
|
523
|
+
return {"style": style, "source_type": typ, "reference": ref,
|
|
524
|
+
"intext": _intext(style, typ, f, au)}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def sample_output():
|
|
528
|
+
return compute("apa", "article",
|
|
529
|
+
authors=[{"family": "Mounier-Kuhn", "given": "Pierre"}],
|
|
530
|
+
title="Computer science in French universities: Early entrants and latecomers",
|
|
531
|
+
year=2012, container="Information & Culture: A Journal of History",
|
|
532
|
+
volume=47, issue=4, pages="414-456", doi="10.7560/IC47402")
|