'
return {'data': out, 'count': count}
def get_firefox_profile_names(path):
"""List folder and detect default Firefox profile names for all installs.
Returns
-------
profiles : [str]
All default Firefox profile names.
"""
from configparser import ConfigParser, NoOptionError
profiles = []
profile_path = os.path.expanduser(os.path.join(path, 'profiles.ini'))
if os.path.exists(profile_path):
config = ConfigParser()
config.read(profile_path)
install_names = [section for section in config.sections() if section.startswith('Install')]
for name in install_names:
try:
profiles += [config.get(name, 'default')]
except NoOptionError:
pass
if profiles:
return profiles
profiles_names = [section for section in config.sections() if section.startswith('Profile')]
for name in profiles_names:
try:
# If profile is default
if config.getboolean(name, 'default'):
profiles += [config.get(name, 'path')]
continue
except NoOptionError:
pass
try:
# alternative way to detect default profile
if config.get(name, 'name').lower() == "default":
profiles += [config.get(name, 'path')]
except NoOptionError:
pass
return profiles
# There are no default profiles
LOGDBG('get_firefox_profile_names(): {} does not exist'.format(path))
return profiles
def get_firefox_db_paths(*default_ff_folders, specified=None):
_profile_path = lambda f, s: (s if os.path.isabs(s) else os.path.join(f, s))
result = {}
for folder in default_ff_folders:
profiles = ([specified] if specified else get_firefox_profile_names(folder))
result.update({s: os.path.join(_profile_path(folder, s), 'places.sqlite')
for s in profiles if s not in result})
return result
def walk(root):
"""Recursively iterate over JSON.
Parameters
----------
root : JSON element
Base node of the JSON data.
"""
for element in root['children']:
if element['type'] == 'url':
url = element['url']
title = element['name']
yield (url, title, None, None, 0, True)
else:
walk(element)
def import_md(filepath: str, newtag: Optional[str]):
"""Parse bookmark Markdown file.
Parameters
----------
filepath : str
Path to Markdown file.
newtag : str, optional
New tag for bookmarks in Markdown file.
Returns
-------
tuple
Parsed result.
"""
# Supported Markdown format: `[title](url) ` (or ` `)
_named_link, _raw_link = r'\[(?P.*)\]\((?P.+)\)', r'\<(?P[^!>][^>]*)\>'
pattern = re.compile(r'(%s|%s)(\s+)?' % (_named_link, _raw_link))
with open(filepath, mode='r', encoding='utf-8') as infp:
for line in infp:
if match := pattern.search(line):
title = match.group('title') or ''
url = match.group('url') or match.group('url_raw')
if is_nongeneric_url(url):
continue
tags = DELIM.join(s for s in [newtag, match.group('tags')] if s)
tags = parse_tags([tags])
yield (url, title, delim_wrap(tags), None, 0, True, False)
def import_rss(filepath: str, newtag: Optional[str]):
"""Parse bookmark RSS file.
Parameters
----------
filepath : str
Path to RSS file.
newtag : str, optional
New tag for bookmarks in RSS file.
Returns
tuple
Parsed result.
"""
with open(filepath, mode='r', encoding='utf-8') as infp:
ns = {'atom': 'http://www.w3.org/2005/Atom'}
root = ET.fromstring(infp.read())
for entry in root.findall('atom:entry', ns):
title = entry.find('atom:title', ns).text
url = entry.find('atom:link', ns).attrib['href']
tags = ','.join([tag.attrib['term'] for tag in entry.findall('atom:category', ns)])
if newtag is not None:
tags = newtag + ',' + tags
desc = entry.find('atom:content', ns)
desc = desc.text if desc is not None else None
yield (url, title, delim_wrap(tags), desc, 0, True, False)
def import_org(filepath: str, newtag: Optional[str]):
"""Parse bookmark org file.
Parameters
----------
filepath : str
Path to org file.
newtag : str, optional
New tag for bookmarks in org file.
Returns
-------
tuple
Parsed result.
"""
def get_org_tags(tag_string):
"""Extracts tags from Org
Parameters
----------
tag_string: str
string of tags in Org-format
Syntax: Org splits tags with colons. If colons are part of a buku-tag, this is indicated by using
multiple colons in org. If a buku-tag starts or ends with a colon, this is indicated by a
preceding or trailing whitespace
Returns
-------
list
List of tags
"""
tag_list_raw = [s for s in re.split(r'(?((?!\]\[).)+?)', r'(\]\[(?P.+))?'
pattern = re.compile(r'\[\[%s%s\]\](?P\s+:.*:)?' % (_url, _maybe_title))
with open(filepath, mode='r', encoding='utf-8') as infp:
for line in infp:
if match := pattern.search(line):
title = match.group('title') or ''
url = match.group('url')
if is_nongeneric_url(url):
continue
tags = list(dict.fromkeys(get_org_tags(match.group('tags') or '')))
tags_string = DELIM.join(tags)
if newtag and newtag.lower() not in tags:
tags_string = (newtag + DELIM) + tags_string
yield (url, title, delim_wrap(tags_string), None, 0, True, False)
def import_firefox_json(json, add_bookmark_folder_as_tag=False, unique_tag=None):
"""Open Firefox JSON export file and import data.
Ignore 'SmartBookmark' and 'Separator' entries.
Needed/used fields out of the JSON schema of the bookmarks:
title : the name/title of the entry
tags : ',' separated tags for the bookmark entry
typeCode : 1 - uri, 2 - subfolder, 3 - separator
annos/{name,value} : following annotation entries are used
name : Places/SmartBookmark : identifies smart folder, ignored
name : bookmarkPropereties/description : detailed bookmark entry description
children : for subfolders, recurse into the child entries
Parameters
----------
path : str
Path to Firefox JSON bookmarks file.
unique_tag : str
Timestamp tag in YYYYMonDD format.
add_bookmark_folder_as_tag : bool
True if bookmark parent folder should be added as tags else False.
"""
class TypeCode(Enum):
""" Format
typeCode
1 : uri (type=text/x-moz-place)
2 : subfolder (type=text/x-moz-container)
3 : separator (type=text/x-moz-separator)
"""
uri = 1
folder = 2
separator = 3
def is_smart(entry):
result = False
try:
d = [anno for anno in entry['annos'] if anno['name'] == "Places/SmartBookmark"]
result = bool(len(d))
except Exception:
result = False
return result
def extract_desc(entry):
try:
d = [
anno for anno in entry['annos']
if anno['name'] == "bookmarkProperties/description"
]
return d[0]['value']
except Exception:
LOGDBG("ff_json: No description found for entry: {} {}".format(entry['uri'], entry['title']))
return ""
def extract_tags(entry):
tags = []
try:
tags = entry['tags'].split(',')
except Exception:
LOGDBG("ff_json: No tags found for entry: {} {}".format(entry['uri'], entry['title']))
return tags
def iterate_children(parent_folder, entry_list):
for bm_entry in entry_list:
entry_title = bm_entry['title'] if 'title' in bm_entry else ""
try:
typeCode = bm_entry['typeCode']
except Exception:
LOGDBG("ff_json: item without typeCode found, ignoring: {}".format(entry_title))
continue
LOGDBG("ff_json: processing typeCode '{}', title '{}'".format(typeCode, entry_title))
if TypeCode.uri.value == typeCode:
try:
if is_smart(bm_entry):
LOGDBG("ff_json: SmartBookmark found, ignoring: {}".format(entry_title))
continue
if is_nongeneric_url(bm_entry['uri']):
LOGDBG("ff_json: Non-Generic URL found, ignoring: {}".format(entry_title))
continue
desc = extract_desc(bm_entry)
bookmark_tags = extract_tags(bm_entry)
# if parent_folder is not "None"
if add_bookmark_folder_as_tag and parent_folder:
bookmark_tags.append(parent_folder)
if unique_tag:
bookmark_tags.append(unique_tag)
formatted_tags = [DELIM + tag for tag in bookmark_tags]
tags = parse_tags(formatted_tags)
LOGDBG("ff_json: Entry found: {}, {}, {}, {} " .format(bm_entry['uri'], entry_title, tags, desc))
yield (bm_entry['uri'], entry_title, tags, desc, 0, True, False)
except Exception as e:
LOGERR("ff_json: Error parsing entry '{}' Exception '{}'".format(entry_title, e))
elif TypeCode.folder.value == typeCode:
# ignore special bookmark folders
if 'root' in bm_entry and bm_entry['root'] in IGNORE_FF_BOOKMARK_FOLDERS:
LOGDBG("ff_json: ignoring root folder: {}" .format(entry_title))
entry_title = None
if "children" in bm_entry:
yield from iterate_children(entry_title, bm_entry['children'])
else:
# if any of the properties does not exist, bail out silently
LOGDBG("ff_json: No 'children' found in bookmark folder - skipping: {}".format(entry_title))
elif TypeCode.separator.value == typeCode:
# ignore separator
pass
else:
LOGDBG("ff_json: Unknown typeCode found : {}".format(typeCode))
if "children" in json:
main_entry_list = json['children']
else:
LOGDBG("ff_json: No children in Root entry found")
return []
yield from iterate_children(None, main_entry_list)
def import_xbel(html_soup: BeautifulSoup, add_parent_folder_as_tag: bool, newtag: str, use_nested_folder_structure: bool = False):
"""Parse bookmark XBEL.
Parameters
----------
html_soup : BeautifulSoup object
BeautifulSoup representation of bookmark HTML.
add_parent_folder_as_tag : bool
True if bookmark parent folders should be added as tags else False.
newtag : str
A new unique tag to add to imported bookmarks.
use_nested_folder_structure: bool
True if all bookmark parent folder should be added, not just direct parent else False
add_parent_folder_as_tag must be True for this flag to have an effect
Returns
-------
tuple
Parsed result.
"""
# compatibility
soup = html_soup
for tag in soup.find_all('bookmark'):
# Extract comment from tag
try:
if is_nongeneric_url(tag['href']):
continue
except KeyError:
continue
title_tag = tag.title.string
desc = None
comment_tag = tag.find_next_sibling('desc')
if comment_tag:
desc = comment_tag.find(text=True, recursive=False)
if add_parent_folder_as_tag:
# add parent folder as tag
if use_nested_folder_structure:
# New method that would generalize for else case to
# structure of folders
# folder
# title (folder name)
# folder
# title
# bookmark (could be h3, and continue recursively)
parents = tag.find_parents('folder')
for parent in parents:
header = parent.find_previous_sibling('title')
if header:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + header.text)
else:
tag['tags'] = header.text
else:
# could be its folder or not
possible_folder = tag.find_previous('title')
# get list of tags within that folder
tag_list = tag.parent.parent.find_parent('folder')
if ((possible_folder) and possible_folder.parent in list(tag_list.parents)):
# then it's the folder of this bookmark
if tag.has_attr('tags'):
tag['tags'] += (DELIM + possible_folder.text)
else:
tag['tags'] = possible_folder.text
# add unique tag if opted
if newtag:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + newtag)
else:
tag['tags'] = newtag
yield (
tag['href'], title_tag,
parse_tags([tag['tags']]) if tag.has_attr('tags') else None,
desc if not desc else desc.strip(), 0, True, False
)
def import_html(html_soup: BeautifulSoup, add_parent_folder_as_tag: bool, newtag: str, use_nested_folder_structure: bool = False):
"""Parse bookmark HTML.
Parameters
----------
html_soup : BeautifulSoup object
BeautifulSoup representation of bookmark HTML.
add_parent_folder_as_tag : bool
True if bookmark parent folders should be added as tags else False.
newtag : str
A new unique tag to add to imported bookmarks.
use_nested_folder_structure: bool
True if all bookmark parent folder should be added, not just direct parent else False
add_parent_folder_as_tag must be True for this flag to have an effect
Returns
-------
tuple
Parsed result.
"""
# compatibility
soup = html_soup
for tag in soup.find_all('a'):
# Extract comment from
tag
try:
if is_nongeneric_url(tag['href']):
continue
except KeyError:
continue
desc = None
comment_tag = tag.find_next_sibling('dd')
if comment_tag:
desc = comment_tag.find(string=True, recursive=False)
if add_parent_folder_as_tag:
# New method that would generalize for else case to
# structure of folders
# dt
# h3 (folder name)
# dl
# dt
# a (could be h3, and continue recursively)
parents = tag.find_parents('dl')
for parent in (parents if use_nested_folder_structure else parents[:1]):
header = parent.find_previous_sibling('h3')
if header:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + strip_delim(header.text))
else:
tag['tags'] = strip_delim(header.text)
# add unique tag if opted
if newtag:
if tag.has_attr('tags'):
tag['tags'] += (DELIM + strip_delim(newtag))
else:
tag['tags'] = strip_delim(newtag)
yield (
tag['href'], tag.string,
parse_tags([tag['tags']]) if tag.has_attr('tags') else None,
desc if not desc else desc.strip(), 0, True, False
)
def get_netloc(url):
"""Get the netloc token, or None."""
try:
parsed = urlparse(url)
netloc = parsed.netloc
if not netloc and (not parsed.scheme or re.match(r'^\d+($|/)', parsed.path)):
# Try to prepend '//' and get netloc
netloc = urlparse('//' + url).netloc
return netloc or None
except Exception as e:
LOGERR('%s, URL: %s', e, url)
return None
def is_bad_url(url, *, permit_ip=True):
"""Check if URL is malformed.
.. Note:: This API is not bulletproof but works in most cases.
Parameters
----------
url : str
URL to scan.
permit_ip : bool
Determines whether IPs are permitted as hostnames (True by default).
Returns
-------
bool
True if URL is malformed, False otherwise.
"""
netloc = get_netloc(url)
if not netloc:
return True
hostname, port = re.match(r'^(.*?)(:(\d+))?$', netloc).group(1, 3)
LOGDBG('netloc: %s, hostname: %s', netloc, hostname)
# valid hostnames cannot be dotted-decimals (RFC-1123)
if not permit_ip and all(s.isdigit() for s in hostname.split('.')):
return True
# netloc cannot start or end with a '.'
if hostname.startswith('.') or hostname.endswith('.'):
return True
# netloc should have at least one '.'
return '.' not in hostname
def is_nongeneric_url(url):
"""Returns True for URLs which are non-http and non-generic.
Parameters
----------
url : str
URL to scan.
Returns
-------
bool
True if URL is a non-generic URL, False otherwise.
"""
ignored_prefix = [
'about:',
'apt:',
'chrome://',
'file://',
'place:',
'vivaldi://',
]
for prefix in ignored_prefix:
if url.startswith(prefix):
return True
return False
def is_ignored_mime(url):
"""Check if URL links to ignored MIME.
.. Note:: Only a 'HEAD' request is made for these URLs.
Parameters
----------
url : str
URL to scan.
Returns
-------
bool
True if URL links to ignored MIME, False otherwise.
"""
for mime in SKIP_MIMES:
if url.lower().endswith(mime):
LOGDBG('matched MIME: %s', mime)
return True
return False
def is_unusual_tag(tagstr):
"""Identify unusual tags with word to comma ratio > 3.
Parameters
----------
tagstr : str
tag string to check.
Returns
-------
bool
True if valid tag else False.
"""
if not tagstr:
return False
nwords = len(tagstr.split())
ncommas = tagstr.count(',') + 1
if nwords / ncommas > 3:
return True
return False
def parse_decoded_page(page):
"""Fetch title, description and keywords from decoded HTML page.
Parameters
----------
page : str
Decoded HTML page.
Returns
-------
tuple
(title, description, keywords).
"""
title = ''
desc = ''
keys = ''
soup = BeautifulSoup(page, 'html5lib')
try:
title = soup.find('title').text.strip().replace('\n', ' ')
if title:
title = re.sub(r'\s{2,}', ' ', title)
except Exception as e:
LOGDBG(e)
description = (soup.find('meta', attrs={'name':'description'}) or
soup.find('meta', attrs={'name':'Description'}) or
soup.find('meta', attrs={'property':'description'}) or
soup.find('meta', attrs={'property':'Description'}) or
soup.find('meta', attrs={'name':'og:description'}) or
soup.find('meta', attrs={'name':'og:Description'}) or
soup.find('meta', attrs={'property':'og:description'}) or
soup.find('meta', attrs={'property':'og:Description'}))
try:
if description:
desc = description.get('content').strip()
if desc:
desc = re.sub(r'\s{2,}', ' ', desc)
except Exception as e:
LOGDBG(e)
keywords = (soup.find('meta', attrs={'name':'keywords'}) or
soup.find('meta', attrs={'name':'Keywords'}))
try:
if keywords:
keys = keywords.get('content').strip().replace('\n', ' ')
keys = re.sub(r'\s{2,}', ' ', re.sub(r'\s*,\s*', ',', keys))
if is_unusual_tag(keys):
if keys not in (title, desc):
LOGDBG('keywords to description: %s', keys)
if desc:
desc = desc + '\n## ' + keys
else:
desc = '* ' + keys
keys = ''
except Exception as e:
LOGDBG(e)
LOGDBG('title: %s', title)
LOGDBG('desc : %s', desc)
LOGDBG('keys : %s', keys)
return (title, desc, keys and keys.strip(DELIM))
def get_data_from_page(resp):
"""Detect HTTP response encoding and invoke parser with decoded data.
Parameters
----------
resp : HTTP response
Response from GET request.
Returns
-------
tuple
(title, description, keywords).
"""
try:
charset = EncodingDetector.find_declared_encoding(resp.data, is_html=True)
if not charset and 'content-type' in resp.headers:
m = email.message.Message()
m['content-type'] = resp.headers['content-type']
if m.get_param('charset') is not None:
charset = m.get_param('charset')
if charset:
LOGDBG('charset: %s', charset)
title, desc, keywords = parse_decoded_page(resp.data.decode(charset, errors='replace'))
else:
title, desc, keywords = parse_decoded_page(resp.data.decode(errors='replace'))
return (title, desc, keywords)
except Exception as e:
LOGERR(e)
return (None, None, None)
def extract_auth(url):
"""Convert an url into an (auth, url) tuple [the returned URL will contain no auth part]."""
_url = urlparse(url)
if _url.username is None: # no '@' in netloc
return None, url
auth = _url.username + ('' if _url.password is None else f':{_url.password}')
return auth, url.replace(auth + '@', '')
def gen_headers():
"""Generate headers for network connection."""
global MYHEADERS, MYPROXY
MYHEADERS = {
'Accept-Encoding': 'gzip,deflate',
'User-Agent': USER_AGENT,
'Sec-Fetch-Mode': 'navigate',
'Accept': '*/*',
'Cookie': '',
'DNT': '1'
}
MYPROXY = os.environ.get('https_proxy')
if MYPROXY:
try:
auth, MYPROXY = extract_auth(MYPROXY)
except Exception as e:
LOGERR(e)
return
# Strip username and password (if present) and update headers
if auth:
auth_headers = make_headers(basic_auth=auth)
MYHEADERS.update(auth_headers)
LOGDBG('proxy: [%s]', MYPROXY)
def get_PoolManager():
"""Creates a pool manager with proxy support, if applicable.
Returns
-------
ProxyManager or PoolManager
ProxyManager if https_proxy is defined, PoolManager otherwise.
"""
ca_certs = os.getenv('BUKU_CA_CERTS', default=CA_CERTS)
if MYPROXY:
return urllib3.ProxyManager(MYPROXY, num_pools=1, headers=MYHEADERS, timeout=15,
cert_reqs='CERT_REQUIRED', ca_certs=ca_certs)
return urllib3.PoolManager(
num_pools=1,
headers=MYHEADERS,
timeout=15,
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs)
def network_handler(
url: str,
http_head: bool = False
) -> Tuple[str, str, str, int, int]:
"""Handle server connection and redirections.
Deprecated; use fetch_data() instead.
Returns
-------
tuple
(title, description, tags, recognized mime, bad url)
"""
warn('\'buku.network_handler()\' is deprecated; use \'buku.fetch_data()\' instead.', DeprecationWarning)
result = fetch_data(url, http_head)
return (result.title, result.desc, result.keywords, int(result.mime), int(result.bad))
def fetch_data(
url: str,
http_head: bool = False,
permit_ip: bool = True,
) -> FetchResult:
"""Handle server connection and redirections.
Parameters
----------
url : str
URL to fetch.
http_head : bool
If True, send only HTTP HEAD request. Default is False.
permit_ip : bool
Determines whether IPs are permitted as hostnames (True by default).
Returns
-------
FetchResult
(url, title, desc, keywords, mime, bad, fetch_status)
"""
page_status = None
page_url = url
page_title = ''
page_desc = ''
page_keys = ''
exception = False
if is_nongeneric_url(url) or is_bad_url(url, permit_ip=permit_ip):
return FetchResult(url, bad=True)
if is_ignored_mime(url) or http_head:
method = 'HEAD'
else:
method = 'GET'
if not MYHEADERS:
gen_headers()
try:
manager = get_PoolManager()
while True:
resp = manager.request(method, url, retries=Retry(redirect=10))
page_status = resp.status
if resp.status == 200:
if method == 'GET':
for retry in resp.retries.history:
if retry.status not in PERMANENT_REDIRECTS:
break
page_status, page_url = retry.status, retry.redirect_location
page_title, page_desc, page_keys = get_data_from_page(resp)
elif resp.status == 403 and url.endswith('/'):
# HTTP response Forbidden
# Handle URLs in the form of https://www.domain.com/
# which fail when trying to fetch resource '/'
# retry without trailing '/'
LOGDBG('Received status 403: retrying...')
# Remove trailing /
url = url[:-1]
resp.close()
continue
else:
page_title, page_desc, page_keys = get_data_from_page(resp)
LOGERR('[%s] %s', resp.status, resp.reason)
if resp:
resp.close()
break
except Exception as e:
LOGERR('fetch_data(): %s', e)
exception = True
if manager:
manager.clear()
if exception:
return FetchResult(url)
if method == 'HEAD':
return FetchResult(url, mime=True, fetch_status=page_status)
return FetchResult(page_url, title=page_title, desc=page_desc, keywords=page_keys, fetch_status=page_status)
def parse_tags(keywords=[], *, edit_input=False):
"""Format and get tag string from tokens.
Parameters
----------
keywords : list
List of tags to parse. Default is empty list.
edit_input : bool
Whether the taglist is an edit input (i.e. may start with '+'/'-').
Defaults to False.
Returns
-------
str
Comma-delimited string of tags.
DELIM : str
If no keywords, returns the delimiter.
None
If keywords is None.
"""
if keywords is None:
return None
tagstr = ' '.join(s for s in keywords if s)
if not tagstr:
return DELIM
if edit_input and keywords[0] in ('+', '-'):
return keywords[0] + parse_tags(keywords[1:])
# Cleanse and get the tags
marker = tagstr.find(DELIM)
tags = DELIM
while marker >= 0:
token = tagstr[0:marker]
tagstr = tagstr[marker + 1:]
marker = tagstr.find(DELIM)
token = token.strip()
if token == '':
continue
tags += token + DELIM
tagstr = tagstr.strip()
if tagstr != '':
tags += tagstr + DELIM
LOGDBG('keywords: %s', keywords)
LOGDBG('parsed tags: [%s]', tags)
if tags == DELIM:
return tags
# sorted unique tags in lowercase, wrapped with delimiter
return taglist_str(tags)
def prep_tag_search(tags: str) -> Tuple[List[str], Optional[str], Optional[str]]:
"""Prepare list of tags to search and determine search operator.
Parameters
----------
tags : str
String list of tags to search.
Returns
-------
tuple
(list of formatted tags to search,
a string indicating query search operator (either OR or AND),
a regex string of tags or None if ' - ' delimiter not in tags).
"""
exclude_only = False
# tags may begin with `- ` if only exclusion list is provided
if tags.startswith('- '):
tags = ' ' + tags
exclude_only = True
# tags may start with `+ ` etc., tricky test case
if tags.startswith(('+ ', ', ')):
tags = tags[2:]
# tags may end with ` -` etc., tricky test case
if tags.endswith((' -', ' +', ' ,')):
tags = tags[:-2]
# tag exclusion list can be separated by comma (,), so split it first
excluded_tags = None
if ' - ' in tags:
tags, excluded_tags = tags.split(' - ', 1)
excluded_taglist = [delim_wrap(re.escape(t.strip())) for t in excluded_tags.split(',')]
# join with pipe to construct regex string
excluded_tags = '|'.join(excluded_taglist)
if exclude_only:
search_operator = 'OR'
tags_ = ['']
else:
# do not allow combination of search logics in tag inclusion list
if ' + ' in tags and ',' in tags:
return [], None, None
search_operator = 'OR'
tag_delim = ','
if ' + ' in tags:
search_operator = 'AND'
tag_delim = ' + '
tags_ = [delim_wrap(t.strip()) for t in tags.split(tag_delim)]
return tags_, search_operator, excluded_tags
def gen_auto_tag():
"""Generate a tag in Year-Month-Date format.
Returns
-------
str
New tag as YYYYMonDD.
"""
t = time.localtime()
return '%d%s%02d' % (t.tm_year, calendar.month_abbr[t.tm_mon], t.tm_mday)
def edit_at_prompt(obj, nav, suggest=False):
"""Edit and add or update a bookmark.
Parameters
----------
obj : BukuDb instance
A valid instance of BukuDb class.
nav : str
Navigation command argument passed at prompt by user.
suggest : bool
If True, suggest similar tags on new bookmark addition.
"""
if nav == 'w':
editor = get_system_editor()
if not is_editor_valid(editor):
return
elif is_int(nav[2:]):
obj.edit_update_rec(int(nav[2:]))
return
else:
editor = nav[2:]
result = edit_rec(editor, '', None, DELIM, None)
if result is not None:
url, title, tags, desc = result
if suggest:
tags = obj.suggest_similar_tag(tags)
obj.add_rec(url, title, tags, desc)
def show_taglist(obj):
"""Additional prompt to show unique tag list.
Parameters
----------
obj : BukuDb instance
A valid instance of BukuDb class.
"""
unique_tags, dic = obj.get_tag_all()
if not unique_tags:
count = 0
print('0 tags')
else:
count = 1
for tag in unique_tags:
print('%6d. %s (%d)' % (count, tag, dic[tag]))
count += 1
print()
def dbs_list(dir: Optional[str] = None, current: Optional[str] = None, bdb: Optional[BukuDb] = None) -> list[DbFile]:
"""Produces a list of DBs in specified path; can be invoked with a BukuDb instance or with a DB path.
Parameters
----------
dir : str, optional
Location for databases search (falls back to bdb location, then to BukuDb.get_default_dbdir()).
current : str, optional
Filename of current database, without path (for populating is_current).
bdb : BukuDb instance, optional
A valid instance of BukuDb class. Used to extract DB dir/file if the former is not supplied.
Returns
-------
list[DbFile]
List of sorted DB files in specified/derived directory.
"""
if not dir:
dir, current = bdb.db_dir_and_file if bdb else (BukuDb.get_default_dbdir(), current)
return sorted(DbFile.make(dir=dir, file=s, is_current=s == current)
for s in os.listdir(dir) if s.endswith('.db') and os.path.isfile(os.path.join(dir, s)))
def prompt(obj, results, noninteractive=False, deep=False, listtags=False, suggest=False, num=10, markers=False, order=['+id']):
"""Show each matching result from a search and prompt.
Parameters
----------
obj : BukuDb instance
A valid instance of BukuDb class.
results : list
Search result set from a DB query.
noninteractive : bool
If True, does not seek user input. Shows all results. Default is False.
deep : bool
Use deep search. Default is False.
markers : bool
Use search-with-markers. Default is False.
listtags : bool
If True, list all tags.
suggest : bool
If True, suggest similar tags on edit and add bookmark.
order : list of str
Order description (fields from JSON export or DB, prepended with '+'/'-' for ASC/DESC).
num : int
Number of results to show per page. Default is 10.
"""
if not isinstance(obj, BukuDb):
LOGERR('Not a BukuDb instance')
return
bdb = obj
new_results = bool(results)
nav = ''
cur_index = next_index = prev_index = 0
if listtags:
show_taglist(obj)
try:
columns, _ = os.get_terminal_size()
except OSError:
columns = 0
if noninteractive:
try:
for i, row in enumerate(results or []):
print_single_rec(row, i + 1, columns)
except Exception as e:
LOGERR(e)
return
skip_print = False
while True:
if (new_results or nav in ['n', 'N']) and not skip_print:
_total_results = len(results or [])
cur_index = next_index # used elsewhere as "most recent page start index"
if not results:
print('0 results')
new_results = False
elif cur_index >= _total_results and nav != 'N':
print('No more results')
new_results = False
else:
if nav == 'N':
cur_index = min(cur_index, prev_index)
prev_index = max(0, cur_index - num)
next_index = min(cur_index + num, _total_results)
print()
for i in range(cur_index, next_index):
print_single_rec(results[i], i + 1, columns)
print('%d-%d/%d' % (cur_index + 1, next_index, _total_results))
skip_print = False
try:
prompt_suffix = ''
if bdb.dbfile != os.path.realpath(os.path.join(BukuDb.get_default_dbdir(), 'bookmarks.db')):
prompt_suffix = (f'[{bdb.dbname}] ' if not bdb.colorize else
f'\001\x1b[7m\002[{bdb.dbname}]\001\x1b[0m\002 ')
nav = read_in(PROMPTMSG + prompt_suffix)
if not nav:
nav = read_in(PROMPTMSG + prompt_suffix)
if not nav:
# Quit on double enter
break
nav = nav.strip()
except EOFError:
return
# show the next set of results from previous search
if nav in ('n', 'N'):
continue
if (m := re.match(r'^R(?: (-)?([0-9]+))?$', nav.rstrip())) and (n := int(m[2] or 1)) > 0:
skip_print = True
if results and not m[1]: # from search results
picked = random.sample(results, min(n, len(results)))
else: # from all bookmarks
ids = range(1, 1 + (bdb.get_max_id() or 0))
picked = bdb.get_rec_all_by_ids(random.sample(ids, min(n, len(ids))))
for row in bdb._sort(picked, order):
print_single_rec(row, columns=columns)
continue
if (m := re.match(r'^\^ ([1-9][0-9]*) ([1-9][0-9]*)$', nav.rstrip())):
index1, index2 = map(int, m.group(1, 2))
if bdb.swap_recs(index1, index2):
bdb.print_rec({index1, index2})
else:
print('Failed to swap records #%d and #%d' % (index1, index2))
continue
# search ANY match with new keywords
if nav.startswith('s '):
keywords = (nav[2:].split() if not markers else split_by_marker(nav[2:]))
results = bdb.searchdb(keywords, deep=deep, markers=markers, order=order)
new_results = True
cur_index = next_index = 0
continue
# search ALL match with new keywords
if nav.startswith('S '):
keywords = (nav[2:].split() if not markers else split_by_marker(nav[2:]))
results = bdb.searchdb(keywords, all_keywords=True, deep=deep, markers=markers, order=order)
new_results = True
cur_index = next_index = 0
continue
# regular expressions search with new keywords
if nav.startswith('r '):
keywords = (nav[2:].split() if not markers else split_by_marker(nav[2:]))
results = bdb.searchdb(keywords, all_keywords=True, regex=True, markers=markers, order=order)
new_results = True
cur_index = next_index = 0
continue
# tag search with new keywords
if nav.startswith('t '):
results = bdb.search_by_tag(nav[2:], order=order)
new_results = True
cur_index = next_index = 0
continue
# quit with 'q'
if nav == 'q':
return
# No new results fetched beyond this point
new_results = False
# toggle deep search with 'd', search-with-markers with 'm'
if nav == 'd':
deep = not deep
print('deep search', ('on' if deep else 'off'))
continue
if nav == 'm':
markers = not markers
print('search-with-markers', ('on' if markers else 'off'))
continue
if re.match(r'v!? ', nav): # letters 's' and 'o' are taken already
_fields = {'metadata': 'title', **JSON_FIELDS}
_order = bdb._ordering(filter(None, re.split(r'[,\s]+', nav[2:].strip())))
order_ = [('+' if asc else '-') + _fields.get(s, s) for s, asc in _order]
if nav.startswith('v! '):
bdb.reorder(order_)
print('Reindexed bookmarks to match order:', ', '.join(order_))
else:
order = order_
print('order', ', '.join(order))
if results:
results = bdb._sort(results, order)
cur_index = next_index = 0
continue
# Toggle GUI browser with 'O'
if nav == 'O':
browse.override_text_browser = not browse.override_text_browser
print('text browser override toggled')
continue
# Show help with '?'
if nav == '?':
ExtendedArgumentParser.prompt_help(sys.stdout)
continue
# Edit and add or update
if nav == 'w' or nav.startswith('w '):
edit_at_prompt(bdb, nav, suggest)
continue
# Append or overwrite tags
if nav.startswith('g '):
unique_tags, dic = obj.get_tag_all()
_count = bdb.set_tag(nav[2:], unique_tags)
if _count == -1:
print('Invalid input')
elif _count == -2:
try:
tagid_list = nav[2:].split()
tagstr = bdb.get_tagstr_from_taglist(tagid_list, unique_tags)
tagstr = tagstr.strip(DELIM)
results = bdb.search_by_tag(tagstr)
new_results = True
cur_index = next_index = 0
except Exception:
print('Invalid input')
else:
print('%d updated' % _count)
continue
# Print bookmarks by DB index
if nav.startswith('p '):
try:
ids = parse_range(nav[2:].split(), maxidx=bdb.get_max_id() or 0)
ids and bdb.print_rec(ids, order=order)
except ValueError:
print('Invalid input')
continue
# Browse bookmarks by DB index
if nav.startswith('o '):
id_list = nav[2:].split()
try:
for id in id_list:
if is_int(id):
bdb.browse_by_index(int(id))
elif '-' in id:
vals = [int(x) for x in id.split('-')]
bdb.browse_by_index(0, vals[0], vals[-1], True)
else:
print('Invalid input')
except ValueError:
print('Invalid input')
continue
# Copy URL to clipboard
if nav.startswith('c ') and nav[2:].isdigit():
index = int(nav[2:]) - 1
if index < 0 or index >= next_index:
print('No matching index')
continue
copy_to_clipboard(content=results[index + cur_index][1].encode('utf-8'))
continue
# open all results and re-prompt with 'a'
if nav == 'a':
for index in range(cur_index, next_index):
browse(results[index][1], bdb.default_scheme)
continue
# list tags with 't'
if nav == 't':
show_taglist(bdb)
continue
if (nav+' ').startswith('DB '):
dbpath, dbfile = bdb.db_dir_and_file
if nav == 'DB':
print(f'Available DB files (in {dbpath}):')
for db in dbs_list(dbpath, dbfile):
print(('*' if db.is_current else ' '), db.name)
else:
s = os.path.expanduser(re.sub(r'^DB\s+', '', nav))
path = os.path.join(dbpath, (s if s != '~.' else BukuDb.get_default_dbdir())) # relative to current dir
newpath, newfile = os.path.split(path+'/' if os.path.isdir(path) else path)
newfile, (_, ext) = newfile or 'bookmarks', os.path.splitext(newfile)
if not ext or os.path.isfile(os.path.join(newpath, newfile+'.db')):
newfile += '.db'
newdb = os.path.join(newpath, newfile)
try:
if not os.path.exists(newdb):
print(f'DB file is being created at {newdb}')
_bdb = bdb
bdb = BukuDb(json=bdb.json, field_filter=bdb.field_filter, colorize=bdb.colorize, dbfile=newdb,
default_scheme=bdb.default_scheme)
_bdb.close()
results, new_results = [], False
cur_index = next_index = prev_index = 0
print(f'Loaded DB file at {bdb.dbfile}')
except Exception:
print(f'Failed to open DB file at {newdb}')
continue
toggled = False
# Open in GUI browser
if nav.startswith('O '):
if not browse.override_text_browser:
browse.override_text_browser = True
toggled = True
nav = nav[2:]
# iterate over white-space separated indices
for nav in nav.split():
if is_int(nav):
index = int(nav) - 1
if index < 0 or index >= next_index:
print('No matching index %s' % nav)
continue
browse(results[index][1], bdb.default_scheme)
elif '-' in nav:
try:
vals = [int(x) for x in nav.split('-')]
if vals[0] > vals[-1]:
vals[0], vals[-1] = vals[-1], vals[0]
for _id in range(vals[0]-1, vals[-1]):
if 0 <= _id < next_index:
browse(results[_id][1], bdb.default_scheme)
else:
print('No matching index %d' % (_id + 1))
except ValueError:
print('Invalid input')
break
else:
print('Invalid input')
break
if toggled:
browse.override_text_browser = False
def copy_to_clipboard(content):
"""Copy content to clipboard
Parameters
----------
content : str
Content to be copied to clipboard
"""
# try copying the url to clipboard using native utilities
copier_params = []
if sys.platform.startswith(('linux', 'freebsd', 'openbsd')):
if shutil.which('xsel') is not None:
copier_params = ['xsel', '-b', '-i']
elif shutil.which('xclip') is not None:
copier_params = ['xclip', '-selection', 'clipboard']
elif shutil.which('wl-copy') is not None:
copier_params = ['wl-copy']
# If we're using Termux (Android) use its 'termux-api'
# add-on to set device clipboard.
elif shutil.which('termux-clipboard-set') is not None:
copier_params = ['termux-clipboard-set']
elif sys.platform == 'darwin':
copier_params = ['pbcopy']
elif sys.platform == 'win32':
copier_params = ['clip']
if copier_params:
Popen(copier_params, stdin=PIPE, stdout=DEVNULL, stderr=DEVNULL).communicate(content)
return
# If native clipboard utilities are absent, try to use terminal multiplexers
# tmux
if os.getenv('TMUX_PANE'):
copier_params = ['tmux', 'set-buffer']
Popen(
copier_params + [content],
stdin=DEVNULL,
stdout=DEVNULL,
stderr=DEVNULL
).communicate()
print('URL copied to tmux buffer.')
return
# GNU Screen paste buffer
if os.getenv('STY'):
copier_params = ['screen', '-X', 'readbuf', '-e', 'utf8']
tmpfd, tmppath = tempfile.mkstemp()
try:
with os.fdopen(tmpfd, 'wb') as fp:
fp.write(content)
copier_params.append(tmppath)
Popen(copier_params, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL).communicate()
finally:
os.unlink(tmppath)
return
print('Failed to locate suitable clipboard utility')
return
def print_rec_with_filter(records, field_filter=0):
"""Print records filtered by field.
User determines which fields in the records to display
by using the --format option.
Parameters
----------
records : list or sqlite3.Cursor object
List of bookmark records to print
field_filter : int
Integer indicating which fields to print. Default is 0 ("all fields").
"""
try:
records = bookmark_vars(records)
fields = FIELD_FILTER.get(field_filter)
if fields:
pattern = '\t'.join('%s' for k in fields)
for row in records:
print(pattern % tuple(getattr(row, k) for k in fields))
else:
try:
columns, _ = os.get_terminal_size()
except OSError:
columns = 0
for row in records:
print_single_rec(row, columns=columns)
except BrokenPipeError:
sys.stdout = os.fdopen(1)
sys.exit(1)
def print_single_rec(row: BookmarkVar, idx: int=0, columns: int=0): # NOQA
"""Print a single DB record.
Handles both search results and individual record.
Parameters
----------
row : tuple
Tuple representing bookmark record data.
idx : int
Search result index. If 0, print with DB index.
Default is 0.
columns : int
Number of columns to wrap comments to.
Default is 0.
"""
str_list = []
row = BookmarkVar(*row) # ensuring named tuple
# Start with index and title
if idx != 0:
id_title_res = ID_STR % (idx, row.title or 'Untitled', row.id)
else:
id_title_res = ID_DB_STR % (row.id, row.title or 'Untitled')
# Indicate if record is immutable
if row.immutable:
id_title_res = MUTE_STR % (id_title_res,)
else:
id_title_res += '\n'
try:
print(id_title_res, end='')
print(URL_STR % (row.url,), end='')
if columns == 0:
if row.desc:
print(DESC_STR % (row.desc,), end='')
if row.tags:
print(TAG_STR % (row.tags,), end='')
print()
return
INDENT = 5
fillwidth = columns - INDENT
desc_lines = [s for line in row.desc.splitlines()
for s in textwrap.wrap(line, width=fillwidth) or ['']]
TR = str.maketrans(',-', '-,') # we want breaks after commas rather than hyphens
tag_lines = textwrap.wrap(row.tags.translate(TR), width=fillwidth)
for idx, line in enumerate(desc_lines):
if idx == 0:
print(DESC_STR % line, end='')
else:
print(DESC_WRAP % (' ' * INDENT, line))
for idx, line in enumerate(tag_lines):
if idx == 0:
print(TAG_STR % line.translate(TR), end='')
else:
print(TAG_WRAP % (' ' * INDENT, line.translate(TR)))
print()
except UnicodeEncodeError:
str_list = []
str_list.append(id_title_res)
str_list.append(URL_STR % (row.url,))
if row.desc:
str_list.append(DESC_STR % (row.desc,))
if row.tags:
str_list.append(TAG_STR % (row.tags,))
sys.stdout.buffer.write((''.join(str_list) + '\n').encode('utf-8'))
except BrokenPipeError:
sys.stdout = os.fdopen(1)
sys.exit(1)
def write_string_to_file(content: str, filepath: str):
"""Writes given content to file
Parameters
----------
content : str
filepath : str
Returns
-------
None
"""
try:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
LOGERR(e)
def format_json(resultset, single_record=False, field_filter=0):
"""Return results in JSON format.
Parameters
----------
resultset : list
Search results from DB query.
single_record : bool
If True, indicates only one record. Default is False.
field_filter : int
Indicates format for displaying bookmarks. Default is 0 ("all fields").
Returns
-------
json
Record(s) in JSON format.
"""
resultset = bookmark_vars(resultset)
fields = [(k, JSON_FIELDS.get(k, k)) for k in FIELD_FILTER.get(field_filter, ALL_FIELDS)]
marks = [{field: getattr(row, k) for k, field in fields} for row in resultset]
if single_record:
marks = marks[-1] if marks else {}
return json.dumps(marks, sort_keys=True, indent=4)
def print_json_safe(resultset, single_record=False, field_filter=0):
"""Prints json results and handles IOError
Parameters
----------
resultset : list
Search results from DB query.
single_record : bool
If True, indicates only one record. Default is False.
field_filter : int
Indicates format for displaying bookmarks. Default is 0 ("all fields").
Returns
-------
None
"""
try:
print(format_json(resultset, single_record, field_filter))
except IOError:
try:
sys.stdout.close()
except IOError:
pass
try:
sys.stderr.close()
except IOError:
pass
def is_int(string):
"""Check if a string is a digit.
string : str
Input string to check.
Returns
-------
bool
True on success, False on exception.
"""
try:
int(string)
return True
except Exception:
return False
def browse(url, default_scheme=SCHEME_HTTP):
"""Duplicate stdin, stdout and open URL in default browser.
.. Note:: Duplicates stdin and stdout in order to
suppress showing errors on the terminal.
Parameters
----------
url : str
URL to open in browser.
default_scheme : str
Scheme to assume if missing from URL. Default is http.
Attributes
----------
suppress_browser_output : bool
True if a text based browser is detected.
Must be initialized (as applicable) to use the API.
override_text_browser : bool
If True, tries to open links in a GUI based browser.
"""
if not urlparse(url).scheme:
# Prefix with default_scheme to improve the chance
# it will open properly in the browser
LOGERR('Scheme missing in URI, trying %s', default_scheme)
url = f'{default_scheme}://{url}'
browser = webbrowser.get()
if browse.override_text_browser:
browser_output = browse.suppress_browser_output
for name in [b for b in webbrowser._tryorder if b not in TEXT_BROWSERS]:
browser = webbrowser.get(name)
LOGDBG(browser)
# Found a GUI browser, suppress browser output
browse.suppress_browser_output = True
break
if sys.platform == 'win32': # GUI apps have no terminal IO on Windows
browse.suppress_browser_output = False
if browse.suppress_browser_output:
_stderr = os.dup(2)
os.close(2)
_stdout = os.dup(1)
if "microsoft" not in platform.uname()[3].lower():
os.close(1)
fd = os.open(os.devnull, os.O_RDWR)
os.dup2(fd, 2)
os.dup2(fd, 1)
try:
if sys.platform != 'win32':
browser.open(url, new=2)
else:
# On Windows, the webbrowser module does not fork.
# Use threads instead.
def browserthread():
webbrowser.open(url, new=2)
t = threading.Thread(target=browserthread)
t.start()
except Exception as e:
LOGERR('browse(): %s', e)
finally:
if browse.suppress_browser_output:
os.close(fd)
os.dup2(_stderr, 2)
os.dup2(_stdout, 1)
if browse.override_text_browser:
browse.suppress_browser_output = browser_output
def check_upstream_release():
"""Check and report the latest upstream release version."""
if MYPROXY is None:
gen_headers()
ca_certs = os.getenv('BUKU_CA_CERTS', default=CA_CERTS)
if MYPROXY:
manager = urllib3.ProxyManager(
MYPROXY,
num_pools=1,
headers=MYHEADERS,
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs
)
else:
manager = urllib3.PoolManager(num_pools=1,
headers={'User-Agent': USER_AGENT},
cert_reqs='CERT_REQUIRED',
ca_certs=ca_certs)
try:
r = manager.request(
'GET',
'https://api.github.com/repos/jarun/buku/releases?per_page=1',
headers={'User-Agent': USER_AGENT}
)
except Exception as e:
LOGERR(e)
return
if r.status == 200:
latest = json.loads(r.data.decode(errors='replace'))[0]['tag_name']
if latest == 'v' + __version__:
print('This is the latest release')
else:
print('Latest upstream release is %s' % latest)
else:
LOGERR('[%s] %s', r.status, r.reason)
manager.clear()
def regexp(expr, item):
"""Perform a regular expression search.
Parameters
----------
expr : regex
Regular expression to search for.
item : str
Item on which to perform regex search.
Returns
-------
bool
True if result of search is not None, else False.
"""
if expr is None or item is None:
LOGDBG('expr: [%s], item: [%s]', expr, item)
return False
return re.search(expr, item, re.IGNORECASE) is not None
def delim_wrap(token):
"""Returns token string wrapped in delimiters.
Parameters
----------
token : str
String item to wrap with DELIM.
Returns
-------
str
Token string wrapped by DELIM.
"""
if token is None or token.strip() == '':
return DELIM
if token[0] != DELIM:
token = DELIM + token
if token[-1] != DELIM:
token = token + DELIM
return token
def read_in(msg):
"""A wrapper to handle input() with interrupts disabled.
Parameters
----------
msg : str
String to pass to to input().
"""
disable_sigint_handler()
message = None
try:
message = input(msg)
except KeyboardInterrupt:
print('Interrupted.')
enable_sigint_handler()
return message
def sigint_handler(signum, frame):
"""Custom SIGINT handler.
.. Note:: Neither signum nor frame are used in
this custom handler. However, they are
required parameters for signal handlers.
Parameters
----------
signum : int
Signal number.
frame : frame object or None.
"""
global INTERRUPTED
INTERRUPTED = True
print('\nInterrupted.', file=sys.stderr)
# Do a hard exit from here
os._exit(1)
DEFAULT_HANDLER = signal.signal(signal.SIGINT, sigint_handler)
def disable_sigint_handler():
"""Disable signint handler."""
signal.signal(signal.SIGINT, DEFAULT_HANDLER)
def enable_sigint_handler():
"""Enable sigint handler."""
signal.signal(signal.SIGINT, sigint_handler)
# ---------------------
# Editor mode functions
# ---------------------
def get_system_editor():
"""Returns default system editor is $EDITOR is set."""
return os.environ.get('EDITOR', 'none')
def is_editor_valid(editor):
"""Check if the editor string is valid.
Parameters
----------
editor : str
Editor string.
Returns
-------
bool
True if string is valid, else False.
"""
if editor == 'none':
LOGERR('EDITOR is not set')
return False
if editor == '0':
LOGERR('Cannot edit index 0')
return False
return True
def to_temp_file_content(url, title_in, tags_in, desc):
"""Generate temporary file content string.
Parameters
----------
url : str
URL to open.
title_in : str
Title to add manually.
tags_in : str
Comma-separated tags to add manually.
desc : str
String description.
Returns
-------
str
Lines as newline separated string.
Raises
------
AttributeError
when tags_in is None.
"""
strings = [('# Lines beginning with "#" will be stripped.\n'
'# Add URL in next line (single line).'), ]
# URL
if url is not None:
strings += (url,)
# TITLE
strings += (('# Add TITLE in next line (single line). '
'Leave blank to web fetch, "-" for no title.'),)
if title_in is None:
title_in = ''
elif title_in == '':
title_in = '-'
strings += (title_in,)
# TAGS
strings += ('# Add comma-separated TAGS in next line (single line).',)
strings += [(tags_in or '').strip(DELIM)]
# DESC
strings += ('# Add COMMENTS in next line(s). Leave blank to web fetch, "-" for no comments.',)
if desc is None:
strings += ('\n',)
elif desc == '':
strings += ('-',)
else:
strings += (desc,)
return '\n'.join(strings)
def parse_temp_file_content(content):
"""Parse and return temporary file content.
Parameters
----------
content : str
String of content.
Returns
-------
tuple
(url, title, tags, comments)
url: URL to open
title: string title to add manually
tags: string of comma-separated tags to add manually
comments: string description
"""
content = content.split('\n')
content = [c for c in content if not c or c[0] != '#']
if not content or content[0].strip() == '':
print('Edit aborted')
return None
url = content[0]
title = None
if len(content) > 1:
title = content[1]
if title == '':
title = None
elif title == '-':
title = ''
tags = DELIM
if len(content) > 2:
tags = parse_tags([content[2]])
comments = []
if len(content) > 3:
comments = list(content[3:])
# need to remove all empty line that are at the end
# and not those in the middle of the text
for i in range(len(comments) - 1, -1, -1):
if comments[i].strip() != '':
break
if i == -1:
comments = []
else:
comments = comments[0:i+1]
comments = '\n'.join(comments)
if comments == '':
comments = None
elif comments == '-':
comments = ''
return url, title, tags, comments
def edit_rec(editor, url, title_in, tags_in, desc):
"""Edit a bookmark record.
Parameters
----------
editor : str
Editor to open.
URL : str
URL to open.
title_in : str
Title to add manually.
tags_in : str
Comma-separated tags to add manually.
desc : str
Bookmark description.
Returns
-------
tuple
Parsed results from parse_temp_file_content().
"""
temp_file_content = to_temp_file_content(url, title_in, tags_in, desc)
fd, tmpfile = tempfile.mkstemp(prefix='buku-edit-')
os.close(fd)
try:
with open(tmpfile, 'w+', encoding='utf-8') as fp:
fp.write(temp_file_content)
fp.flush()
LOGDBG('Edited content written to %s', tmpfile)
cmd = editor.split(' ')
# on Windows, subprocess only finds .exe files by itself; this also finds e.g. code.cmd
cmd[0] = shutil.which(cmd[0]) or cmd[0]
cmd += (tmpfile,)
subprocess.call(cmd)
with open(tmpfile, 'r', encoding='utf-8') as f:
content = f.read()
os.remove(tmpfile)
except FileNotFoundError:
if os.path.exists(tmpfile):
os.remove(tmpfile)
LOGERR('Cannot open editor')
else:
LOGERR('Cannot open tempfile')
return None
parsed_content = parse_temp_file_content(content)
return parsed_content
def setup_logger(LOGGER):
"""Setup logger with color.
Parameters
----------
LOGGER : logger object
Logger to colorize.
"""
def decorate_emit(fn):
def new(*args):
levelno = args[0].levelno
if levelno == logging.DEBUG:
color = '\x1b[35m'
elif levelno == logging.ERROR:
color = '\x1b[31m'
elif levelno == logging.WARNING:
color = '\x1b[33m'
elif levelno == logging.INFO:
color = '\x1b[32m'
elif levelno == logging.CRITICAL:
color = '\x1b[31m'
else:
color = '\x1b[0m'
args[0].msg = '{}[{}]\x1b[0m {}'.format(color, args[0].levelname, args[0].msg)
return fn(*args)
return new
sh = logging.StreamHandler()
sh.emit = decorate_emit(sh.emit)
LOGGER.addHandler(sh)
def piped_input(argv, pipeargs=None):
"""Handle piped input.
Parameters
----------
pipeargs : str
"""
if not sys.stdin.isatty():
pipeargs += argv
print('buku: waiting for input (unexpected? try --nostdin)')
for s in sys.stdin:
pipeargs += s.split()
def setcolors(args):
"""Get colors from user and separate into 'result' list for use in arg.colors.
Parameters
----------
args : str
Color string.
"""
Colors = collections.namedtuple('Colors', ' ID_srch, ID_STR, URL_STR, DESC_STR, TAG_STR')
colors = Colors(*[COLORMAP[c] for c in args])
id_col = colors.ID_srch
id_str_col = colors.ID_STR
url_col = colors.URL_STR
desc_col = colors.DESC_STR
tag_col = colors.TAG_STR
result = [id_col, id_str_col, url_col, desc_col, tag_col]
return result
def unwrap(text):
"""Unwrap text."""
lines = text.split('\n')
result = ''
for i in range(len(lines) - 1):
result += lines[i]
if not lines[i]:
# Paragraph break
result += '\n\n'
elif lines[i + 1]:
# Next line is not paragraph break, add space
result += ' '
# Handle last line
result += lines[-1] if lines[-1] else '\n'
return result
def check_stdout_encoding():
"""Make sure stdout encoding is utf-8.
If not, print error message and instructions, then exit with
status 1.
This function is a no-op on win32 because encoding on win32 is
messy, and let's just hope for the best. /s
"""
if sys.platform == 'win32':
return
# Use codecs.lookup to resolve text encoding alias
encoding = codecs.lookup(sys.stdout.encoding).name
if encoding != 'utf-8':
locale_lang, locale_encoding = locale.getlocale()
if locale_lang is None:
locale_lang = ''
if locale_encoding is None:
locale_encoding = ''
ioencoding = os.getenv('PYTHONIOENCODING', 'not set')
sys.stderr.write(unwrap(textwrap.dedent("""\
stdout encoding '{encoding}' detected. ddgr requires utf-8 to
work properly. The wrong encoding may be due to a non-UTF-8
locale or an improper PYTHONIOENCODING. (For the record, your
locale language is {locale_lang} and locale encoding is
{locale_encoding}; your PYTHONIOENCODING is {ioencoding}.)
Please set a UTF-8 locale (e.g., en_US.UTF-8) or set
PYTHONIOENCODING to utf-8.
""".format(
encoding=encoding,
locale_lang=locale_lang,
locale_encoding=locale_encoding,
ioencoding=ioencoding,
))))
sys.exit(1)
def monkeypatch_textwrap_for_cjk():
"""Monkeypatch textwrap for CJK wide characters.
"""
try:
if textwrap.wrap.patched:
return
except AttributeError:
pass
psl_textwrap_wrap = textwrap.wrap
def textwrap_wrap(text, width=70, **kwargs):
width = max(width, 2)
# We first add a U+0000 after each East Asian Fullwidth or East
# Asian Wide character, then fill to width - 1 (so that if a NUL
# character ends up on a new line, we still have one last column
# to spare for the preceding wide character). Finally we strip
# all the NUL characters.
#
# East Asian Width: https://www.unicode.org/reports/tr11/
return [
line.replace('\0', '')
for line in psl_textwrap_wrap(
''.join(
ch + '\0' if unicodedata.east_asian_width(ch) in ('F', 'W') else ch
for ch in unicodedata.normalize('NFC', text)
),
width=width - 1,
**kwargs
)
]
def textwrap_fill(text, width=70, **kwargs):
return '\n'.join(textwrap_wrap(text, width=width, **kwargs))
textwrap.wrap = textwrap_wrap
textwrap.fill = textwrap_fill
textwrap.wrap.patched = True
textwrap.fill.patched = True
def parse_range(tokens: Optional[str | Values[str]],
valid: Optional[Callable[[int], bool]] = None,
maxidx: int = None) -> Optional[Set[int]]:
"""Convert a token or sequence/set of token into a set of indices.
Raises a ValueError on invalid token. Returns None if passed None as tokens.
Parameters
----------
tokens : str | str[] | str{}, optional
String(s) containing an index (#), or a range (#-#), or a comma-separated list thereof.
valid : (int) -> bool, optional
Additional check for invalid indices (default is None).
maxidx : int, optional
When specified, negative indices are valid and parsed as tail-ranges.
Returns
-------
Optional[Set[int]]
None if tokens is None, otherwise parsed indices as unordered set.
"""
if tokens is None:
return None
result = set()
for token in ([tokens] if isinstance(tokens, str) else tokens):
for idx in token.split(','):
if is_int(idx):
result |= ({int(idx)} if not idx.startswith('-') or maxidx is None else
set(range(maxidx, max(0, maxidx + int(idx)), -1)))
elif '-' in idx:
l, r = map(int, idx.split('-'))
if l > r:
l, r = r, l
if maxidx is not None:
r = min(r, maxidx)
result |= set(range(l, r + 1))
elif idx:
raise ValueError(f'Invalid token: {idx}')
if valid and any(not valid(idx) for idx in result):
raise ValueError('Not a valid range')
return result
# main starts here
def main(argv=sys.argv[1:], *, program_name=os.path.basename(sys.argv[0])):
"""Main."""
global ID_STR, ID_DB_STR, MUTE_STR, URL_STR, DESC_STR, DESC_WRAP, TAG_STR, TAG_WRAP, PROMPTMSG
# readline should not be loaded when buku is used as a library
import readline
if sys.platform == 'win32':
try:
import colorama
colorama.just_fix_windows_console() # noop on non-Windows systems
except ImportError:
pass
title_in = None
tags_in = None
desc_in = None
pipeargs = []
colorstr_env = os.getenv('BUKU_COLORS')
if argv == ['--db']:
for s in sorted(s for s in os.listdir(BukuDb.get_default_dbdir()) if s.endswith('.db')):
print(s.removesuffix('.db'))
return
if argv and argv[0] != '--nostdin':
try:
piped_input(argv, pipeargs)
except KeyboardInterrupt:
pass
# If piped input, set argument vector
if pipeargs:
argv = pipeargs
# Setup custom argument parser
argparser = ExtendedArgumentParser(
prog=program_name,
description='''Bookmark manager like a text-based mini-web.
POSITIONAL ARGUMENTS:
KEYWORD search keywords''',
formatter_class=argparse.RawTextHelpFormatter,
usage='''buku [OPTIONS] [KEYWORD [KEYWORD ...]]''',
add_help=False)
hide = argparse.SUPPRESS
argparser.add_argument('keywords', nargs='*', metavar='KEYWORD', help=hide)
# ---------------------
# GENERAL OPTIONS GROUP
# ---------------------
general_grp = argparser.add_argument_group(
title='GENERAL OPTIONS',
description=''' -a, --add URL [+|-] [tag, ...]
bookmark URL with comma-separated tags
(prepend tags with '+' or '-' to use fetched tags)
-u, --update [...] update fields of an existing bookmark
accepts indices and ranges
refresh title and desc if no edit options
if no arguments:
- update results when used with search
- otherwise refresh all titles and desc
-w, --write [editor|index]
edit and add a new bookmark in editor
else, edit bookmark at index in EDITOR
edit last bookmark, if index=-1
if no args, edit new bookmark in EDITOR
-d, --delete [...] remove bookmarks from DB
accepts indices or a single range
if no arguments:
- delete results when used with search
- otherwise delete all bookmarks
--retain-order prevents reordering after deleting a bookmark
-h, --help show this information and exit
-v, --version show the program version and exit''')
addarg = general_grp.add_argument
addarg('-a', '--add', nargs='+', help=hide)
addarg('-u', '--update', nargs='*', help=hide)
addarg('-w', '--write', nargs='?', const=get_system_editor(), help=hide)
addarg('-d', '--delete', nargs='*', help=hide)
addarg('--retain-order', action='store_true', default=False, help=hide)
addarg('-h', '--help', action='store_true', help=hide)
addarg('-v', '--version', action='version', version=__version__, help=hide)
# ------------------
# EDIT OPTIONS GROUP
# ------------------
edit_grp = argparser.add_argument_group(
title='EDIT OPTIONS',
description=''' --url keyword bookmark link
--tag [+|-] [...] comma-separated tags
clear bookmark tagset, if no arguments
'+' appends to, '-' removes from tagset
--title [...] bookmark title; if no arguments:
-a: do not set title, -u: clear title
-c, --comment [...] notes or description of the bookmark
clears description, if no arguments
--immutable N disable web-fetch during auto-refresh
N=0: mutable (default), N=1: immutable
--swap N M swap two records at specified indices''')
addarg = edit_grp.add_argument
addarg('--url', nargs=1, help=hide)
addarg('--tag', nargs='*', help=hide)
addarg('--title', nargs='*', help=hide)
addarg('-c', '--comment', nargs='*', help=hide)
addarg('--immutable', type=int, choices={0, 1}, help=hide)
addarg('--swap', nargs=2, type=int, help=hide)
_bool = lambda x: x if x is None else bool(x)
_immutable = lambda args: _bool(args.immutable)
# --------------------
# SEARCH OPTIONS GROUP
# --------------------
search_grp = argparser.add_argument_group(
title='SEARCH OPTIONS',
description=''' -s, --sany [...] find records with ANY matching keyword
this is the default search option
-S, --sall [...] find records matching ALL the keywords
special keywords -
"blank": entries with empty title/tag
"immutable": entries with locked title
--deep match substrings ('pen' matches 'opens')
--markers search for keywords in specific fields
based on (optional) prefix markers:
'.' - title, '>' - description, ':' - URL,
'#' - tags (comma-separated, PARTIAL matches)
'#,' - tags (comma-separated, EXACT matches)
'*' - any field (same as no prefix)
-r, --sreg expr run a regex search
-t, --stag [tag [,|+] ...] [- tag, ...]
search bookmarks by tags
use ',' to find entries matching ANY tag
use '+' to find entries matching ALL tags
excludes entries with tags after ' - '
list all tags, if no search keywords
-x, --exclude [...] omit records matching specified keywords
--random [N] output random bookmarks out of the selection (default 1)
--order fields [...] comma-separated list of fields to order the output by
(prepend with '+'/'-' to choose sort direction)''')
addarg = search_grp.add_argument
addarg('-s', '--sany', nargs='*', help=hide)
addarg('-S', '--sall', nargs='*', help=hide)
addarg('-r', '--sreg', nargs='*', help=hide)
addarg('--deep', action='store_true', help=hide)
addarg('--markers', action='store_true', help=hide)
addarg('-t', '--stag', nargs='*', help=hide)
addarg('-x', '--exclude', nargs='*', help=hide)
addarg('--random', nargs='?', type=int, const=1, help=hide)
addarg('--order', nargs='+', help=hide)
# ------------------------
# ENCRYPTION OPTIONS GROUP
# ------------------------
crypto_grp = argparser.add_argument_group(
title='ENCRYPTION OPTIONS',
description=''' -l, --lock [N] encrypt DB in N (default 8) # iterations
-k, --unlock [N] decrypt DB in N (default 8) # iterations''')
addarg = crypto_grp.add_argument
addarg('-k', '--unlock', nargs='?', type=int, const=8, help=hide)
addarg('-l', '--lock', nargs='?', type=int, const=8, help=hide)
# ----------------
# POWER TOYS GROUP
# ----------------
power_grp = argparser.add_argument_group(
title='POWER TOYS',
description=''' --ai auto-import bookmarks from web browsers
Firefox, Chrome, Chromium, Vivaldi, Vivaldi
Internal, Brave, Edge
(Firefox profile can be specified using
environment variable FIREFOX_PROFILE;
Firefox profiles directory can be specified using
environment variable FIREFOX_PROFILES_DIR)
-e, --export file export bookmarks to Firefox format HTML
export XBEL, if file ends with '.xbel'
export Markdown, if file ends with '.md'
format: [title](url)
export Orgfile, if file ends with '.org'
format: *[[url][title]] :tags:
export rss feed if file ends with '.rss'/'.atom'
export buku DB, if file ends with '.db'
combines with search results, if opted
-i, --import file import bookmarks from file
supports .html .xbel .json .md .org .rss .atom .db
(.json = Firefox backup; .db = another Buku DB)
-p, --print [...] show record details by indices, ranges
print all bookmarks, if no arguments
-n shows the last n results (like tail)
-f, --format N limit fields in -p or JSON search output
N=1: URL; N=2: URL, tag; N=3: title;
N=4: URL, title, tag; N=5: title, tag;
N0 (10, 20, 30, 40, 50) omits DB index
-j, --json [file] JSON formatted output for -p and search.
prints to stdout if argument missing.
otherwise writes to given file
--colors COLORS set output colors in five-letter string
--nc disable color output
-n, --count N show N results per page (default 10)
--np do not show the subprompt, run and exit
-o, --open [...] browse bookmarks by indices and ranges
open a random bookmark, if no arguments
--oa browse all search results immediately
--default-scheme S if scheme is missing from uri, assume S
when opening in browser (default http)
--replace old new replace old tag with new tag everywhere
delete old tag, if new tag not specified
--url-redirect when fetching an URL, use the resulting
URL from following *permanent* redirects
(when combined with --export, the old URL
is included as additional metadata)
--tag-redirect [tag] when fetching an URL that causes permanent
redirect, add a tag in specified pattern
(using 'http:{}' if not specified)
--tag-error [tag] when fetching an URL that causes an HTTP
error, add a tag in specified pattern
(using 'http:{}' if not specified)
--del-error [...] when fetching an URL causes any (given)
HTTP error, delete/do not add it
--export-on [...] export records affected by the above
options, including removed info
(requires --update and --export; specific
HTTP response filter can be provided)
--reorder order... update DB indices to match specified order
--cached index|URL browse a cached page from Wayback Machine
--offline add a bookmark without connecting to web
--suggest show similar tags when adding bookmarks
--tacit reduce verbosity, skip some confirmations
--nostdin do not wait for input (must be first arg)
--threads N max network connections in full refresh
default N=4, min N=1, max N=10
-V check latest upstream version available
-g, --debug show debug information and verbose logs''')
addarg = power_grp.add_argument
addarg('--ai', action='store_true', help=hide)
addarg('-e', '--export', nargs=1, help=hide)
addarg('-i', '--import', nargs=1, dest='importfile', help=hide)
addarg('-p', '--print', nargs='*', help=hide)
addarg('-f', '--format', type=int, default=0, choices={1, 2, 3, 4, 5, 10, 20, 30, 40, 50}, help=hide)
addarg('-j', '--json', nargs='?', default=None, const='', help=hide)
addarg('--colors', dest='colorstr', type=argparser.is_colorstr, metavar='COLORS', help=hide)
addarg('--nc', action='store_true', help=hide)
addarg('-n', '--count', nargs='?', const=10, type=int, default=0, help=hide)
addarg('--np', action='store_true', help=hide)
addarg('-o', '--open', nargs='*', help=hide)
addarg('--oa', action='store_true', help=hide)
addarg('--default-scheme', nargs=1, default=[SCHEME_HTTP], help=hide)
addarg('--replace', nargs='+', help=hide)
addarg('--url-redirect', action='store_true', help=hide)
addarg('--tag-redirect', nargs='?', const=True, default=False, help=hide)
addarg('--tag-error', nargs='?', const=True, default=False, help=hide)
addarg('--del-error', nargs='*', help=hide)
addarg('--export-on', nargs='*', help=hide)
addarg('--reorder', nargs='+', help=hide)
addarg('--cached', nargs=1, help=hide)
addarg('--offline', action='store_true', help=hide)
addarg('--suggest', action='store_true', help=hide)
addarg('--tacit', action='store_true', help=hide)
addarg('--nostdin', action='store_true', help=hide)
addarg('--threads', type=int, default=4, choices=range(1, 11), help=hide)
addarg('-V', dest='upstream', action='store_true', help=hide)
addarg('-g', '--debug', action='store_true', help=hide)
# Undocumented APIs
# Fix uppercase tags allowed in releases before v2.7
addarg('--fixtags', action='store_true', help=hide)
# App-use only, not for manual usage
addarg('--db', nargs=1, default=[None], help=hide)
# Parse the arguments
args = argparser.parse_args(argv)
# Show help and exit if help requested
if args.help:
argparser.print_help()
sys.exit(0)
# By default, buku uses ANSI colors. As Windows does not really use them,
# we'd better check for known working console emulators first. Currently,
# only ConEmu is supported. If the user does not use ConEmu, colors are
# disabled unless --colors or %BUKU_COLORS% is specified.
if sys.platform == 'win32' and os.environ.get('ConemuDir') is None:
if args.colorstr is None and colorstr_env is not None:
args.nc = True
# Handle NO_COLOR as well:
if os.environ.get('NO_COLOR') is not None:
args.nc = True
# Handle color output preference
if args.nc:
logging.basicConfig(format='[%(levelname)s] %(message)s')
else:
# Set colors
if colorstr_env is not None:
# Someone set BUKU_COLORS.
colorstr = colorstr_env
elif args.colorstr is not None:
colorstr = args.colorstr
else:
colorstr = 'oKlxm'
ID = setcolors(colorstr)[0] + '%d. ' + COLORMAP['x']
ID_DB_dim = COLORMAP['z'] + '[%s]\n' + COLORMAP['x']
ID_STR = ID + setcolors(colorstr)[1] + '%s ' + COLORMAP['x'] + ID_DB_dim
ID_DB_STR = ID + setcolors(colorstr)[1] + '%s' + COLORMAP['x']
MUTE_STR = '%s \x1b[2m(L)\x1b[0m\n'
URL_STR = COLORMAP['j'] + ' > ' + setcolors(colorstr)[2] + '%s\n' + COLORMAP['x']
DESC_STR = COLORMAP['j'] + ' + ' + setcolors(colorstr)[3] + '%s\n' + COLORMAP['x']
DESC_WRAP = COLORMAP['j'] + setcolors(colorstr)[3] + '%s%s' + COLORMAP['x']
TAG_STR = COLORMAP['j'] + ' # ' + setcolors(colorstr)[4] + '%s\n' + COLORMAP['x']
TAG_WRAP = COLORMAP['j'] + setcolors(colorstr)[4] + '%s%s' + COLORMAP['x']
# Enable color in logs
setup_logger(LOGGER)
# Enable prompt with reverse video
PROMPTMSG = '\001\x1b[7m\002buku (? for help)\001\x1b[0m\002 '
# Enable browser output in case of a text based browser
if os.getenv('BROWSER') in TEXT_BROWSERS:
browse.suppress_browser_output = False
else:
browse.suppress_browser_output = True
# Overriding text browsers is disabled by default
browse.override_text_browser = False
# Handle DB name (--db value without extension and path separators)
_db = args.db[0]
if _db and not os.path.dirname(_db) and not os.path.splitext(_db)[1]:
_db = os.path.join(BukuDb.get_default_dbdir(), _db + '.db')
# Fallback to prompt if no arguments
if args._passed <= {'nostdin', 'db'}:
try:
_db = _db or os.path.join(BukuDb.get_default_dbdir(), 'bookmarks.db')
if not os.path.exists(_db):
print(f'DB file is being created at {_db}') # not printed without chatty param
bdb = BukuDb(dbfile=_db, default_scheme=args.default_scheme[0])
except Exception:
sys.exit(1)
prompt(bdb, None)
bdb.close_quit(0)
# Set up debugging
if args.debug:
LOGGER.setLevel(logging.DEBUG)
LOGDBG('buku v%s', __version__)
LOGDBG('Python v%s', ('%d.%d.%d' % sys.version_info[:3]))
else:
logging.disable(logging.WARNING)
urllib3.disable_warnings()
# Handle encrypt/decrypt options at top priority
try:
if args.lock is not None:
BukuCrypt.encrypt_file(args.lock, dbfile=_db)
print('File encrypted')
sys.exit(0)
if args.unlock is not None:
BukuCrypt.decrypt_file(args.unlock, dbfile=_db)
print('File decrypted')
except RuntimeError as e:
LOGERR(e)
sys.exit(1)
order = parse_order(args.order or [])
# Set up title
if args.title is not None:
title_in = ' '.join(args.title)
# Set up tags
if args.tag is not None:
tags_in = args.tag or [DELIM]
# Set up comment
if args.comment is not None:
desc_in = ' '.join(args.comment)
# validating HTTP-code handling args
tag_redirect = args.tag_redirect
if isinstance(args.tag_redirect, str):
try:
args.tag_redirect.format(301)
tag_redirect = args.tag_redirect.strip() or True
except (IndexError, KeyError):
LOGERR('Invalid format of --tag-redirect (should use "{}" as placeholder)')
sys.exit(1)
tag_error = args.tag_error
if isinstance(args.tag_error, str):
try:
args.tag_error.format(301)
tag_error = args.tag_error.strip() or True
except (IndexError, KeyError):
LOGERR('Invalid format of --tag-error (should use "{}" as placeholder)')
sys.exit(1)
try:
del_error = (None if args.del_error is None else
parse_range(args.del_error, lambda x: 400 <= x < 600) or range(400, 600))
except ValueError:
LOGERR('Invalid HTTP code(s) given for --del-error (should be within 4xx/5xx ranges)')
sys.exit(1)
try:
_default = (set() if not args.url_redirect and not tag_redirect else PERMANENT_REDIRECTS)
_default |= set([] if not tag_error else range(400, 600)) | set(del_error or [])
export_on = (None if args.export_on is None else
parse_range(args.export_on, lambda x: 100 <= x < 600) or _default)
except ValueError:
LOGERR('Invalid HTTP code(s) given for --export-on')
sys.exit(1)
# Initialize the database and get handles, set verbose by default
try:
bdb = BukuDb(args.json, args.format, not args.tacit, dbfile=_db, colorize=not args.nc, default_scheme=args.default_scheme[0])
except Exception:
sys.exit(1)
if args.swap:
index1, index2 = args.swap
if bdb.swap_recs(index1, index2):
bdb.print_rec({index1, index2})
else:
LOGERR('Failed to swap records #%d and #%d', index1, index2)
bdb.close_quit(0)
# Editor mode
if args.write is not None:
if not is_editor_valid(args.write):
bdb.close_quit(1)
if is_int(args.write):
if not bdb.edit_update_rec(int(args.write), _immutable(args)):
bdb.close_quit(1)
elif args.add is None:
# Edit and add a new bookmark
# Parse tags into a comma-separated string
tags = parse_tags(tags_in, edit_input=True)
result = edit_rec(args.write, '', title_in, tags, desc_in)
if result is not None:
url, title_in, tags, desc_in = result
if args.suggest:
tags = bdb.suggest_similar_tag(tags)
bdb.add_rec(url, title_in, tags, desc_in, _immutable(args), False, not args.offline)
# Add record
if args.add is not None:
if args.url is not None and args.update is None:
LOGERR('Bookmark a single URL at a time')
bdb.close_quit(1)
# Parse tags into a comma-separated string
# --add may have URL followed by tags
keywords_except, keywords = [], args.add[1:]
# taglists are taken from --add (starting from 2nd value) and from --tags
# if taglist starts with '-', its contents are excluded from resulting tags
# if BOTH taglists is are either empty or start with '+'/'-', fetched tags are included
if keywords and keywords[0] == '-':
keywords, keywords_except = [], keywords[1:]
tags_add = (not keywords or keywords[0] == '+')
if tags_add:
keywords = keywords[1:]
if tags_in:
# note: need to add a delimiter as url+tags may not end with one
if tags_in[0] == '-':
keywords_except += [DELIM] + tags_in[1:]
elif tags_in[0] == '+':
keywords += [DELIM] + tags_in[1:]
else:
keywords += [DELIM] + tags_in
tags_add = False
tags, tags_except = parse_tags(keywords), parse_tags(keywords_except)
tags, tags_except = ((s if s and s != DELIM else None) for s in [tags, tags_except])
url = args.add[0]
edit_aborted = False
if args.write and not is_int(args.write):
result = edit_rec(args.write, url, title_in, tags, desc_in)
if result is not None:
url, title_in, tags, desc_in = result
else:
edit_aborted = True
if edit_aborted is False:
if args.suggest:
tags = bdb.suggest_similar_tag(tags)
network_test = args.url_redirect or tag_redirect or tag_error or del_error
fetch = not args.offline and (network_test or tags_add or title_in is None)
bdb.add_rec(url, title_in, tags, desc_in, _immutable(args), delay_commit=False, fetch=fetch,
tags_fetch=tags_add, tags_except=tags_except, url_redirect=args.url_redirect,
tag_redirect=tag_redirect, tag_error=tag_error, del_error=del_error)
# Search record
search_results, search_opted = None, True
if args.sany is not None:
if not args.sany:
LOGERR('no keyword')
else:
LOGDBG('args.sany')
# Apply tag filtering, if opted
search_results = bdb.search_keywords_and_filter_by_tags(
args.sany, deep=args.deep, stag=args.stag, markers=args.markers, without=args.exclude, order=order)
elif args.sall is not None:
if not args.sall:
LOGERR('no keyword')
else:
LOGDBG('args.sall')
search_results = bdb.search_keywords_and_filter_by_tags(
args.sall, all_keywords=True, deep=args.deep, stag=args.stag,
markers=args.markers, without=args.exclude, order=order)
elif args.sreg is not None:
if not args.sreg:
LOGERR('no expression')
else:
LOGDBG('args.sreg')
search_results = bdb.search_keywords_and_filter_by_tags(
args.sreg, regex=True, stag=args.stag, markers=args.markers, without=args.exclude, order=order)
elif args.keywords:
LOGDBG('args.keywords')
search_results = bdb.search_keywords_and_filter_by_tags(
args.keywords, deep=args.deep, stag=args.stag, markers=args.markers, without=args.exclude, order=order)
elif args.stag is not None:
if not args.stag: # use sub-prompt to list all tags
prompt(bdb, None, noninteractive=args.np, listtags=True, suggest=args.suggest, order=order)
else:
LOGDBG('args.stag')
search_results = bdb.exclude_results_from_search(
bdb.search_by_tag(' '.join(args.stag), order=order), args.exclude, deep=args.deep, markers=args.markers)
elif args.exclude is not None:
LOGERR('No search criteria to exclude results from')
elif args.markers:
LOGERR('No search criteria to apply markers to')
else:
search_opted = False
# Add cmdline search options to readline history
if search_opted and len(args.keywords):
try:
readline.add_history(' '.join(args.keywords))
except Exception:
pass
check_stdout_encoding()
monkeypatch_textwrap_for_cjk()
update_search_results = False
if search_results:
if args.random and args.random < len(search_results):
search_results = bdb._sort(random.sample(search_results, args.random), order)
single_record = args.random == 1 # matching print_rec() behaviour
oneshot = args.np
# Open all results in browser right away if args.oa
# is specified. The has priority over delete/update.
# URLs are opened first and updated/deleted later.
if args.oa:
for row in search_results:
browse(row[1], args.default_scheme[0])
if (
(args.export is not None) or
(args.delete is not None and not args.delete) or
(args.update is not None and not args.update)):
oneshot = True
if args.json is None and not args.format and not args.random:
num = 10 if not args.count else args.count
prompt(bdb, search_results, noninteractive=oneshot, deep=args.deep, markers=args.markers, order=order, num=num)
elif args.json is None:
print_rec_with_filter(search_results, field_filter=args.format)
elif args.json:
write_string_to_file(format_json(search_results, single_record, field_filter=args.format), args.json)
else:
# Printing in JSON format is non-interactive
print_json_safe(search_results, single_record, field_filter=args.format)
# Export the results, if opted
if args.export and not (args.update is not None and export_on):
bdb.exportdb(args.export[0], search_results)
# In case of search and delete/update,
# prompt should be non-interactive
# delete gets priority over update
if args.delete is not None and not args.delete:
bdb.delete_resultset(search_results, retain_order=args.retain_order)
elif args.update is not None and not args.update:
update_search_results = True
# Update record
if args.update is not None:
url_in = (args.url[0] if args.url else None)
# Parse tags into a comma-separated string
tags = parse_tags(tags_in, edit_input=True)
tags = (None if tags == DELIM else tags)
# No arguments to --update, update all
if not args.update:
# Update all records only if search was not opted
if not search_opted:
_indices = []
elif search_results and update_search_results:
if not args.tacit:
print('Updated results:\n')
_indices = [x.id for x in search_results]
else:
_indices = None
else:
try:
_indices = parse_range(args.update, lambda x: x >= 0)
except ValueError:
LOGERR('Invalid index or range to update')
bdb.close_quit(1)
_indices = ([] if 0 in _indices else _indices)
if _indices is not None:
bdb.update_rec(_indices, url_in, title_in, tags, desc_in, _immutable(args), threads=args.threads,
url_redirect=args.url_redirect, tag_redirect=tag_redirect, tag_error=tag_error,
del_error=del_error, export_on=export_on, retain_order=args.retain_order)
if args.export and bdb._to_export is not None:
bdb.exportdb(args.export[0], order=order)
# Delete record
if args.delete is not None:
if not args.delete:
# Attempt delete-all only if search was not opted
if not search_opted:
bdb.cleardb()
elif len(args.delete) == 1 and '-' in args.delete[0]:
try:
vals = [int(x) for x in args.delete[0].split('-')]
if len(vals) == 2:
bdb.delete_rec(0, vals[0], vals[1], is_range=True, retain_order=args.retain_order)
except ValueError:
LOGERR('Invalid index or range to delete')
bdb.close_quit(1)
else:
ids = set(args.delete)
try:
# Index delete order - highest to lowest
ids = sorted(map(int, ids), reverse=True)
for idx in ids:
bdb.delete_rec(idx, retain_order=args.retain_order)
except ValueError:
LOGERR('Invalid index or range or combination')
bdb.close_quit(1)
# Print record
if args.print is not None:
try:
max_id = bdb.get_max_id() or 0
id_range = list(parse_range(args.print, maxidx=max_id) or []) or range(1, 1 + max_id)
except ValueError:
LOGERR('Invalid index or range to print')
bdb.close_quit(1)
if args.random and args.random < len(id_range):
bdb.print_rec(random.sample(id_range, args.random), order=order)
elif not args.print:
if args.count:
search_results = bdb.list_using_id(order=order)
prompt(bdb, search_results, noninteractive=args.np, num=args.count, order=order)
else:
bdb.print_rec(None, order=order)
else:
if args.count:
search_results = bdb.list_using_id(args.print, order=order)
prompt(bdb, search_results, noninteractive=args.np, num=args.count, order=order)
else:
bdb.print_rec(id_range, order=order)
# Replace a tag in DB
if args.replace is not None:
if len(args.replace) == 1:
bdb.delete_tag_at_index(0, args.replace[0])
else:
try:
bdb.replace_tag(args.replace[0], [' '.join(args.replace[1:])])
except Exception as e:
LOGERR(str(e))
bdb.close_quit(1)
# Export bookmarks
if args.export and not search_opted and not export_on:
bdb.exportdb(args.export[0], order=order, pick=args.random)
# Import bookmarks
if args.importfile is not None:
bdb.importdb(args.importfile[0], args.tacit)
# Import bookmarks from browser
if args.ai:
bdb.auto_import_from_browser(firefox_profile=os.environ.get('FIREFOX_PROFILE'),
firefox_profiles_dir=os.environ.get('FIREFOX_PROFILES_DIR'))
# Open URL in browser
if args.open is not None:
if not args.open:
bdb.browse_by_index(0)
else:
try:
for idx in args.open:
if is_int(idx):
bdb.browse_by_index(int(idx))
elif '-' in idx:
vals = [int(x) for x in idx.split('-')]
bdb.browse_by_index(0, vals[0], vals[-1], True)
except ValueError:
LOGERR('Invalid index or range to open')
bdb.close_quit(1)
# Try to fetch URL from Wayback Machine
if args.cached:
wbu = bdb.browse_cached_url(args.cached[0])
if wbu is not None:
browse(wbu)
# Report upstream version
if args.upstream:
check_upstream_release()
# Fix tags
if args.fixtags:
bdb.fixtags()
if args.reorder:
size = bdb.get_max_id()
if size and (args.np or read_in(f'Are you sure you want to reorder all {size} bookmarks? (y/n): ') == 'y'):
bdb.reorder(parse_order(args.reorder))
print(f'...Reordered {size} bookmarks.')
# Close DB connection and quit
bdb.close_quit(0)
if __name__ == '__main__':
main()