Merge branch 'develop' into texnote
This commit is contained in:
commit
4fe3b45836
1
TODO
1
TODO
@ -1,7 +1,6 @@
|
|||||||
TODO list
|
TODO list
|
||||||
=========
|
=========
|
||||||
- manage cross-references
|
- manage cross-references
|
||||||
+ labels
|
|
||||||
- find (authors) duplicates
|
- find (authors) duplicates
|
||||||
+ remove command
|
+ remove command
|
||||||
- stats command
|
- stats command
|
||||||
|
@ -24,7 +24,7 @@ filepath = cyan
|
|||||||
def dye(s, color=end, bold=False):
|
def dye(s, color=end, bold=False):
|
||||||
assert color[0] == '\033'
|
assert color[0] == '\033'
|
||||||
if bold:
|
if bold:
|
||||||
s = '\033[1' + s[3:]
|
color = '\033[1' + color[3:]
|
||||||
return color + s + end
|
return color + s + end
|
||||||
|
|
||||||
_dye = dye
|
_dye = dye
|
||||||
|
@ -8,5 +8,6 @@ import open_cmd
|
|||||||
import edit_cmd
|
import edit_cmd
|
||||||
import remove_cmd
|
import remove_cmd
|
||||||
import websearch_cmd
|
import websearch_cmd
|
||||||
import tags_cmd
|
import tag_cmd
|
||||||
import attach_cmd
|
import attach_cmd
|
||||||
|
import update_cmd
|
||||||
|
@ -10,7 +10,7 @@ def parser(subparsers, config):
|
|||||||
parser.add_argument('-b', '--bibfile',
|
parser.add_argument('-b', '--bibfile',
|
||||||
help='bibtex, bibtexml or bibyaml file', default=None)
|
help='bibtex, bibtexml or bibyaml file', default=None)
|
||||||
parser.add_argument('-d', '--docfile', help='pdf or ps file', default=None)
|
parser.add_argument('-d', '--docfile', help='pdf or ps file', default=None)
|
||||||
parser.add_argument('-l', '--label', help='label associated to the paper',
|
parser.add_argument('-t', '--tags', help='tags associated to the paper, separated by commas',
|
||||||
default=None)
|
default=None)
|
||||||
parser.add_argument('-c', '--copy', action='store_true', default=None,
|
parser.add_argument('-c', '--copy', action='store_true', default=None,
|
||||||
help="copy document files into library directory (default)")
|
help="copy document files into library directory (default)")
|
||||||
@ -19,7 +19,7 @@ def parser(subparsers, config):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def command(config, ui, bibfile, docfile, label, copy):
|
def command(config, ui, bibfile, docfile, tags, copy):
|
||||||
"""
|
"""
|
||||||
:param bibfile: bibtex file (in .bib, .bibml or .yaml format.
|
:param bibfile: bibtex file (in .bib, .bibml or .yaml format.
|
||||||
:param docfile: path (no url yet) to a pdf or ps file
|
:param docfile: path (no url yet) to a pdf or ps file
|
||||||
@ -37,15 +37,15 @@ def command(config, ui, bibfile, docfile, label, copy):
|
|||||||
cont = False
|
cont = False
|
||||||
except Exception:
|
except Exception:
|
||||||
cont = ui.input_yn(
|
cont = ui.input_yn(
|
||||||
question='Invalid bibfile. Edit again or abort?',
|
question='Invalid bibfile. Edit again ?',
|
||||||
default='y')
|
default='y')
|
||||||
if not cont:
|
if not cont:
|
||||||
ui.exit()
|
ui.exit()
|
||||||
p = Paper(bibentry=bib, citekey=key)
|
p = Paper(bibentry=bib, citekey=key)
|
||||||
else:
|
else:
|
||||||
p = Paper.load(bibfile)
|
p = Paper.load(bibfile)
|
||||||
if label is not None:
|
if tags is not None:
|
||||||
p.metadata['labels'] = label.split()
|
p.tags = set(tags.split(','))
|
||||||
# Check if another doc file is specified in bibtex
|
# Check if another doc file is specified in bibtex
|
||||||
docfile2 = extract_doc_path_from_bibdata(p, ui)
|
docfile2 = extract_doc_path_from_bibdata(p, ui)
|
||||||
if docfile is None:
|
if docfile is None:
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from .. import files
|
from .. import files
|
||||||
from .. import color
|
from .. import color
|
||||||
|
from .. import pretty
|
||||||
from ..repo import InvalidReference
|
from ..repo import InvalidReference
|
||||||
from ..paper import NoDocumentFile
|
from ..paper import NoDocumentFile
|
||||||
|
|
||||||
@ -51,3 +52,16 @@ def parse_reference(ui, rp, ref):
|
|||||||
def parse_references(ui, rp, refs):
|
def parse_references(ui, rp, refs):
|
||||||
citekeys = [parse_reference(ui, rp, ref) for ref in refs]
|
citekeys = [parse_reference(ui, rp, ref) for ref in refs]
|
||||||
return citekeys
|
return citekeys
|
||||||
|
|
||||||
|
def paper_oneliner(p, n = 0, citekey_only = False):
|
||||||
|
if citekey_only:
|
||||||
|
return p.citekey
|
||||||
|
else:
|
||||||
|
bibdesc = pretty.bib_oneliner(p.bibentry)
|
||||||
|
return (u'{num:d}: [{citekey}] {descr} {tags}'.format(
|
||||||
|
num=int(n),
|
||||||
|
citekey=color.dye(p.citekey, color.purple),
|
||||||
|
descr=bibdesc,
|
||||||
|
tags=color.dye(' '.join(p.tags),
|
||||||
|
color.purple, bold=True),
|
||||||
|
)).encode('utf-8')
|
||||||
|
@ -24,13 +24,14 @@ def command(config, ui, path, doc_dir):
|
|||||||
else:
|
else:
|
||||||
papersdir = os.path.join(os.getcwd(), path)
|
papersdir = os.path.join(os.getcwd(), path)
|
||||||
configs.add_and_write_option('papers', 'papers-directory', papersdir)
|
configs.add_and_write_option('papers', 'papers-directory', papersdir)
|
||||||
if not os.path.exists(papersdir):
|
if os.path.exists(papersdir):
|
||||||
|
if len(os.listdir(papersdir)) > 0:
|
||||||
|
ui.error('directory {} is not empty.'.format(
|
||||||
|
color.dye(papersdir, color.filepath)))
|
||||||
|
ui.exit()
|
||||||
|
|
||||||
ui.print_('Initializing papers in {}.'.format(
|
ui.print_('Initializing papers in {}.'.format(
|
||||||
color.dye(papersdir, color.filepath)))
|
color.dye(papersdir, color.filepath)))
|
||||||
repo = Repository()
|
repo = Repository()
|
||||||
repo.init(papersdir) # Creates directories
|
repo.init(papersdir) # Creates directories
|
||||||
repo.save() # Saves empty repository description
|
repo.save() # Saves empty repository description
|
||||||
else:
|
|
||||||
ui.error('papers already present in {}.'.format(
|
|
||||||
color.dye(papersdir, color.filepath)))
|
|
||||||
ui.exit()
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from .. import pretty
|
from .. import pretty
|
||||||
from .. import repo
|
from .. import repo
|
||||||
from .. import color
|
from .. import color
|
||||||
|
from . import helpers
|
||||||
|
|
||||||
|
|
||||||
def parser(subparsers, config):
|
def parser(subparsers, config):
|
||||||
@ -9,7 +10,7 @@ def parser(subparsers, config):
|
|||||||
default=False, dest='citekeys',
|
default=False, dest='citekeys',
|
||||||
help='Only returns citekeys of matching papers.')
|
help='Only returns citekeys of matching papers.')
|
||||||
parser.add_argument('query', nargs='*',
|
parser.add_argument('query', nargs='*',
|
||||||
help='Paper query (e.g. "year: 2000" or "labels: math")')
|
help='Paper query (e.g. "year: 2000" or "tags: math")')
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@ -17,20 +18,7 @@ def command(config, ui, citekeys, query):
|
|||||||
rp = repo.Repository.from_directory(config)
|
rp = repo.Repository.from_directory(config)
|
||||||
papers = [(n, p) for n, p in enumerate(rp.all_papers())
|
papers = [(n, p) for n, p in enumerate(rp.all_papers())
|
||||||
if test_paper(query, p)]
|
if test_paper(query, p)]
|
||||||
if citekeys:
|
ui.print_('\n'.join(helpers.paper_oneliner(p, n = n, citekey_only = citekeys) for n, p in papers))
|
||||||
paper_strings = [p.citekey for n, p in papers]
|
|
||||||
else:
|
|
||||||
paper_strings = []
|
|
||||||
for n, p in papers:
|
|
||||||
bibdesc = pretty.bib_oneliner(p.bibentry)
|
|
||||||
paper_strings.append((u'{num:d}: [{citekey}] {descr} {labels}'.format(
|
|
||||||
num=int(n),
|
|
||||||
citekey=color.dye(p.citekey, color.purple),
|
|
||||||
descr=bibdesc,
|
|
||||||
labels=color.dye(' '.join(p.metadata.get('labels', [])),
|
|
||||||
color.purple, bold=True),
|
|
||||||
)).encode('utf-8'))
|
|
||||||
ui.print_('\n'.join(paper_strings))
|
|
||||||
|
|
||||||
|
|
||||||
# TODO author is not implemented, should we do it by last name only or more
|
# TODO author is not implemented, should we do it by last name only or more
|
||||||
@ -45,8 +33,8 @@ def test_paper(query_string, p):
|
|||||||
field = tmp[0]
|
field = tmp[0]
|
||||||
value = tmp[1]
|
value = tmp[1]
|
||||||
|
|
||||||
if field in ['labels', 'l', 'tags', 't']:
|
if field in ['tags', 't']:
|
||||||
if value not in p.metadata['labels']:
|
if value not in p.tags:
|
||||||
return False
|
return False
|
||||||
elif field in ['author', 'authors', 'a']: # that is the very ugly
|
elif field in ['author', 'authors', 'a']: # that is the very ugly
|
||||||
if not 'author' in p.bibentry.persons:
|
if not 'author' in p.bibentry.persons:
|
||||||
|
59
papers/commands/tag_cmd.py
Normal file
59
papers/commands/tag_cmd.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
This command is all about tags.
|
||||||
|
The different use cases are :
|
||||||
|
1. > papers tag
|
||||||
|
Returns the list of all tags
|
||||||
|
2. > papers tag citekey
|
||||||
|
Return the list of tags of the given citekey
|
||||||
|
3. > papers tag citekey math
|
||||||
|
Add 'math' to the list of tags of the given citekey
|
||||||
|
4. > papers tag citekey :math
|
||||||
|
Remove 'math' for the list of tags of the given citekey
|
||||||
|
5. > papers tag citekey math,romance,:war
|
||||||
|
Add 'math' and 'romance' tags to the given citekey, and remove the 'war' tag
|
||||||
|
6. > papers tag math
|
||||||
|
If 'math' is not a citekey, then display all papers with the tag 'math'
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..repo import Repository, InvalidReference
|
||||||
|
from . import helpers
|
||||||
|
|
||||||
|
def parser(subparsers, config):
|
||||||
|
parser = subparsers.add_parser('tag', help="add, remove and show tags")
|
||||||
|
parser.add_argument('referenceOrTag', nargs='?', default = None,
|
||||||
|
help='reference to the paper (citekey or number), or '
|
||||||
|
'tag.')
|
||||||
|
parser.add_argument('tags', nargs='?', default = None,
|
||||||
|
help='If the previous argument was a reference, then '
|
||||||
|
'then a list of tags separated by commas.')
|
||||||
|
# TODO find a way to display clear help for multiple command semantics,
|
||||||
|
# indistinguisable for argparse. (fabien, 201306)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
def command(config, ui, referenceOrTag, tags):
|
||||||
|
"""Add, remove and show tags"""
|
||||||
|
rp = Repository.from_directory(config)
|
||||||
|
|
||||||
|
if referenceOrTag is None:
|
||||||
|
for tag in rp.get_tags():
|
||||||
|
ui.print_(tag)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
citekey = rp.citekey_from_ref(referenceOrTag)
|
||||||
|
p = rp.get_paper(citekey)
|
||||||
|
if tags is None:
|
||||||
|
ui.print_(' '.join(p.tags))
|
||||||
|
else:
|
||||||
|
tags = tags.split(',')
|
||||||
|
for tag in tags:
|
||||||
|
if tag[0] == ':':
|
||||||
|
p.remove_tag(tag[1:])
|
||||||
|
else:
|
||||||
|
p.add_tag(tag)
|
||||||
|
rp.save_paper(p)
|
||||||
|
except InvalidReference:
|
||||||
|
tag = referenceOrTag
|
||||||
|
papers_list = [(p, n) for n, p in enumerate(rp.all_papers())
|
||||||
|
if tag in p.tags]
|
||||||
|
ui.print_('\n'.join(helpers.paper_oneliner(p, n)
|
||||||
|
for p, n in papers_list))
|
@ -1,13 +0,0 @@
|
|||||||
from ..repo import Repository
|
|
||||||
|
|
||||||
|
|
||||||
def parser(subparsers, config):
|
|
||||||
parser = subparsers.add_parser('tags', help="list existing tags")
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def command(config, ui):
|
|
||||||
"""List existing tags"""
|
|
||||||
rp = Repository.from_directory(config)
|
|
||||||
for tag in rp.get_labels():
|
|
||||||
ui.print_(tag)
|
|
19
papers/commands/update_cmd.py
Normal file
19
papers/commands/update_cmd.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from .. import repo
|
||||||
|
from .. import color
|
||||||
|
|
||||||
|
def parser(subparsers, config):
|
||||||
|
parser = subparsers.add_parser('update', help='update the repository to the lastest format')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def command(config, ui):
|
||||||
|
rp = repo.Repository.from_directory(config)
|
||||||
|
msg = ("You should backup the paper directory {} before continuing."
|
||||||
|
"Continue ?").format(color.dye(rp.papersdir, color.filepath))
|
||||||
|
sure = ui.input_yn(question=msg, default='n')
|
||||||
|
if sure:
|
||||||
|
for p in rp.all_papers():
|
||||||
|
tags = set(p.metadata['tags'])
|
||||||
|
tags = tags.union(p.metadata['labels'])
|
||||||
|
p.metadata.pop('labels', [])
|
||||||
|
rp.save_paper(p)
|
@ -5,12 +5,13 @@ import urllib
|
|||||||
def parser(subparsers, config):
|
def parser(subparsers, config):
|
||||||
parser = subparsers.add_parser('websearch',
|
parser = subparsers.add_parser('websearch',
|
||||||
help="launch a search on Google Scholar")
|
help="launch a search on Google Scholar")
|
||||||
parser.add_argument("search_string",
|
parser.add_argument("search_string", nargs = '*',
|
||||||
help="the search query (anything googly is possible)")
|
help="the search query (anything googly is possible)")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def command(config, ui, search_string):
|
def command(config, ui, search_string):
|
||||||
|
print search_string
|
||||||
url = ("https://scholar.google.fr/scholar?q=%s&lr="
|
url = ("https://scholar.google.fr/scholar?q=%s&lr="
|
||||||
% (urllib.quote_plus(search_string)))
|
% (urllib.quote_plus(' '.join(search_string))))
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from cStringIO import StringIO
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@ -21,10 +22,10 @@ try:
|
|||||||
import pybtex.database.output.bibyaml
|
import pybtex.database.output.bibyaml
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print(ui.dye('error', ui.error) +
|
print(color.dye('error', color.error) +
|
||||||
": you need to install Pybtex; try running 'pip install "
|
": you need to install Pybtex; try running 'pip install "
|
||||||
"pybtex' or 'easy_install pybtex'")
|
"pybtex' or 'easy_install pybtex'")
|
||||||
|
exit(-1)
|
||||||
|
|
||||||
_papersdir = None
|
_papersdir = None
|
||||||
|
|
||||||
@ -122,7 +123,7 @@ def load_externalbibfile(fullbibpath):
|
|||||||
filename, ext = os.path.splitext(os.path.split(fullbibpath)[1])
|
filename, ext = os.path.splitext(os.path.split(fullbibpath)[1])
|
||||||
if ext[1:] in FORMATS_INPUT.keys():
|
if ext[1:] in FORMATS_INPUT.keys():
|
||||||
with open(fullbibpath) as f:
|
with open(fullbibpath) as f:
|
||||||
return parse_bibdata(f, ext[1:])
|
return _parse_bibdata_formated_stream(f, ext[1:])
|
||||||
else:
|
else:
|
||||||
print('{}: {} not recognized format for bibliography'.format(
|
print('{}: {} not recognized format for bibliography'.format(
|
||||||
color.dye('error', color.error),
|
color.dye('error', color.error),
|
||||||
@ -130,14 +131,38 @@ def load_externalbibfile(fullbibpath):
|
|||||||
exit(-1)
|
exit(-1)
|
||||||
|
|
||||||
|
|
||||||
def parse_bibdata(content, format_):
|
def _parse_bibdata_formated_stream(stream, fmt):
|
||||||
"""Parse bib data from string.
|
"""Parse a stream for bibdata, using the supplied format."""
|
||||||
|
try:
|
||||||
|
parser = FORMATS_INPUT[fmt].Parser()
|
||||||
|
data = parser.parse_stream(stream)
|
||||||
|
if data.entries.keys() > 0:
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise ValueError, 'content format is not recognized.'
|
||||||
|
|
||||||
|
def parse_bibdata(content, format_ = None):
|
||||||
|
"""Parse bib data from string or stream.
|
||||||
|
|
||||||
|
Raise ValueError if no bibdata is present.
|
||||||
:content: stream
|
:content: stream
|
||||||
:param format_: (bib|xml|yml)
|
:param format_: (bib|xml|yml) if format is None, tries to recognize the
|
||||||
|
format automatically.
|
||||||
"""
|
"""
|
||||||
parser = FORMATS_INPUT[format_].Parser()
|
fmts = [format_]
|
||||||
return parser.parse_stream(content)
|
if format_ is None:
|
||||||
|
fmts = FORMATS_INPUT.keys()
|
||||||
|
# we need to reuse the content
|
||||||
|
content = content if type(content) == str else str(content.read())
|
||||||
|
|
||||||
|
for fmt in fmts:
|
||||||
|
try:
|
||||||
|
return _parse_bibdata_formated_stream(StringIO(content), fmt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise ValueError, 'content format is not recognized.'
|
||||||
|
|
||||||
|
|
||||||
def editor_input(config, initial="", suffix=None):
|
def editor_input(config, initial="", suffix=None):
|
||||||
|
@ -20,7 +20,7 @@ CITEKEY_EXCLUDE_RE = re.compile('[%s]'
|
|||||||
|
|
||||||
BASE_META = {
|
BASE_META = {
|
||||||
'external-document': None,
|
'external-document': None,
|
||||||
'labels': [],
|
'tags': [],
|
||||||
'notes': [],
|
'notes': [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ def get_bibentry_from_file(bibfile):
|
|||||||
def get_bibentry_from_string(content):
|
def get_bibentry_from_string(content):
|
||||||
"""Extract first entry (supposed to be the only one) from given file.
|
"""Extract first entry (supposed to be the only one) from given file.
|
||||||
"""
|
"""
|
||||||
bib_data = files.parse_bibdata(StringIO(content), 'yml')
|
bib_data = files.parse_bibdata(StringIO(content))
|
||||||
first_key = bib_data.entries.keys()[0]
|
first_key = bib_data.entries.keys()[0]
|
||||||
first_entry = bib_data.entries[first_key]
|
first_entry = bib_data.entries[first_key]
|
||||||
return first_key, first_entry
|
return first_key, first_entry
|
||||||
@ -69,6 +69,7 @@ def get_safe_metadata(meta):
|
|||||||
base_meta = Paper.create_meta()
|
base_meta = Paper.create_meta()
|
||||||
if meta is not None:
|
if meta is not None:
|
||||||
base_meta.update(meta)
|
base_meta.update(meta)
|
||||||
|
base_meta['tags'] = set(base_meta['tags'])
|
||||||
return base_meta
|
return base_meta
|
||||||
|
|
||||||
|
|
||||||
@ -250,7 +251,27 @@ class Paper(object):
|
|||||||
return papers
|
return papers
|
||||||
|
|
||||||
|
|
||||||
class PaperInRepo(Paper):
|
# tags
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tags(self):
|
||||||
|
return self.metadata.setdefault('tags', set())
|
||||||
|
|
||||||
|
@tags.setter
|
||||||
|
def tags(self, value):
|
||||||
|
if not hasattr(value, '__iter__'):
|
||||||
|
raise ValueError, 'tags must be iterables'
|
||||||
|
self.metadata['tags'] = set(value)
|
||||||
|
|
||||||
|
def add_tag(self, tag):
|
||||||
|
self.tags.add(tag)
|
||||||
|
|
||||||
|
def remove_tag(self, tag):
|
||||||
|
"""Remove a tag from a paper if present."""
|
||||||
|
self.tags.discard(tag)
|
||||||
|
|
||||||
|
|
||||||
|
class PaperInRepo(Paper): # TODO document why this class exists (fabien, 2013/06)
|
||||||
|
|
||||||
def __init__(self, repo, *args, **kwargs):
|
def __init__(self, repo, *args, **kwargs):
|
||||||
Paper.__init__(self, *args, **kwargs)
|
Paper.__init__(self, *args, **kwargs)
|
||||||
|
@ -21,8 +21,9 @@ cmds = collections.OrderedDict([
|
|||||||
('remove', commands.remove_cmd),
|
('remove', commands.remove_cmd),
|
||||||
('open', commands.open_cmd),
|
('open', commands.open_cmd),
|
||||||
('websearch', commands.websearch_cmd),
|
('websearch', commands.websearch_cmd),
|
||||||
('tags', commands.tags_cmd),
|
('tag', commands.tag_cmd),
|
||||||
('attach', commands.attach_cmd),
|
('attach', commands.attach_cmd),
|
||||||
|
('update', commands.update_cmd),
|
||||||
])
|
])
|
||||||
|
|
||||||
config = configs.read_config()
|
config = configs.read_config()
|
||||||
|
@ -198,11 +198,11 @@ class Repository(object):
|
|||||||
new_doc_file = os.path.join(doc_path, citekey + ext)
|
new_doc_file = os.path.join(doc_path, citekey + ext)
|
||||||
shutil.copy(doc_file, new_doc_file)
|
shutil.copy(doc_file, new_doc_file)
|
||||||
|
|
||||||
def get_labels(self):
|
def get_tags(self):
|
||||||
labels = set()
|
tags = set()
|
||||||
for p in self.all_papers():
|
for p in self.all_papers():
|
||||||
labels = labels.union(p.metadata.get('labels', []))
|
tags = tags.union(p.tags)
|
||||||
return labels
|
return tags
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_directory(cls, config, papersdir=None):
|
def from_directory(cls, config, papersdir=None):
|
||||||
|
@ -24,7 +24,7 @@ entries:
|
|||||||
META = """
|
META = """
|
||||||
external-document: null
|
external-document: null
|
||||||
notes: []
|
notes: []
|
||||||
labels: []
|
tags: []
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user