', "@%1")
notes = notes:gsub("%s*(.-)%s*", "\n - %1")
notes = notes:gsub("", "\n%1\n")
notes = notes:gsub("(.-)
", "\n%1\n")
notes = notes:gsub("%s*(.-)%s*
", "\n> %1\n")
notes = notes:gsub('(.-)
', "\n%1\n")
notes = notes:gsub("(.-)
", "\n```\n%1\n```\n")
notes = notes:gsub("
", "\n\n")
notes = notes:gsub("
", "\n\n")
notes = notes:gsub("(.-)
", "%1\n\n")
notes = notes:gsub(' class="highlight" data%-annotation=".-"(.-)', "")
notes = notes:gsub("", "")
notes = notes:gsub("
", "")
notes = notes:gsub("", "")
notes = notes:gsub("", "")
notes = notes:gsub("\n\n\n", "\n\n")
return notes .. "\n"
end
--- Convert HTML to Typst
---@param notes string
local notes_to_typ = function(notes)
-- Year-page separator
local ypsep = get_ypsep("typst")
notes = notes:gsub("(.-)
", "= %1\n")
notes = notes:gsub("(.-)
", "== %1\n")
notes = notes:gsub("(.-)
", "=== %1\n")
notes = notes:gsub("(.-)
", "==== %1\n")
notes = notes:gsub("(.-)
", "===== %1\n")
notes = notes:gsub("(.-)", "*%1*")
notes = notes:gsub("(.-)", "*%1*")
notes = notes:gsub("(.-)", "_%1_")
notes = notes:gsub("(.-)", "_%1_")
notes =
notes:gsub('(.-)', "~~%1~~")
notes = notes:gsub(' rel="noopener noreferrer nofollow"', "")
notes = notes:gsub('(.-)', '#link("%1")[%2]')
notes = notes:gsub('%(.-%)', "")
notes = notes:gsub(
'.-',
'#cite(<%1>, supplement: "' .. ypsep .. '%2", form: "normal")'
)
notes =
notes:gsub('', "@%1")
notes = notes:gsub("%s*(.-)%s*", "\n - %1")
notes = notes:gsub("", "\n%1\n")
notes = notes:gsub("(.-)
", "\n%1\n")
notes = notes:gsub("%s*(.-)%s*
", "\n#quote[%1]\n")
notes = notes:gsub('(.-)
', "\n%1\n")
notes = notes:gsub("(.-)
", "\n```\n%1\n```\n")
notes = notes:gsub("
", "\n\n")
notes = notes:gsub("
", "\n\n")
notes = notes:gsub("(.-)
", "%1\n\n")
notes = notes:gsub(' class="highlight" data%-annotation=".-"(.-)', "")
notes = notes:gsub("", "")
notes = notes:gsub("
", "")
notes = notes:gsub("", "")
notes = notes:gsub("", "")
notes = notes:gsub("\n\n\n", "\n\n")
return notes .. "\n"
end
-- Return user notes from a reference.
---@param key string The Zotero key
---@return string | nil
function M.get_notes(key)
if not config.zotero_sqlite_path then return end
copy_zotero_data()
local key_id = get_key_id(key)
if not key_id then return end
local query = "SELECT parentItemID, note FROM itemNotes WHERE parentItemID = "
.. tostring(key_id)
local sql_data = get_sql_data(query)
if not sql_data then return end
local notes = ""
for _, v in pairs(sql_data) do
notes = notes .. v.note
end
if notes == "" then return nil end
local lang = get_ft_lang()
notes = sanitize(notes)
if lang == "typst" then
notes = notes_to_typ(notes)
elseif lang == "latex" then
notes = notes_to_tex(notes)
else
notes = notes_to_md(notes)
end
return notes
end
--- Return information useful for debugging the application
---@return table
function M.info()
local ntypes = {}
local n = 0
for _, v in pairs(entry) do
n = n + 1
if ntypes[v.etype] then
ntypes[v.etype] = ntypes[v.etype] + 1
else
ntypes[v.etype] = 1
end
end
local r = {
["attach_dir"] = config.attach_dir,
["zotero.sqlite"] = config.zotero_sqlite_path,
["tmpdir"] = config.tmpdir,
["docs"] = docs,
["citation template"] = cite_template,
["banned words"] = table.concat(banned_words, " "),
["excluded fields"] = table.concat(exclude_fields, ", "),
["n refs"] = n,
["types"] = ntypes,
}
return r
end
local function load_zotero_data()
if not config.zotero_sqlite_path then return end
copy_zotero_data()
get_collections()
get_items_key_type()
delete_items()
add_most_fields()
add_authors()
add_citekeys()
end
--- Find citation key and return list of completions
---@param ptrn string Search pattern
---@param d string Buffer name
---@return table
function M.get_match(ptrn, d)
if getmtime(config.zotero_sqlite_path) > ztime then load_zotero_data() end
local keys = {}
if docs[d] then
for _, c in pairs(docs[d]) do
for _, v in pairs(collections[c]) do
table.insert(keys, v)
end
end
end
if #keys == 0 then
for k, _ in pairs(entry) do
table.insert(keys, k)
end
end
-- priority level
local p1, p2, p3, p4, p5, p6 = {}, {}, {}, {}, {}, {}
ptrn = ptrn:lower()
for _, v in ipairs(keys) do
local e = entry[v]
if e.citekey:lower():find(ptrn) == 1 then
table.insert(p1, e)
elseif e.alastnm and e.alastnm[1] and e.alastnm[1][1]:lower():find(ptrn) == 1 then
table.insert(p2, e)
elseif e.title and e.title:lower():find(ptrn) == 1 then
table.insert(p3, e)
elseif e.citekey:lower():find(ptrn) and e.citekey:lower():find(ptrn) > 1 then
table.insert(p4, e)
elseif
e.alastnm
and e.alastnm[1]
and e.alastnm[1][1]:lower():find(ptrn)
and e.alastnm[1][1]:lower():find(ptrn) > 1
then
table.insert(p5, e)
elseif
e.title
and e.title:lower():find(ptrn)
and e.title:lower():find(ptrn) > 1
then
table.insert(p6, e)
end
end
local resp = {}
for _, v in pairs(p1) do
table.insert(resp, v)
end
for _, v in pairs(p2) do
table.insert(resp, v)
end
for _, v in pairs(p3) do
table.insert(resp, v)
end
for _, v in pairs(p4) do
table.insert(resp, v)
end
for _, v in pairs(p5) do
table.insert(resp, v)
end
for _, v in pairs(p6) do
table.insert(resp, v)
end
return resp
end
--- Get Zotero data
---@return boolean
function M.init()
-- Template for citation keys
cite_template = config.citation_template
if not cite_template then cite_template = "{Authors}-{Year}" end
-- Title words to be ignored
if config.banned_words then
banned_words = vim.split(config.banned_words, " ")
else
banned_words =
{ "a", "an", "the", "some", "from", "on", "in", "to", "of", "do", "with" }
end
-- Path to zotero.sqlite
if config.zotero_sqlite_path then
if not vim.uv.fs_access(config.zotero_sqlite_path, "r") then
local msg = 'Please, check if the config option `zotero_sqlite_path` is set correctly: "'
.. config.zotero_sqlite_path
.. '" not found.'
zwarn(msg)
return false
end
end
-- Path to attachments directory
if config.attach_dir and not is_directory(config.attach_dir) then
local msg = "Please, fix the value `attach_dir` in your config. The directory "
.. config.attach_dir
.. " is not writable."
zwarn(msg)
return false
end
if not config.zotero_sqlite_path then
local adir, zdir = get_zotero_prefs()
if adir and not config.attach_dir then config.attach_dir = adir end
if zdir then
config.zotero_sqlite_path = zdir
else
return false
end
end
-- Temporary directory
if not config.tmpdir then
if os.getenv("XDG_CACHE_HOME") then
config.tmpdir = os.getenv("XDG_CACHE_HOME") .. "/zotcite"
elseif os.getenv("APPDATA") then
config.tmpdir = os.getenv("APPDATA") .. "/zotcite"
elseif is_directory(expand_tilde("~/.cache")) then
config.tmpdir = expand_tilde("~/.cache/zotcite")
elseif is_directory(expand_tilde("~/Library/Caches")) then
config.tmpdir = expand_tilde("~/Library/Caches/zotcite")
else
config.tmpdir = "/tmp/.zotcite"
end
end
if not is_directory(config.tmpdir) then
if vim.fn.mkdir(config.tmpdir, "p", "0o700") == 0 then
zwarn("Error creating directory '" .. config.tmpdir .. "'")
return false
end
if not is_directory(config.tmpdir) then
zwarn(
'Please, either set or fix the value of `tmpdir` in your zotcite config: "'
.. config.tmpdir
.. '" is not writable.'
)
return false
end
end
-- Fields that should not be added to the bib entries:
local zexcl = config.exclude_fields
if not zexcl then
exclude_fields = {}
else
exclude_fields = str_split(zexcl, " ")
end
load_zotero_data()
return true
end
return M
================================================
FILE: plugin/zotcite.lua
================================================
local did_plugin = false
if did_plugin then return end
did_plugin = true
vim.api.nvim_create_user_command("Zinfo", "lua require('zotcite.get').zotero_info()", {})
================================================
FILE: scripts/abntfix.py
================================================
#!/usr/bin/env python3
"""
Pandoc filter to fix ABNT citations.
"""
import sys
import io
import json
def TitleCase(x):
if x in [ 'et', 'al.', 'a', 'an', 'and', 'in', 'the', 'de', 'do', 'da',
'dos', 'das', 'com', 'na', 'no', 'nas', 'nos', 'e', 'um', 'uma']:
return x
return x.title()
def WalkFix(x):
if isinstance(x, dict) and 't' in x.keys() and x['t'] == 'Cite' and x['c'][0][0]['citationMode']['t'] == 'AuthorInText':
ultimo = 1000
for s, vs in enumerate(x['c'][1]):
if vs['t'] == 'Str':
vs['c'] = TitleCase(vs['c'])
if vs['c'].find(';') > -1:
ultimo = s
vs['c'] = vs['c'].replace(";", ",")
if ultimo < 1000:
x['c'][1][ultimo]['c'] = x['c'][1][ultimo]['c'].replace(",", " e")
else:
if isinstance(x, dict) and 'c' in x.keys() and isinstance(x['c'], list):
for y in x['c']:
WalkFix(y)
else:
if isinstance(x, list):
for y in x:
WalkFix(y)
if __name__ == "__main__":
sys.stderr.write('Running abntfix...\n')
sys.stderr.flush()
# Get json from pandoc (stdin)
i = sys.stdin.read()
j = json.load(io.StringIO(i))
WalkFix(j['blocks'])
# Print the json representation of the new document
sys.stdout.write(json.dumps(j))
================================================
FILE: scripts/apafix
================================================
The R package rmdfiltr includes an ampersand replacement filter:
https://cran.r-project.org/web/packages/rmdfiltr/index.html
================================================
FILE: scripts/odt2md.py
================================================
#!/usr/bin/env python3
"""
Convert an ODT file into Markdown, converting citations into zotcite format.
"""
import sys
import subprocess
import re
import json
from pathlib import Path
from zotero import ZoteroEntries
if __name__ == "__main__":
cmd = ['pandoc', sys.argv[1], '-t', 'markdown']
doc = subprocess.run(cmd, capture_output=True, text=True).stdout
z = ZoteroEntries()
while True:
res = re.search(
r"\[\]{#ZOTERO_ITEM CSL_CITATION\s(.*?),\\\"schema\\\":\\\"[\w:/\.-]*?\\\"}\s\w+}\(.*?\)", doc, re.DOTALL)
if not res:
break
cit = res.group(1)
cit += "}"
cit = cit.replace("\\\"", "\"")
cit = cit.replace("\\\\", "\\")
try:
cit = json.loads(cit)
except Exception as e:
with open("debug.txt", "w") as fh:
fh.write(cit)
raise (e)
cit_new = "["
for cit_item in cit["citationItems"]:
cit_new += z.GetCitationById(cit_item["id"]) + ";"
cit_new = cit_new[:-1] + "]"
doc = doc[:res.start()] + cit_new + doc[res.end():]
inf = Path(sys.argv[1])
outf = inf.parent / (inf.stem+".md")
with open(outf, "w") as fh:
fh.write(doc)
================================================
FILE: scripts/pdfnotes.py
================================================
#!/usr/bin/env python3
"""
Adapted from code written by Hamed MP, published at
https://gist.github.com/HamedMP/03440cca542ee7ae279175b78499fabf
"""
import sys
import os
import re
try:
import PyQt5
except ImportError:
sys.stderr.write("Please, install the Python3 module PyQt5")
sys.exit(1)
try:
import popplerqt5
except ImportError:
sys.stderr.write("Please, install the Python3 module python-poppler-qt5")
sys.exit(1)
def main():
doc = popplerqt5.Poppler.Document.load(sys.argv[1])
if doc is None:
sys.exit(33)
if len(sys.argv) > 2:
citekey = sys.argv[2]
else:
citekey = ''
# Sometimes, the page labels are spurious. So the priority is:
# 1. given page range; 2. page label; 3. given starting page.
page1 = 1
has_pg_range = False
if len(sys.argv) > 3:
pg = sys.argv[3]
if pg.find("-"):
has_pg_range = True
pIni = int(re.sub("-.*", "", pg))
pEnd = int(re.sub(".*-", "", pg))
nPgs = pEnd - pIni + 1
# Some documents have 1 or 2 extra presentation pages. Ignore them.
if doc.numPages() <= nPgs and (nPgs - doc.numPages()) < 2:
page1 = pEnd - doc.numPages() + 1
else:
page1 = int(sys.argv[3])
if os.getenv('ZYearPageSep') is None:
ypsep = ', p. '
else:
ypsep = os.getenv('ZYearPageSep')
notes = []
# Count the pages because not all page labels can be easily converted into
# numbers (examples: A1 and III)
pnum = 0
for i in range(doc.numPages()):
page = doc.page(i)
pnum += 1
pgnum = str(i + page1)
if not has_pg_range and page.label():
pgnum = page.label()
annotations = page.annotations()
(pwidth, pheight) = (page.pageSize().width(), page.pageSize().height())
if annotations:
for a in annotations:
if isinstance(a, popplerqt5.Poppler.Annotation):
# Guess the annotation's column for pages with two columns
if a.boundary().topRight().x() < 0.6:
c = 1
else:
c = 2
# Get the y coordinate to to print the annotations in the
# correct order
y = a.boundary().topRight().y()
if a.contents():
txt = a.contents() + ' [annotation'
if a.author():
txt = txt + ' by ' + a.author()
if citekey:
txt = txt + ' on ' + citekey
txt = txt + ypsep + pgnum + ']\n'
# Decrease the value of y to ensure that the comment
# on a highlighted text will be printed before the
# highlighted text itself
notes.append([pnum, c, y - 0.0000001, txt])
if isinstance(a, popplerqt5.Poppler.HighlightAnnotation):
quads = a.highlightQuads()
txt = ''
for quad in quads:
rect = (quad.points[0].x() * pwidth,
quad.points[0].y() * pheight,
quad.points[2].x() * pwidth,
quad.points[2].y() * pheight)
bdy = PyQt5.QtCore.QRectF()
bdy.setCoords(*rect)
txt = txt + str(page.text(bdy)) + '\n'
txt = txt.replace('-\n', '')
txt = txt.replace('\n', ' ')
txt = re.sub('^ *', '', txt)
txt = re.sub(' *$', '', txt)
if txt:
txt = '> ' + txt + ' ['
if citekey:
txt = txt + citekey
txt = txt + ypsep + pgnum + ']\n'
notes.append([pnum, c, y, txt])
if notes:
snotes = sorted(notes, key=lambda x: (x[0], x[1], x[2]))
for n in snotes:
print(n[3])
else:
sys.exit(34)
if __name__ == "__main__":
main()
================================================
FILE: scripts/pdfnotes2.py
================================================
#!/usr/bin/env python3
"""
PDF annotation extraction tool for zotcite using PyMuPDF (fitz)
"""
import sys
import os
import re
try:
import fitz # PyMuPDF
except ImportError:
sys.stderr.write("Please install the Python3 module pymupdf: pip install pymupdf")
sys.exit(1)
def main():
# Check arguments
if len(sys.argv) < 2:
sys.stderr.write("Usage: pdfnotes.py PDF_FILE [CITEKEY] [PAGE_OFFSET]\n")
sys.exit(1)
# Load the PDF file
try:
doc = fitz.open(sys.argv[1])
except Exception as e:
sys.stderr.write(f"Error opening PDF: {e}\n")
sys.exit(33)
# Get the citation key if provided
if len(sys.argv) > 2:
citekey = sys.argv[2]
else:
citekey = ''
# Get page offset
page1 = 1
has_pg_range = False
if len(sys.argv) > 3:
pg = sys.argv[3]
try:
# Check if it's a page range with hyphen
if "-" in pg:
has_pg_range = True
pIni = int(re.sub("-.*", "", pg))
pEnd = int(re.sub(".*-", "", pg))
nPgs = pEnd - pIni + 1
# Some documents have 1 or 2 extra presentation pages. Ignore them.
if doc.page_count <= nPgs and (nPgs - doc.page_count) < 2:
page1 = pEnd - doc.page_count + 1
else:
# Try to convert to an integer
try:
page1 = int(pg)
except ValueError:
# Not a valid page number, might be a Zotero key passed by mistake
sys.stderr.write(f"Warning: Third argument '{pg}' is not a valid page number. Using default page 1.\n")
page1 = 1
except Exception as e:
# Catch any other errors in page number parsing
sys.stderr.write(f"Warning: Error parsing page number: {e}. Using default page 1.\n")
page1 = 1
# Get year-page separator from environment
ypsep = os.getenv('ZYearPageSep')
if ypsep is None:
ypsep = ', p. '
notes = []
# Process each page
for page_idx in range(doc.page_count):
page = doc[page_idx]
pnum = page_idx + 1
# Handle page numbering
pgnum = str(page_idx + page1)
# Try to use page labels if available and not using a range
if not has_pg_range:
try:
# Get page label if available (Roman numerals, etc.)
if hasattr(doc, "get_page_labels"):
label = doc.get_page_labels()[page_idx]
if label and label.strip():
pgnum = label
except:
pass # Fallback to numeric page
# Get annotations
annots = page.annots()
if not annots:
continue
# Page dimensions for positioning
pwidth, _ = page.rect.width, page.rect.height
# Process annotations on this page
for annot in annots:
# Get annotation type
annot_type = annot.type[1]
# Get coordinates for sorting (top of annotation)
rect = annot.rect
x, y = rect.x0, rect.y0
# Guess column (for dual-column documents)
c = 1 if x < (pwidth / 2) else 2
# Get contents (annotation text)
contents = annot.info.get("content", "")
# Get author if available
author = annot.info.get("title", "")
# Process text comments/notes
if contents and annot_type in ["Text", "FreeText", "Note"]:
txt = contents + ' [annotation'
if author:
txt += ' by ' + author
if citekey:
txt += ' on ' + citekey
txt += ypsep + pgnum + ']\n'
# Add to notes list (with slight y-offset to ensure comments come before highlights)
notes.append([pnum, c, y - 0.0000001, txt])
# Process highlighted text
if annot_type in ["Highlight", "Underline"]:
# Extract text from the highlighted region
text = ""
try:
# Use word extraction so that only words mostly inside the highlight are included.
words = page.get_text("words")
for word in words:
if len(word) >= 5: # Ensure all required fields are present
word_rect = fitz.Rect(word[:4])
inter_rect = rect & word_rect # Compute the intersection rectangle
# Only add the word if more than 50% of its area is inside the annotation
if word_rect.get_area() > 0 and (inter_rect.get_area() / word_rect.get_area()) > 0.5:
text += word[4] + " "
except Exception:
try:
all_text = page.get_text("text")
if all_text:
text = "[Highlighted text could not be extracted precisely]"
except Exception:
pass
# Clean up the text
if text:
text = text.replace('-\n', '')
text = text.replace('\n', ' ')
text = re.sub(r'^\s*', '', text)
text = re.sub(r'\s*$', '', text)
text = re.sub(r'\s+', ' ', text)
if text.strip():
txt = '> ' + text + ' ['
if citekey:
txt += citekey
txt += ypsep + pgnum + ']\n'
notes.append([pnum, c, y, txt])
# Close the document
doc.close()
# Check if we found any notes
if notes:
# Sort notes by page, column, and y-position
snotes = sorted(notes, key=lambda x: (x[0], x[1], x[2]))
for n in snotes:
print(n[3], end='\n')
else:
sys.stderr.write(f"No annotations found in the PDF: {sys.argv[1]}\n")
sys.exit(34)
if __name__ == "__main__":
main()
================================================
FILE: scripts/zotid2bbtcitkey.py
================================================
#!/usr/bin/env python3
# Convert Zotero IDs into Better BibTeX (BBT) citation keys in a Markdown document
# These are the citation keys exported by Zotero File->Export Library with the "Better BibLaTeX" format
# and can be used in Quarto by setting the `bibliography:` YAML parameter to the exported .bib file
import requests
import sys
import json
import re
def zotid2citkey(zotid):
# Convert a Zotero ID into a Better BibTeX (BBT) citation key
#
# See
# https://github.com/retorquere/zotero-better-bibtex/discussions/3034#discussioncomment-11029462
url = "http://localhost:23119/better-bibtex/json-rpc"
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
data = {
"jsonrpc": "2.0",
"method": "item.citationkey",
"params": {"item_keys": [zotid]}
}
res = requests.post(url=url, headers=headers, data=json.dumps(data))
ress = list(res.json()["result"].items())
citkey = ress[0][1]
return (citkey)
fh = open(sys.argv[1], "r")
inf = fh.read()
fh.close()
ms = re.finditer(r"@\w+#[\w-]+(?=\]|;)", inf)
last_m = None
for m in ms:
if last_m is None:
outs = inf[0:m.start()]
else:
outs += inf[last_m.end():m.start()]
last_m = m
zotid = m.group(0)
if "#" in zotid:
zotid = zotid.split("#")[0]
zotid = re.sub("^@", "", zotid)
bbtid = zotid2citkey(zotid)
outs += "@" + bbtid
# Insert last part
outs += inf[last_m.end():]
with open(sys.argv[2], "w") as fh:
fh.write(outs)
================================================
FILE: selene.toml
================================================
# Set the base std to your custom library, which you'll put in std_dirs
std = "vim"
[rules]
multiple_statements = "allow"
[config]
# Point Selene to the directory containing your nvim.toml file.
# Assuming nvim.toml is in the same directory as selene.toml ('.').
std_dirs = [ "." ]
================================================
FILE: stylua.toml
================================================
column_width = 90
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 4
quote_style = "AutoPreferDouble"
call_parentheses = "Always"
collapse_simple_statement = "Always"
[sort_requires]
enabled = false
================================================
FILE: vim.yml
================================================
---
base: lua51
globals:
vim:
any: true
pandoc:
any: true