Files
xbsjsonedit/xbsjsonedit
T
2019-12-06 22:58:47 -07:00

525 lines
21 KiB
Python
Executable File

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""xBrowserSync json backup editor"""
__version__ = "v0.3 191206"
#==========================================================
#
# Chris Nelson 2018 - 2019
#
# 191206 v0.3 Added tags dump
# 191205 v0.2 Updated to xbrowsersync v1.5 and Python 3.x ONLY
# 181128 v0.1 New
#
# Issues, and changes pending
# None
#
#==========================================================
import argparse
import os.path
import json
import codecs
import sys
import io
# Configs / Constants
INDENT = 2
OFILESUFFIX = "_OUT"
NONE_SEARCH = "__NONE__"
# Global items
url_dict = {}
folder_dict = {}
tags_dict = {}
def main():
global match_cnt
global change_cnt
global do_all
global json_dict
global url_dict
global folder_dict
global tags_dict
with io.open(args.Infile, encoding='utf8', errors="replace") as json_data:
json_dict = json.load(json_data)
term = NONE_SEARCH
if args.Print:
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="printtree")
exit()
if args.Tags_Print:
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="GatherTags")
for tag in sorted(tags_dict.keys()):
if args.Tags_Count == 0:
if len(tags_dict[tag]) == 1:
print ("\n[{}]:".format(tag))
print (" {:<4} - {}".format(tags_dict[tag][0]["id"], tags_dict[tag][0]["title"]))
else:
if len(tags_dict[tag]) >= args.Tags_Count:
print ("\n[{}]:".format(tag))
for bookmark in tags_dict[tag]:
print (" {:<4} - {}".format(bookmark["id"], bookmark["title"]))
exit()
while (1):
match_cnt = 0
change_cnt = 0
do_all = False
print ("""
---------------------------------------------------------------------
Options:
p: Print the bookmarks tree hierarchy
s: Search term entry (currently <{}>)
t: List bookmark search matches
T: Delete bookmark search matches
g: List folder names search matches
G: Delete folder names search matches
d: List duplicate URLs
D: Delete duplicate URLs
f: List duplicate folders
F: Delete duplicate folders
x: List tags on folders (only leaf nodes should have tags)
X: Delete tags on folders
y: List empty folders (may arise if all children were deleted)
Y: Delete empty folders
w: Write out the bookmarks data to a file
q: Quit/exit (Do a Write first!)
""".format(term))
select = prompt("Enter option: ")
if select == 'p':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="printtree")
elif select == 's':
term = prompt("Search for (empty to clear search term): ").lower()
if term == "":
term = NONE_SEARCH
elif select == 't':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="search")
print ("\nMatches: {}\n".format(match_cnt))
elif select == 'T':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="search", commit=True)
delete_items () # Do the queued deletes
print ("\nMatches: {} Deletes: {}\n".format(match_cnt, change_cnt))
elif select == 'g':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="SearchFolders")
print ("\nMatches: {}\n".format(match_cnt))
elif select == 'G':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term=term, operation="SearchFolders", commit=True)
delete_items () # Do the queued deletes
print ("\nMatches: {} Deletes: {}\n".format(match_cnt, change_cnt))
elif select == 'd':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupURLs")
dup_urls()
url_dict = {}
print ("\nMatches: {}\n".format(match_cnt))
elif select == 'D':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupURLs")
dup_urls(commit=True)
url_dict = {}
delete_items ()
print ("\nMatches: {} Deletes: {}\n".format(match_cnt, change_cnt))
elif select == 'f':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupFolders")
dup_folders()
folder_dict = {}
print ("\nMatches: {}\n".format(match_cnt))
elif select == 'F':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="DupFolders")
dup_folders(commit=True)
folder_dict = {}
delete_items ()
print ("\nMatches: {} Deletes: {}\n".format(match_cnt, change_cnt))
elif select == 'x':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="FolderTags")
print ("\nMatches: {}\n".format(match_cnt))
elif select == 'X':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="FolderTags", commit=True)
print ("\nMatches: {} Deletes: {}\n".format(match_cnt, change_cnt))
elif select == 'y':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="EmptyFolders")
print ("\nMatches: {}\n".format(match_cnt))
elif select == 'Y':
digin(parent=json_dict["xbrowsersync"]["data"]["bookmarks"], parent_id="", path="/", search_term="", operation="EmptyFolders", commit=True)
delete_items ()
print ("\nMatches: {} Deletes: {}\n".format(match_cnt, change_cnt))
elif select == 'w':
ans = prompt("Output file name (default <{}>: ".format(args.Infile + OFILESUFFIX))
if ans == "":
ans = args.Infile + OFILESUFFIX
with io.open(ans, "w", encoding='utf8') as ofile:
json.dump(json_dict, ofile, ensure_ascii=False, indent=2)
elif select == 'q':
exit()
else:
print ("Invalid option {}".format(select))
def digin(parent, parent_id, path, search_term, operation, commit=False, indent=""):
"""Recurse through the json dictionary bookmark tree, with operation options."""
global match_cnt
global change_cnt
global do_all
global path_dict
item_index = -1
ans = 'n'
for item in parent:
item_index += 1
if "children" in item: # ***** Its a folder *****
if operation == "printtree":
print ("{:<4} > {}{}".format(item["id"], indent, item["title"]))
if operation == "SearchFolders":
if search_term in item["title"].lower():
print ("{:<4} > {}{}".format(item["id"], indent, item["title"]))
match_cnt += 1
if commit:
if do_all:
ans = 'y'
else:
ans = prompt("Confirm delete for this item ('y'es, 'a'll, or 'q'uit, default no) ").lower()
if ans == 'a':
do_all = True
ans = 'y'
if ans == 'y':
collect_items (path + parent_id, parent, item_index)
change_cnt += 1
if ans == 'q':
if prompt("Discard pending deletes ('y'es, default no)? ").lower() == 'y':
path_dict = {}
return -1
if operation == "FolderTags":
if "tags" in item:
print ("\n{:4} - {} >>> {}\n {}".format(
item["id"],
path[3:],
item["title"],
item["tags"]))
if "url" in item:
if item["url"] == None:
print (" url = None !!!")
else:
print (" url: <{}>".format(item["url"]))
match_cnt += 1
if commit:
if do_all:
ans = 'y'
else:
ans = prompt("Confirm immediate delete of tags on this folder ('y'es, 'a'll, or 'q'uit, default no) ").lower()
if ans == 'a':
do_all = True
ans = 'y'
if ans == 'y':
del item["tags"]
change_cnt += 1
if ans == 'q':
return -1
if operation == "EmptyFolders":
if len(item["children"]) == 0:
print ("\n{:4} - {} >>> {}".format(
item["id"],
path, #[3:],
item["title"]))
match_cnt += 1
if commit:
if do_all:
ans = 'y'
else:
ans = prompt("Confirm delete of this empty folder ('y'es, 'a'll, or 'q'uit, default no) ").lower()
if ans == 'a':
do_all = True
ans = 'y'
if ans == 'y':
collect_items (path + parent_id, parent, item_index)
change_cnt += 1
if ans == 'q':
if prompt("Discard pending deletes ('y'es, default no)? ").lower() == 'y':
path_dict = {} # TODO
return -1
if operation == "DupFolders":
log_folder (item["title"], path, item["id"], parent, parent_id, item_index)
if digin ( # Recurse down to the next level
parent = item["children"],
parent_id = str(item["id"]),
path = path + " > " + item["title"],
search_term = search_term,
operation = operation,
commit = commit,
indent = indent + " ") == -1:
return -1 # recursion 'q'uit switch
else: # ***** Its a Leaf node *****
if operation == "printtree":
tags = ""
if "tags" in item:
for tag in item["tags"]:
tags += tag + ", "
print ("{:<4} - {}{} [{}]".format(item["id"], indent, item["title"], tags[:-2]))
if operation == "DupURLs":
log_urls (item["url"], path, item["id"], parent, parent_id, item_index)
if operation == "search":
match = False
if search_term in item["url"].lower() or search_term in item["title"].lower():
match = True
if "tags" in item:
if item["tags"] != None:
for tag in item["tags"]:
if search_term in tag:
match = True
if match:
match_cnt += 1
print ("\n{:4} - {} >>> {}\n url: <{}>".format(
item["id"],
path[3:],
item["title"],
item["url"]))
if "tags" in item:
if item["tags"] == None:
print (" tags = None !!!")
else:
print (" tags: <{}>".format(item["tags"]))
if commit:
if do_all:
ans = 'y'
else:
ans = prompt("Confirm delete for this item ('y'es, 'a'll, or 'q'uit, default no) ").lower()
if ans == 'a':
do_all = True
ans = 'y'
if ans == 'y':
collect_items (path + parent_id, parent, item_index)
change_cnt += 1
if ans == 'q':
if prompt("Discard pending deletes ('y'es, default no)? ").lower() == 'y':
path_dict = {}
return -1
if operation == "GatherTags":
if "tags" in item:
if item["tags"] != None:
for tag in item["tags"]:
if tag not in tags_dict:
tags_dict[tag] = [{"title":item["title"], "id":item["id"]}]
else:
tags_dict[tag].append({"title":item["title"], "id":item["id"]})
path_dict = {}
def collect_items (path, parent, index):
"""Collect items for later deletion by delete_items."""
global path_dict
# print (path) # "/ > [xbs] Toolbar > Chris' > Blogs166"
# print (parent) # List of dictionaries of bookmarks [{}, {}, {}]
# print (index) # Index within the list - 2
if path not in path_dict:
path_dict[path] = {"parent":parent, "index":[int(index)]}
else:
# else clause could happen for two identical bookmarks in the same folder, but normally they would have different ID#s
path_dict[path]["index"].append(int(index))
def delete_items ():
"""Delete queued-up items by collect_items.
Note: Items in lists that are to be deleted must be deleted from the highest
index / item number to the lowest, else the index numbers will be wrong, resulting
in garbage. This is the purpose of the collect_items and delete_items functions.
"""
global path_dict
for path in path_dict:
xxx = path_dict[path]["index"]
## print path
## print xxx
xxx.sort(reverse=True)
for _index in xxx:
## print path_dict[path]["parent"]
del path_dict[path]["parent"][_index]
path_dict = {}
def log_folder (foldername, path, _id, parent, parent_id, item_index):
"""Capture list of all instances of a folder name."""
path_pid = path + " [" + parent_id + "]"
if foldername not in folder_dict:
folder_dict[foldername] = {"instance": [{"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id}]}
else:
folder_dict[foldername]["instance"].append({"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id})
def dup_folders (commit=False):
"""List out, and optionally delete, duplicate folders."""
global match_cnt
global change_cnt
global folder_dict
global path_dict
for folder in folder_dict:
if len(folder_dict[folder]["instance"]) > 1:
print ("-------------------------------------------------\n{}".format(folder))
for instance in folder_dict[folder]["instance"]:
print (" {:>4} - {}".format(instance["id"], instance["path"]))
match_cnt += 1
if commit:
print ("")
for instance in folder_dict[folder]["instance"]:
print (" {:>4} - {}".format(instance["id"], instance["path"]))
ans = prompt ("Confirm delete for this item ('y'es, 'q'uit, 's'kip to next URL - default no) ").lower()
if ans == 'y':
collect_items (instance["path"], instance["parent"], instance["item_index"])
change_cnt += 1
if ans == 's':
break
if ans == 'q':
if prompt("Discard pending deletes ('y'es, default no)? ").lower() == 'y':
path_dict = {}
return -1
def log_urls (url, path, _id, parent, parent_id, item_index):
"""Capture a list of all instances of each URL."""
path_pid = path + " [" + parent_id + "]"
if url not in url_dict:
url_dict[url] = {"instance": [{"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id}]}
else:
url_dict[url]["instance"].append({"id":_id, "path":path_pid, "parent":parent, "item_index":item_index, "parent_id":parent_id})
def dup_urls (commit=False):
"""List out, and optionally delete, bookmarks with the same URL."""
global match_cnt
global change_cnt
global path_dict
for url in url_dict:
if len(url_dict[url]["instance"]) > 1:
print ("-------------------------------------------------\n{}".format(url))
for instance in url_dict[url]["instance"]:
print (" {:>4} - {}".format(instance["id"], instance["path"]))
match_cnt += 1
if commit:
print ("")
for instance in url_dict[url]["instance"]:
print (" {:>4} - {}".format(instance["id"], instance["path"]))
ans = prompt ("Confirm delete for this item ('y'es, 'q'uit, 's'kip to next URL - default no) ").lower()
if ans == 'y':
collect_items (instance["path"], instance["parent"], instance["item_index"])
change_cnt += 1
if ans == 's':
break
if ans == 'q':
if prompt("Discard pending deletes ('y'es, default no)? ").lower() == 'y':
path_dict = {}
return -1
def prompt (prompt_text):
# if sys.version_info[0] < 3:
# return (raw_input(prompt_text))
# else:
return (input(prompt_text))
# def get_item (path):
# """Lookup leaf node data given hierarchical path info. NOT USED, BUT POTENTIALLY INTERESTING.
# Example passed-in path list structure:
# [ "xbrowsersync", "data", "bookmarks", 2, "ATV", 7, "Honda parts", 4 ]
# A named item translates to "children" in the json_dict hierarchy.
# Corresponds to this path in json_dict:
# json_dict["xbrowsersync"]["data"]["bookmarks"][2]["children"][7]["children"][4]
# Returns dictionary:
# {"title" : "<title text>",
# "url" : "<url text>",
# "id" : id (int)
# "tags" : [ "<tags>", "<list"> ],
# "text_path: "<string concat of titles and list indexes>"
# }
# """
# global json_dict
# JOINTEXT = " > "
# xxx = json_dict["xbrowsersync"]["data"]["bookmarks"]
# text_full_path = ""
# for item in path:
# print (item)
# if type(item) is str: # Its the title of a folder (child) level
# xxx = xxx["children"]
# else: # Its a list index (integer)
# text_full_path += JOINTEXT + xxx[item]["title"]
# xxx = xxx[item]
# tags = ""
# if "tags" in xxx:
# tags = xxx["tags"]
# return {
# "title" : xxx["title"],
# "url" : xxx["url"],
# "id" : xxx["id"],
# "tags" : tags,
# "text_full_path": text_full_path
# }
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument('Infile',
help="json backup file")
parser.add_argument('--Print', '-p', action='store_true',
help="Print bookmark hierarchy (redirect to less or a file)")
parser.add_argument('--Tags-Print', '-t', action='store_true',
help="Print tags list")
parser.add_argument('--Tags-Count', '-c', default=2, type=int,
help="Filter Tags-List for min number of times a tag is used (defult 2). =0 prints only single use tags.")
parser.add_argument('-V', '--version',
help="Return version number and exit.",
action='version',
version='%(prog)s ' + __version__)
args = parser.parse_args()
if not os.path.exists(args.Infile):
print ("Can't find the input file <{}>".format(args.Infile))
exit()
main()