vcs: Fix writing module structure
This commit is contained in:
parent
fee876b87f
commit
c14710aa98
15 changed files with 7 additions and 7 deletions
3
writing/papis/config/papis/config.py
Normal file
3
writing/papis/config/papis/config.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# from papis import config
|
||||
#
|
||||
# config.set("ref-format", f"{doc[author].split()[0] if 'author' in doc else 'Unknown'}{doc[year] if 'year' in doc else '0000'}")
|
||||
3
writing/papis/config/papis/headerformat
Normal file
3
writing/papis/config/papis/headerformat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{doc.html_escape[title]}
|
||||
<ansigreen>{doc.html_escape[author]}</ansigreen>
|
||||
<ansiblue>({doc.html_escape[year]})</ansiblue> [<ansiyellow>{doc.html_escape[tags]}</ansiyellow>] [<ansired>{doc.html_escape[readstatus]}</ansired>]
|
||||
203
writing/papis/config/papis/papistui.yaml
Normal file
203
writing/papis/config/papis/papistui.yaml
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# current issues/requests
|
||||
# - [ ] call arbitrary shell command
|
||||
# - [ ] non-blocking opening of files
|
||||
# - [ ] distinguish in config between aliases for multiline/table
|
||||
# - [ ] update view after more operations
|
||||
# - if I call an 'update' command (e.g. set new tag or read status), it will not update in list until 'edit' command invoked and rebuilding the list
|
||||
# - two selection additions:
|
||||
# - [ ] select all in view
|
||||
# - [ ] limit view to currently selected
|
||||
#
|
||||
# for papis itself:
|
||||
# - [ ] I can not search custom numeric fields in whoosh? i.e. 'priority:1' will not give results. 'priority:high' will
|
||||
base:
|
||||
vimflavour: nvim
|
||||
documentlist:
|
||||
defaultstyle: multiline
|
||||
marked-icon: ""
|
||||
multilinestyle:
|
||||
rows:
|
||||
# TODO: show if already note attached; have info window display note content?
|
||||
- "{' ' if 'priority' not in doc or doc['priority'] == 'low' else '<red> </red>' if doc['priority'] == 'high' else '<yellow> </yellow>'}<cyan>{doc.alias('type')} {doc['ref']}</cyan> {doc.alias('readstatus')} {' ' if doc['notes'] else ''}"
|
||||
- "<white><bold>{doc.html_escape['title']}</bold></white>"
|
||||
- "<blue>{doc.html_escape['author']}</blue>"
|
||||
- "{doc.foreach('tags', '<lightgray>(</lightgray><gray><dim>{}</dim></gray><lightgray>)</lightgray>', split = ', ', sep = ' ')}"
|
||||
tablestyle:
|
||||
separator: " "
|
||||
headerstyle: "underline|bold"
|
||||
rowstyle: "white_bg"
|
||||
cursorrowstyle: "black_white|bold"
|
||||
columns:
|
||||
- {
|
||||
header: "",
|
||||
content: "{'' if 'priority' not in doc or doc['priority'] == 'low' else '' if doc['priority'] == 'high' else ''}",
|
||||
width: 1,
|
||||
}
|
||||
- {
|
||||
header: "",
|
||||
content: "{'' if doc['readstatus'] == 'read' else '' if doc['readstatus'] == 'skimmed' else ' '}",
|
||||
width: 1,
|
||||
}
|
||||
- {
|
||||
header: "",
|
||||
content: "{'' if doc['notes'] else '' if doc['note'] else ' '}",
|
||||
width: 1,
|
||||
}
|
||||
- {
|
||||
header: "",
|
||||
content: "{doc.alias('type')}",
|
||||
width: 1,
|
||||
}
|
||||
- { header: "Authors", content: "{doc['author']}", width: 25 }
|
||||
- { header: "Year", content: "{str(doc['year'])}", width: 4 }
|
||||
- { header: "Title", content: "{doc['title']}", width: 125 }
|
||||
- { header: "Reference", content: "{doc['ref']}", width: 15 }
|
||||
- {
|
||||
header: "",
|
||||
content: "{str(len(doc.get_files()) if len(doc.get_files()) > 0 else '')}",
|
||||
width: 1,
|
||||
}
|
||||
- { header: "Tags", content: "{doc['tags'] if isinstance(doc['tags'], str) else doc.foreach('tags', '{}', sep=', ')}", width: 35 }
|
||||
aliases:
|
||||
type:
|
||||
{
|
||||
article: "",
|
||||
book: "",
|
||||
inbook: "",
|
||||
incollection: "",
|
||||
software: "",
|
||||
presentation: "",
|
||||
thesis: "",
|
||||
techreport: "",
|
||||
_default_: "",
|
||||
}
|
||||
readstatus:
|
||||
{
|
||||
read: "<green></green>",
|
||||
skimmed: "<yellow></yellow>"
|
||||
}
|
||||
|
||||
keymappings:
|
||||
q: quit
|
||||
"?": help
|
||||
T: toggle_style
|
||||
S: cmd "sort "
|
||||
/: search_mode
|
||||
<key_down>: scroll_down
|
||||
<key_up>: scroll_up
|
||||
<ctrl-f>: page_down
|
||||
<ctrl-b>: page_up
|
||||
G: jump_to_bottom
|
||||
gg: jump_to_top
|
||||
j: scroll_down
|
||||
k: scroll_up
|
||||
o: open -r "pdf$"
|
||||
O: open -d
|
||||
b: browse
|
||||
B: papis browse -k doi papis_id:{doc['papis_id']}
|
||||
R: view_reset
|
||||
<c-r>: reload
|
||||
e:
|
||||
- edit
|
||||
- edit info
|
||||
n:
|
||||
- papis edit -n papis_id:{doc['papis_id']}
|
||||
- edit notes
|
||||
"'n":
|
||||
- search "notes:.+"
|
||||
- limit to entries with notes
|
||||
"'u":
|
||||
- search "readstatus:read OR readstatus:skimmed"
|
||||
- limit to read entries
|
||||
"'r":
|
||||
- search "NOT readstatus:read AND NOT readstatus:skimmed"
|
||||
- limit to unread entries
|
||||
" ": mark_selected
|
||||
mm: mark_selected
|
||||
M: mark_down
|
||||
J: mark_down
|
||||
mu: unmark_all
|
||||
mv: mark_view
|
||||
",t": cmd "tag "
|
||||
",r":
|
||||
- papis update -s readstatus read papis_id:{doc['papis_id']}
|
||||
- set readstatus read
|
||||
",k":
|
||||
- papis update -s readstatus skimmed papis_id:{doc['papis_id']}
|
||||
- set readstatus skimmed
|
||||
",u":
|
||||
- papis update -s readstatus "" papis_id:{doc['papis_id']}
|
||||
- set readstatus unread
|
||||
",ph":
|
||||
- papis update -s priority high papis_id:{doc['papis_id']}
|
||||
- set priority high
|
||||
",pm":
|
||||
- papis update -s priority medium read papis_id:{doc['papis_id']}
|
||||
- set priority medium
|
||||
",pl":
|
||||
- papis update -s priority low read papis_id:{doc['papis_id']}
|
||||
- set priority low
|
||||
i:
|
||||
- info_toggle
|
||||
- "Toggle info window"
|
||||
I:
|
||||
- info_cycle
|
||||
- "Cycle info windows"
|
||||
<ctrl-p>: info_scroll_up
|
||||
<ctrl-n>: info_scroll_down
|
||||
# all require 'clip' script to be available on PATH
|
||||
yy:
|
||||
- copy_to_clipboard "[@{doc['ref']}]"
|
||||
- yank pandoc-styled reference
|
||||
ss:
|
||||
- vim_send "[@{doc['ref']}]"
|
||||
- send vim pandoc-styled reference
|
||||
yl:
|
||||
- copy_to_clipboard "\\cite\{{doc['ref']}\}"
|
||||
- yank latex-styled reference
|
||||
sl:
|
||||
- vim_send "\\cite\{{doc['ref']}\}"
|
||||
- send vim latex-styled reference
|
||||
yr:
|
||||
- copy_to_clipboard "{format_reference(doc,style='apa')}"
|
||||
- yank apa-styled reference
|
||||
sr:
|
||||
- vim_send "{format_reference(doc,style='apa')}"
|
||||
- send vim apa-styled reference
|
||||
yt:
|
||||
- copy_to_clipboard "{doc['title']}"
|
||||
- yank title
|
||||
yu:
|
||||
- copy_to_clipboard "{doc['url']}"
|
||||
- yank url
|
||||
yd:
|
||||
- copy_to_clipboard "{doc['doi']}"
|
||||
- yank doi
|
||||
# TODO look into https://github.com/supersambo/papis-tui vim-send mappings
|
||||
|
||||
infowindow:
|
||||
default_on: False
|
||||
views:
|
||||
doc:
|
||||
content: "author: {doc['author'].strip()}\n title: {doc['title'].strip()}\n tags:{doc['tags'] if isinstance(doc['tags'], str) else doc.foreach('tags', '{}', sep=', ')}"
|
||||
height: 8
|
||||
apa:
|
||||
content: "{format_reference(doc,style='apa')}"
|
||||
abstract:
|
||||
content: "{doc['abstract']}"
|
||||
linewrap: True
|
||||
height: 8
|
||||
|
||||
commandline:
|
||||
search:
|
||||
keyword_aliases: { a: "author:", t: "title:", y: "year:", k: "tags:" }
|
||||
|
||||
statusbar:
|
||||
left:
|
||||
default: "<black_green><bold> {info['mode_upper']} </black_green></bold><green_bg></green_bg>"
|
||||
normal: "<black_green><bold> {info['mode_upper']} </black_green></bold><green_bg></green_bg>"
|
||||
command: "<black_cyan><bold> {info['mode_upper']} </black_cyan></bold><cyan_bg></cyan_bg>"
|
||||
select: "<black_red><bold> {info['mode_upper']} </black_red></bold><red_bg></red_bg>"
|
||||
search: "<black_magenta><bold> {info['mode_upper']} </black_magenta></bold><magenta_bg></magenta_bg>"
|
||||
right:
|
||||
default: "<green>{info['sortkeys']} </green><cyan_bg></cyan_bg><black_cyan> {info['idx']} < {info['marked']} < {info['view']} < {info['items']} </black_cyan>"
|
||||
163
writing/papis/config/papis/scripts/papis-marvin
Executable file
163
writing/papis/config/papis/scripts/papis-marvin
Executable file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env python
|
||||
# papis-short-help: Import iOS Marvin exported csv annotations
|
||||
#
|
||||
# This script can be used to import your highlights and notes from
|
||||
# the iOS application 'Marvin Reader'. In the app, export your
|
||||
# annotations as 'csv' format and then point the script to the
|
||||
# resulting file.
|
||||
# https://git.martyoeh.me/Marty/papis-marvin
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
from typing import Dict
|
||||
import papis.api
|
||||
import papis.pick
|
||||
import papis.format
|
||||
import papis.commands.edit
|
||||
import papis.commands.list
|
||||
import papis.commands.add
|
||||
import papis.notes
|
||||
import papis.config
|
||||
import papis.database
|
||||
import isbnlib
|
||||
import papis.isbn
|
||||
|
||||
logger = logging.getLogger("marvin")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
DEFAULT_CSV_PATH = "/home/marty/Nextcloud/Personal/Backups/Journal.csv"
|
||||
|
||||
|
||||
def main(fpath, db):
|
||||
with open(fpath) as f:
|
||||
import csv
|
||||
|
||||
csv = csv.DictReader(f)
|
||||
notes = get_all_annotations(db, csv)
|
||||
|
||||
write_to_files(notes)
|
||||
|
||||
|
||||
def get_all_annotations(db, csv) -> Dict:
|
||||
notes = {}
|
||||
note_file = ""
|
||||
for row in csv:
|
||||
# switch to next book
|
||||
if not is_same_book(row["Title"]):
|
||||
doc = get_document(db, row["Author"], row["Title"])
|
||||
if not doc:
|
||||
continue
|
||||
note_file = get_notefile(db, doc)
|
||||
|
||||
text = format_entry(row)
|
||||
|
||||
if note_file and text:
|
||||
if note_file not in notes.keys():
|
||||
notes[note_file] = []
|
||||
notes[note_file].append(text)
|
||||
return notes
|
||||
|
||||
|
||||
def get_document(db, author, title):
|
||||
res = query_document(db, author, title)
|
||||
if not res:
|
||||
add_to_database(author, title)
|
||||
res = query_document(db, author, title)
|
||||
if not res:
|
||||
logger.warning(f"Nothing found for {author}: {title}.\nPlease create manually.")
|
||||
return
|
||||
return res
|
||||
|
||||
|
||||
# TODO warn user/ let him pick with picker if multiple docs found
|
||||
def query_document(db, author, title):
|
||||
title = strip_string(title)
|
||||
for query in [f"author:({author}) title:({title})"]:
|
||||
print(f"query: {query}")
|
||||
res = db.query(query)
|
||||
if len(res) >= 1:
|
||||
return res[0]
|
||||
|
||||
|
||||
def add_to_database(author, title, confirm=True, edit=False):
|
||||
logger.info(f"Searching - '{title} {author}'")
|
||||
data = None
|
||||
try:
|
||||
data = papis.isbn.get_data(f"{title}")
|
||||
except isbnlib.ISBNLibException as e:
|
||||
logger.error(e)
|
||||
else:
|
||||
logger.warning(f"Found: {data}")
|
||||
if data:
|
||||
papis_data = papis.isbn.data_to_papis(data[0])
|
||||
papis.commands.add.run([], data=papis_data, confirm=confirm, edit=edit)
|
||||
|
||||
|
||||
def get_notefile(db, document) -> str | None:
|
||||
if not document.has("notes"):
|
||||
notes_name = papis.config.getstring("notes-name")
|
||||
document["notes"] = papis.format.format(notes_name, document)
|
||||
document.save()
|
||||
db.update(document)
|
||||
|
||||
notes_path = os.path.join(str(document.get_main_folder()), document["notes"])
|
||||
|
||||
if not os.path.exists(notes_path):
|
||||
# TODO reimplement logger: logger.debug("Creating '%s'", notes_path)
|
||||
papis.notes.notes_path_ensured(document)
|
||||
return notes_path
|
||||
|
||||
|
||||
# TODO implement custom formatting (akin to pubs-extract)
|
||||
def format_entry(row) -> str:
|
||||
text = f"> {row['HighlightText']}"
|
||||
if row["EntryText"]:
|
||||
if text:
|
||||
text += "\n"
|
||||
else:
|
||||
text = "> "
|
||||
text += f"{row['EntryText']}"
|
||||
return text
|
||||
|
||||
|
||||
_old_title = ""
|
||||
|
||||
|
||||
def is_same_book(title):
|
||||
global _old_title
|
||||
|
||||
same = _old_title == title
|
||||
_old_title = title
|
||||
if same:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_to_files(notes: Dict):
|
||||
# write to notes
|
||||
for f, entries in notes.items():
|
||||
if f:
|
||||
with open(f, "a") as note:
|
||||
logger.info(f"Editing {f}...")
|
||||
num_added = 0
|
||||
for entry in entries:
|
||||
with open(f) as noteread:
|
||||
if entry not in noteread.read():
|
||||
note.write(f"{entry}\n\n")
|
||||
num_added += 1
|
||||
logger.info(f"Added {num_added} entries to it.")
|
||||
|
||||
|
||||
strip_pattern = re.compile(r"([^\s\w]|_)+\w*")
|
||||
|
||||
|
||||
def strip_string(title) -> str:
|
||||
return strip_pattern.sub("", title)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# use argument passed to command as file or default file here
|
||||
fpath = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CSV_PATH
|
||||
|
||||
main(fpath, papis.database.get())
|
||||
28
writing/papis/config/papis/scripts/papis-reload
Executable file
28
writing/papis/config/papis/scripts/papis-reload
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env python
|
||||
# papis-short-help: Manually rebuild cache for all libraries
|
||||
#
|
||||
# This tiny script updates all libraries by rebuilding their caches.
|
||||
# Useful to invoke after manual edits in one of your library folders
|
||||
# if you have many 'sub-libraries' (one library location with sub-
|
||||
# directories).
|
||||
# You don't have to think about which library you changed stuff in
|
||||
# and just get everything updated. Might take a little time but
|
||||
# should generally be a quick process ().
|
||||
|
||||
import papis.api
|
||||
from argparse import ArgumentParser
|
||||
|
||||
parser = ArgumentParser()
|
||||
_ = parser.add_argument(
|
||||
"--all", "-a", help="reload all libraries not just current", action="store_true"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
libs = papis.api.get_libraries()
|
||||
else:
|
||||
libs = [papis.api.get_lib_name()]
|
||||
|
||||
for lib in libs:
|
||||
papis.api.clear_lib_cache(lib)
|
||||
_ = papis.api.get_all_documents_in_lib()
|
||||
116
writing/papis/config/papis/scripts/papis-show
Executable file
116
writing/papis/config/papis/scripts/papis-show
Executable file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env python
|
||||
# papis-short-help: Display pretty human-readable document overview
|
||||
#
|
||||
# Takes a query and displays the metadata of the results. Uses
|
||||
# python-rich to display pretty panels if it exists in the environment,
|
||||
# otherwise displays regular text.
|
||||
# Can be invoked with -s to display single-line results better for
|
||||
# pasting into other documents.
|
||||
|
||||
import argparse
|
||||
from importlib.util import find_spec
|
||||
from papis import database
|
||||
from papis.database.base import Database
|
||||
from papis.document import Document
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--short", "-s", help="only display single-line quick info", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
dest="identifier",
|
||||
help="display identifier (doi, isbn, url) or not",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
)
|
||||
parser.add_argument("query", nargs="*", help="the query to search for")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocInfo:
|
||||
author: str = ""
|
||||
title: str = ""
|
||||
year: str = ""
|
||||
journal: str = ""
|
||||
volume: str = ""
|
||||
issue: str = ""
|
||||
pages: str = ""
|
||||
publisher: str = ""
|
||||
tags: list = field(default_factory=lambda: [])
|
||||
dtype: str = ""
|
||||
identifier: str = ""
|
||||
doi: str = ""
|
||||
|
||||
@staticmethod
|
||||
def from_Document(doc: Document):
|
||||
t: list[str] | str = doc.get("tags", "")
|
||||
tags = (
|
||||
t.replace(";", ",").replace(" ", "").split(",") if isinstance(t, str) else t
|
||||
)
|
||||
return DocInfo(
|
||||
author=doc.get("author", ""),
|
||||
title=doc.get("title", ""),
|
||||
year=doc.get("year", ""),
|
||||
journal=doc.get("journal", ""),
|
||||
volume=doc.get("volume", ""),
|
||||
issue=doc.get("issue", ""),
|
||||
pages=doc.get("pages", ""),
|
||||
publisher=doc.get("publisher", ""),
|
||||
tags=tags,
|
||||
dtype=doc.get("type", ""),
|
||||
identifier=doc.get("doi", doc.get("isbn", doc.get("url", ""))),
|
||||
)
|
||||
|
||||
|
||||
def main(db: Database, args) -> None:
|
||||
query = " ".join(args.query)
|
||||
docs: list[Document] = db.query(query)
|
||||
for doc in docs:
|
||||
info = DocInfo.from_Document(doc)
|
||||
if args.short:
|
||||
print_short(info, with_identifier=args.identifier)
|
||||
else:
|
||||
print_info(info, with_identifier=args.identifier)
|
||||
|
||||
|
||||
def print_short(doc: DocInfo, with_identifier: bool = True) -> None:
|
||||
print(
|
||||
f"{doc.author} ({doc.year}). {doc.title}. "
|
||||
f"{doc.identifier if with_identifier else ''}\n"
|
||||
)
|
||||
|
||||
|
||||
def print_info(doc: DocInfo, with_identifier: bool = True) -> None:
|
||||
if find_spec("rich"):
|
||||
from rich import print as richprint
|
||||
from rich.panel import Panel
|
||||
|
||||
info: str = (
|
||||
f"[red]{doc.author}[/red] ({doc.year}) "
|
||||
f"[steel_blue]\\[{doc.dtype}][/steel_blue]\n"
|
||||
f"[bold]{doc.title}[/bold]\n"
|
||||
f"{doc.journal} ({doc.volume}/{doc.issue}){doc.pages} - {doc.publisher}\n"
|
||||
f"[grey69]{[tag for tag in doc.tags]} "
|
||||
)
|
||||
if with_identifier:
|
||||
info += f"[link={doc.identifier}]{doc.identifier}[/link][/grey69]"
|
||||
richprint(Panel(info, expand=False))
|
||||
else:
|
||||
info: str = (
|
||||
f"{doc.author} ({doc.year}) "
|
||||
f"[{doc.dtype}]\n"
|
||||
f"{doc.title}\n"
|
||||
f"{doc.journal} ({doc.volume}/{doc.issue}){doc.pages} - {doc.publisher}\n"
|
||||
f"{[tag for tag in doc.tags]} "
|
||||
f"{doc.identifier}"
|
||||
if with_identifier
|
||||
else ""
|
||||
)
|
||||
print(f"{info}\n---")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(database.get(), args)
|
||||
57
writing/papis/config/papis/scripts/papis-tags
Executable file
57
writing/papis/config/papis/scripts/papis-tags
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
#!/usr/bin/env python
|
||||
# papis-short-help: List all tags occuring in query items
|
||||
#
|
||||
# Takes a query and spits out a sorted list of all tags contained therein,
|
||||
# nothing more.
|
||||
# Can be very useful for things like picking a tag or two and listing all
|
||||
# items that contain it:
|
||||
# $ papis tags "*" | fzf | xargs papis show "tags:{}"
|
||||
|
||||
import argparse
|
||||
from papis import database
|
||||
from papis.database.base import Database
|
||||
|
||||
from papis.document import Document
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--count", "-c", help="the query to search for", action="store_true"
|
||||
)
|
||||
parser.add_argument("query", nargs="*", help="the query to search for", default="*")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def main(db: Database, args) -> None:
|
||||
query = " ".join(args.query)
|
||||
docs: list[Document] = db.query(query)
|
||||
|
||||
all_tags: dict[str, int] = {}
|
||||
for doc in docs:
|
||||
t: list[str] | str = doc.get("tags", "")
|
||||
tags = (
|
||||
t.replace(";", ",").replace(" ", "").split(",") if isinstance(t, str) else t
|
||||
)
|
||||
|
||||
for tag in tags:
|
||||
if tag == '':
|
||||
continue
|
||||
all_tags[tag] = all_tags.get(tag, 0) + 1
|
||||
|
||||
if args.count:
|
||||
print_tags_and_counts(all_tags)
|
||||
else:
|
||||
print_tags_only(all_tags)
|
||||
|
||||
def print_tags_only(all_tags):
|
||||
for tag in sorted(all_tags):
|
||||
print(tag)
|
||||
|
||||
def print_tags_and_counts(all_tags):
|
||||
for tag, count in sorted(all_tags.items(), key=lambda d: d[1], reverse=True):
|
||||
if args.count:
|
||||
print(tag, count)
|
||||
else:
|
||||
print(tag)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(database.get(), args)
|
||||
Loading…
Add table
Add a link
Reference in a new issue