[impression] menage
This commit is contained in:
parent
7af07b09a8
commit
452be81693
10 changed files with 449 additions and 0 deletions
189
archive/impression_old/crans_backend.py
Normal file
189
archive/impression_old/crans_backend.py
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: iso-8859-15 -*-
|
||||||
|
|
||||||
|
# Fonctions utilisées par les backend devis et laserjet
|
||||||
|
# Écrit par Benoit avec la contribution de Brice et Fred
|
||||||
|
|
||||||
|
# Licence : GNU General Public Licence, version 2
|
||||||
|
|
||||||
|
import sys, tempfile
|
||||||
|
import couts
|
||||||
|
sys.path.append('/usr/scripts/gestion')
|
||||||
|
from ldap_crans import crans_ldap
|
||||||
|
|
||||||
|
|
||||||
|
def preliminaires(arguments, description):
|
||||||
|
""" Verifie le nombre d'arguments passes.
|
||||||
|
Si pas d'arguments : print description,
|
||||||
|
si le bon nombre d'argument : retourne le fichier nettoye,
|
||||||
|
sinon sort sans stopper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# \x1B est le caractere d'échappement échap.
|
||||||
|
UEL="\x1B%-12345X"
|
||||||
|
|
||||||
|
if len(arguments) == 1 :
|
||||||
|
print description
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if len(arguments) not in (6, 7) :
|
||||||
|
sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % arguments[0])
|
||||||
|
sys.exit(0) # On n'arrête pas l'imprimante
|
||||||
|
|
||||||
|
if arguments[-1].find('/') != -1 :
|
||||||
|
try:
|
||||||
|
# Fichier fourni
|
||||||
|
entree = open(arguments[-1])
|
||||||
|
except:
|
||||||
|
# Des fois les options contiennent un / ...
|
||||||
|
entree = sys.stdin
|
||||||
|
else :
|
||||||
|
# Lecture sur entrée standard
|
||||||
|
entree = sys.stdin
|
||||||
|
|
||||||
|
## On nettoie le fichier :
|
||||||
|
fic = tempfile.NamedTemporaryFile()
|
||||||
|
ligne = entree.readline()
|
||||||
|
# On enlève les options qui nous gènent et on rend le fichier propre
|
||||||
|
while ligne:
|
||||||
|
# On ne choisit pas le bac de sortie
|
||||||
|
if ligne.find('%%BeginFeature: *OutputBin') != -1:
|
||||||
|
while ligne.find('%%EndFeature') == -1:
|
||||||
|
ligne = entree.readline()
|
||||||
|
ligne = entree.readline()
|
||||||
|
# On ne choisit pas le bac d'entree sauf si transparent
|
||||||
|
if ligne.find('%%BeginFeature: *InputSlot') != -1:
|
||||||
|
if ligne.find('Transparency') == -1:
|
||||||
|
while ligne.find('%%EndFeature') == -1:
|
||||||
|
ligne = entree.readline()
|
||||||
|
ligne = entree.readline()
|
||||||
|
# On élimine les instructions PJL
|
||||||
|
# Normalement on n'est pas obligé de retirer le UEL
|
||||||
|
# mais c'est plus propre
|
||||||
|
while ligne.startswith('@PJL') or ligne.startswith(UEL):
|
||||||
|
ligne = entree.readline()
|
||||||
|
fic.write(ligne)
|
||||||
|
ligne = entree.readline()
|
||||||
|
fic.flush()
|
||||||
|
return fic
|
||||||
|
|
||||||
|
|
||||||
|
def utilisateur(user, rw):
|
||||||
|
""" Renvoie l'adherent qui imprime le job
|
||||||
|
* user est l'utilisateur (argument n°2 des paramètres passés au backend)
|
||||||
|
* rw vaut 1 si on veut modifier le compte LDAP de l'adhérent,
|
||||||
|
0 dans le cas contraire
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Impression en local avec les certificats (possible pour le groupe adm)
|
||||||
|
if user=="root":
|
||||||
|
sys.stderr.write("ERROR: Utilisateur root passé en paramètre.\n")
|
||||||
|
sys.exit(0) # On conclue l'impression
|
||||||
|
|
||||||
|
# Récupération de l'adhérent
|
||||||
|
try:
|
||||||
|
base = crans_ldap()
|
||||||
|
if rw == 0 :
|
||||||
|
res=base.search("login=%s"%user)['adherent']
|
||||||
|
else:
|
||||||
|
res=base.search("login=%s"%user,'w')['adherent']
|
||||||
|
except:
|
||||||
|
sys.stderr.write("ERROR: Erreur : Base LDAP non joignable\n")
|
||||||
|
sys.exit(0) # On arrete l'impression
|
||||||
|
|
||||||
|
# Si on ne trouve rien :
|
||||||
|
if len(res) != 1 :
|
||||||
|
sys.stderr.write("ERROR: Erreur : adhérent %s non trouvé\n" % user)
|
||||||
|
sys.exit(0) # On conclue l'impression sans stopper l'imprimante
|
||||||
|
|
||||||
|
adherent = res[0]
|
||||||
|
sys.stderr.write("DEBUG: Adherent %s recupere.\n" % adherent.Nom())
|
||||||
|
return adherent
|
||||||
|
|
||||||
|
|
||||||
|
def calcul_prix(nom_fic):
|
||||||
|
"""Calcul le prix du fichier nom_fic"""
|
||||||
|
|
||||||
|
## #Temporaire pour debugage
|
||||||
|
## fs=open(nom_fic,'r')
|
||||||
|
## fd=open('/tmp/benoit_Routard','w')
|
||||||
|
## while 1:
|
||||||
|
## txt=fs.readline()
|
||||||
|
## if txt == '':
|
||||||
|
## break
|
||||||
|
## fd.write(txt)
|
||||||
|
## fs.close()
|
||||||
|
## fd.close()
|
||||||
|
|
||||||
|
# Calcul du cout de l'impression :
|
||||||
|
try:
|
||||||
|
prix = couts.cout(nom_fic)
|
||||||
|
except:
|
||||||
|
sys.stderr.write("ERROR: Erreur : Impossible de calculer le couts de %s." % nom_fic)
|
||||||
|
sys.exit(1) # On arrete l'imprimante
|
||||||
|
|
||||||
|
if prix.erreur == "Taille invalide":
|
||||||
|
sys.stderr.write("ERROR: Erreur de taille de papier (%s).\n" % prix.taille)
|
||||||
|
elif prix.erreur:
|
||||||
|
sys.stderr.write("ERROR: Erreur du calcul du prix : %s.\n" % prix.erreur)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("DEBUG: Prix calcule : %s euros (%s, %s).\n" % (prix.c_total_euros, prix.taille, prix.recto_v) )
|
||||||
|
|
||||||
|
return prix
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(sender, recipient, subject, body):
|
||||||
|
"""Send an email.
|
||||||
|
|
||||||
|
All arguments should be Unicode strings (plain ASCII works as well).
|
||||||
|
|
||||||
|
Only the real name part of sender and recipient addresses may contain
|
||||||
|
non-ASCII characters.
|
||||||
|
|
||||||
|
The email will be properly MIME encoded and delivered though SMTP to
|
||||||
|
localhost port 25. This is easy to change if you want something different.
|
||||||
|
|
||||||
|
The charset of the email will be the first one out of US-ASCII, ISO-8859-1
|
||||||
|
and UTF-8 that can represent all the characters occurring in the email.
|
||||||
|
"""
|
||||||
|
from smtplib import SMTP
|
||||||
|
from email.MIMEText import MIMEText
|
||||||
|
from email.Header import Header
|
||||||
|
from email.Utils import parseaddr, formataddr
|
||||||
|
|
||||||
|
# Header class is smart enough to try US-ASCII, then the charset we
|
||||||
|
# provide, then fall back to UTF-8.
|
||||||
|
header_charset = 'ISO-8859-1'
|
||||||
|
|
||||||
|
# We must choose the body charset manually
|
||||||
|
for body_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8':
|
||||||
|
try:
|
||||||
|
body.encode(body_charset)
|
||||||
|
except UnicodeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Split real name (which is optional) and email address parts
|
||||||
|
sender_name, sender_addr = parseaddr(sender)
|
||||||
|
recipient_name, recipient_addr = parseaddr(recipient)
|
||||||
|
|
||||||
|
# We must always pass Unicode strings to Header, otherwise it will
|
||||||
|
# use RFC 2047 encoding even on plain ASCII strings.
|
||||||
|
sender_name = str(Header(unicode(sender_name), header_charset))
|
||||||
|
recipient_name = str(Header(unicode(recipient_name), header_charset))
|
||||||
|
|
||||||
|
# Make sure email addresses do not contain non-ASCII characters
|
||||||
|
sender_addr = sender_addr.encode('ascii')
|
||||||
|
recipient_addr = recipient_addr.encode('ascii')
|
||||||
|
|
||||||
|
# Create the message ('plain' stands for Content-Type: text/plain)
|
||||||
|
msg = MIMEText(body.encode(body_charset), 'plain', body_charset)
|
||||||
|
msg['From'] = formataddr((sender_name, sender_addr))
|
||||||
|
msg['To'] = formataddr((recipient_name, recipient_addr))
|
||||||
|
msg['Subject'] = Header(unicode(subject), header_charset)
|
||||||
|
|
||||||
|
# Send the message via SMTP to localhost:25
|
||||||
|
smtp = SMTP("localhost")
|
||||||
|
smtp.sendmail(sender, recipient, msg.as_string())
|
||||||
|
smtp.quit()
|
70
archive/impression_old/imprime.py
Normal file
70
archive/impression_old/imprime.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import crans.impression
|
||||||
|
import crans.impression.digicode
|
||||||
|
import crans.ldap_crans_test
|
||||||
|
import os, sys
|
||||||
|
# ######################################################## #
|
||||||
|
# COMMAND LINE OPTION #
|
||||||
|
# ######################################################## #
|
||||||
|
#
|
||||||
|
#
|
||||||
|
OPTIONS_AGRAFES = {
|
||||||
|
0: crans.impression.PAS_D_AGRAPHES,
|
||||||
|
-1: crans.impression.AGRAPHE_DIAGONALE,
|
||||||
|
1: crans.impression.UNE_AGRAPHE,
|
||||||
|
2: crans.impression.DEUX_AGRAPHE,
|
||||||
|
3: crans.impression.TROIS_AGRAPHE,
|
||||||
|
6: crans.impression.STITCHING,
|
||||||
|
}
|
||||||
|
OPTIONS_NOIRETBLANC = {
|
||||||
|
False: crans.impression.IMPRESSION_COULEUR,
|
||||||
|
True: crans.impression.IMPRESSION_NB
|
||||||
|
}
|
||||||
|
OPTIONS_RECTOVERSO = {
|
||||||
|
False: crans.impression.IMPRESSION_RECTO,
|
||||||
|
True: crans.impression.IMPRESSION_RECTO_VERSO
|
||||||
|
}
|
||||||
|
|
||||||
|
from optparse import OptionParser
|
||||||
|
|
||||||
|
parser = OptionParser("usage: %prog [options] pdf")
|
||||||
|
parser.add_option("-a", "--agrafes",
|
||||||
|
action="store", type='int', dest="agrafes", default=0,
|
||||||
|
help="Choix du mode d'agrafes (%s)" % ", ".join(["%s: %s" % (val, crans.impression.LABELS[OPTIONS_AGRAFES[val]]) for val in OPTIONS_AGRAFES.keys()]))
|
||||||
|
parser.add_option("-p", "--papier",
|
||||||
|
action="store", type="string", dest="typepapier",
|
||||||
|
help="Choix papier (%s)" % ", ".join(["%s: %s" % (val, crans.impression.LABELS[val]) for val in crans.impression.PAPIER_VALEURS_POSSIBLES]))
|
||||||
|
parser.add_option("-r", "--recto-verso",
|
||||||
|
action="store_true", dest="rectoverso", default=False,
|
||||||
|
help="Impression recto-verso")
|
||||||
|
parser.add_option("-c", "--copies",
|
||||||
|
action="store", type="int", dest="copies",
|
||||||
|
help="Nombre de copies")
|
||||||
|
|
||||||
|
parser.add_option("-n", "--noir-et-blanc",
|
||||||
|
action="store_true", dest="noiretblanc", default=False,
|
||||||
|
help="impression en noir et blanc")
|
||||||
|
|
||||||
|
|
||||||
|
(options, args) = parser.parse_args()
|
||||||
|
|
||||||
|
if len(args) != 1:
|
||||||
|
parser.error("Nombre d'arguments incorect")
|
||||||
|
PDF_PATH = os.path.join(os.getcwd(), args[0])
|
||||||
|
USER_LOGIN = os.getlogin()
|
||||||
|
|
||||||
|
print("Analyse du fichier...")
|
||||||
|
lpr = crans.impression.impression(PDF_PATH, USER_LOGIN)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lpr.changeSettings(agraphes=OPTIONS_AGRAFES[options.agrafes],
|
||||||
|
papier=options.typepapier,
|
||||||
|
couleurs=OPTIONS_NOIRETBLANC[options.noiretblanc],
|
||||||
|
recto_verso = OPTIONS_RECTOVERSO[options.rectoverso],
|
||||||
|
copies=options.copies )
|
||||||
|
except crans.impression.SettingsError, e:
|
||||||
|
print "erreur: %s" % e
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
lpr.printSettings()
|
||||||
|
|
||||||
|
print "Prix total : %s Euros" % str(lpr.prix())
|
186
archive/impression_old/ipp.py
Executable file
186
archive/impression_old/ipp.py
Executable file
|
@ -0,0 +1,186 @@
|
||||||
|
#! /usr/bin/env python
|
||||||
|
# -*- coding: ISO-8859-15 -*-
|
||||||
|
#
|
||||||
|
# PyKota - Print Quotas for CUPS and LPRng
|
||||||
|
#
|
||||||
|
# (c) 2003-2004 Jerome Alet <alet@librelogiciel.com>
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
|
||||||
|
#
|
||||||
|
# $Id: ipp.py,v 1.2 2004/12/03 20:29:33 jalet Exp $
|
||||||
|
#
|
||||||
|
# $Log: ipp.py,v $
|
||||||
|
# Revision 1.2 2004/12/03 20:29:33 jalet
|
||||||
|
# ipp.py can now be run in standalone mode for testing purposes
|
||||||
|
#
|
||||||
|
# Revision 1.1 2004/11/06 22:35:58 jalet
|
||||||
|
# Added a miniparser for IPP messages (RFC 2910). The job-originating-host-name
|
||||||
|
# retrieval is now fiable, unless the CUPS developpers change something...
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from struct import unpack
|
||||||
|
|
||||||
|
OPERATION_ATTRIBUTES_TAG = 0x01
|
||||||
|
JOB_ATTRIBUTES_TAG = 0x02
|
||||||
|
END_OF_ATTRIBUTES_TAG = 0x03
|
||||||
|
PRINTER_ATTRIBUTES_TAG = 0x04
|
||||||
|
UNSUPPORTED_ATTRIBUTES_TAG = 0x05
|
||||||
|
|
||||||
|
class PyKotaIPPError(Exception):
|
||||||
|
"""An exception for PyKota IPP related stuff."""
|
||||||
|
def __init__(self, message = ""):
|
||||||
|
self.message = message
|
||||||
|
Exception.__init__(self, message)
|
||||||
|
def __repr__(self):
|
||||||
|
return self.message
|
||||||
|
__str__ = __repr__
|
||||||
|
|
||||||
|
class IPPMessage :
|
||||||
|
"""A class for IPP message files."""
|
||||||
|
def __init__(self, data) :
|
||||||
|
"""Initializes an IPP Message object."""
|
||||||
|
self.data = data
|
||||||
|
self._attributes = {}
|
||||||
|
self.curname = None
|
||||||
|
self.tags = [ None ] * 256 # by default all tags reserved
|
||||||
|
|
||||||
|
# Delimiter tags
|
||||||
|
self.tags[0x01] = "operation-attributes-tag"
|
||||||
|
self.tags[0x02] = "job-attributes-tag"
|
||||||
|
self.tags[0x03] = "end-of-attributes-tag"
|
||||||
|
self.tags[0x04] = "printer-attributes-tag"
|
||||||
|
self.tags[0x05] = "unsupported-attributes-tag"
|
||||||
|
|
||||||
|
# out of band values
|
||||||
|
self.tags[0x10] = "unsupported"
|
||||||
|
self.tags[0x11] = "reserved-for-future-default"
|
||||||
|
self.tags[0x12] = "unknown"
|
||||||
|
self.tags[0x13] = "no-value"
|
||||||
|
|
||||||
|
# integer values
|
||||||
|
self.tags[0x20] = "generic-integer"
|
||||||
|
self.tags[0x21] = "integer"
|
||||||
|
self.tags[0x22] = "boolean"
|
||||||
|
self.tags[0x23] = "enum"
|
||||||
|
|
||||||
|
# octetString
|
||||||
|
self.tags[0x30] = "octetString-with-an-unspecified-format"
|
||||||
|
self.tags[0x31] = "dateTime"
|
||||||
|
self.tags[0x32] = "resolution"
|
||||||
|
self.tags[0x33] = "rangeOfInteger"
|
||||||
|
self.tags[0x34] = "reserved-for-collection"
|
||||||
|
self.tags[0x35] = "textWithLanguage"
|
||||||
|
self.tags[0x36] = "nameWithLanguage"
|
||||||
|
|
||||||
|
# character strings
|
||||||
|
self.tags[0x20] = "generic-character-string"
|
||||||
|
self.tags[0x41] = "textWithoutLanguage"
|
||||||
|
self.tags[0x42] = "nameWithoutLanguage"
|
||||||
|
# self.tags[0x43] = "reserved"
|
||||||
|
self.tags[0x44] = "keyword"
|
||||||
|
self.tags[0x45] = "uri"
|
||||||
|
self.tags[0x46] = "uriScheme"
|
||||||
|
self.tags[0x47] = "charset"
|
||||||
|
self.tags[0x48] = "naturalLanguage"
|
||||||
|
self.tags[0x49] = "mimeMediaType"
|
||||||
|
|
||||||
|
# now parses the IPP message
|
||||||
|
self.parse()
|
||||||
|
|
||||||
|
def __getattr__(self, attrname) :
|
||||||
|
"""Allows self.attributes to return the attributes names."""
|
||||||
|
if attrname == "attributes" :
|
||||||
|
keys = self._attributes.keys()
|
||||||
|
keys.sort()
|
||||||
|
return keys
|
||||||
|
raise AttributeError, attrname
|
||||||
|
|
||||||
|
def __getitem__(self, ippattrname) :
|
||||||
|
"""Fakes a dictionnary d['key'] notation."""
|
||||||
|
value = self._attributes.get(ippattrname)
|
||||||
|
if value is not None :
|
||||||
|
if len(value) == 1 :
|
||||||
|
value = value[0]
|
||||||
|
return value
|
||||||
|
get = __getitem__
|
||||||
|
|
||||||
|
def parseTag(self) :
|
||||||
|
"""Extracts information from an IPP tag."""
|
||||||
|
pos = self.position
|
||||||
|
valuetag = self.tags[ord(self.data[pos])]
|
||||||
|
# print valuetag.get("name")
|
||||||
|
pos += 1
|
||||||
|
posend = pos2 = pos + 2
|
||||||
|
namelength = unpack(">H", self.data[pos:pos2])[0]
|
||||||
|
if not namelength :
|
||||||
|
name = self.curname
|
||||||
|
else :
|
||||||
|
posend += namelength
|
||||||
|
self.curname = name = self.data[pos2:posend]
|
||||||
|
pos2 = posend + 2
|
||||||
|
valuelength = unpack(">H", self.data[posend:pos2])[0]
|
||||||
|
posend = pos2 + valuelength
|
||||||
|
value = self.data[pos2:posend]
|
||||||
|
oldval = self._attributes.setdefault(name, [])
|
||||||
|
oldval.append(value)
|
||||||
|
return posend - self.position
|
||||||
|
|
||||||
|
def operation_attributes_tag(self) :
|
||||||
|
"""Indicates that the parser enters into an operation-attributes-tag group."""
|
||||||
|
return self.parseTag()
|
||||||
|
|
||||||
|
def job_attributes_tag(self) :
|
||||||
|
"""Indicates that the parser enters into an operation-attributes-tag group."""
|
||||||
|
return self.parseTag()
|
||||||
|
|
||||||
|
def printer_attributes_tag(self) :
|
||||||
|
"""Indicates that the parser enters into an operation-attributes-tag group."""
|
||||||
|
return self.parseTag()
|
||||||
|
|
||||||
|
def parse(self) :
|
||||||
|
"""Parses an IPP Message.
|
||||||
|
|
||||||
|
NB : Only a subset of RFC2910 is implemented.
|
||||||
|
We are only interested in textual informations for now anyway.
|
||||||
|
"""
|
||||||
|
self.version = "%s.%s" % (ord(self.data[0]), ord(self.data[1]))
|
||||||
|
self.operation_id = "0x%04x" % unpack(">H", self.data[2:4])[0]
|
||||||
|
self.request_id = "0x%08x" % unpack(">I", self.data[4:8])[0]
|
||||||
|
self.position = 8
|
||||||
|
try :
|
||||||
|
tag = ord(self.data[self.position])
|
||||||
|
while tag != END_OF_ATTRIBUTES_TAG :
|
||||||
|
self.position += 1
|
||||||
|
name = self.tags[tag]
|
||||||
|
if name is not None :
|
||||||
|
func = getattr(self, name.replace("-", "_"), None)
|
||||||
|
if func is not None :
|
||||||
|
self.position += func()
|
||||||
|
if ord(self.data[self.position]) > UNSUPPORTED_ATTRIBUTES_TAG :
|
||||||
|
self.position -= 1
|
||||||
|
continue
|
||||||
|
tag = ord(self.data[self.position])
|
||||||
|
except IndexError :
|
||||||
|
raise PyKotaIPPError, "Unexpected end of IPP message."
|
||||||
|
|
||||||
|
if __name__ == "__main__" :
|
||||||
|
if len(sys.argv) < 2 :
|
||||||
|
print "usage : python ipp.py /var/spool/cups/c00005 (for example)\n"
|
||||||
|
else :
|
||||||
|
infile = open(sys.argv[1])
|
||||||
|
message = IPPMessage(infile.read())
|
||||||
|
infile.close()
|
||||||
|
print "Client user name : %s" % message["job-originating-user-name"]
|
|
@ -1,5 +1,9 @@
|
||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
# -*- encoding: utf-8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
|
# canon_wrapper.py
|
||||||
|
# Authors: Daniel STAN <dstan@crans.org>
|
||||||
|
# Antoine Durand-Gasselin <adg@crans.org>
|
||||||
|
# License: GPLv3
|
||||||
|
|
||||||
import requests #pip install requests
|
import requests #pip install requests
|
||||||
# See:
|
# See:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue