|
|
@ -0,0 +1,951 @@ |
|
|
|
#!/Users/gl6/env/bin/python |
|
|
|
|
|
|
|
import os |
|
|
|
import json |
|
|
|
import requests |
|
|
|
import uuid |
|
|
|
import browser_cookie3 |
|
|
|
import time |
|
|
|
from datetime import datetime, timedelta |
|
|
|
import re |
|
|
|
import click |
|
|
|
import logging |
|
|
|
from rich.console import Console |
|
|
|
from rich.theme import Theme |
|
|
|
from rich.logging import RichHandler |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
STORAGE_FILE = os.path.join(os.path.expanduser('~'), '.wf.json') |
|
|
|
|
|
|
|
INBOX_ID = "13859f14-79ab-b758-7224-8dad0236f1e2" |
|
|
|
TASKS_ID = "042d7e11-f613-856c-fb97-c0e6ee7ece05" |
|
|
|
DIARY_ID = "01205285-f5b1-8026-0cfa-942f514e297e" |
|
|
|
PLANNER_IDS = { |
|
|
|
"life": "f6f993a3-c696-c070-296e-6e5055fc834f", |
|
|
|
"admin": "d3911123-138e-8eb3-f2ba-2495d8169660", |
|
|
|
"art": "c2e9f4b2-59ce-d127-4da6-949e54ec1442", |
|
|
|
"health": "4fc929b8-f04b-0dde-9a1a-9d889a13316d", |
|
|
|
"mind": "c6db70a1-72e3-dbcc-f4b9-bfb10a1b4280", |
|
|
|
"social": "60e3d667-eb40-3d26-cc3f-6b151cc5efa4", |
|
|
|
"church": "5c68fad5-ad2b-8018-6535-b1462aed1277", |
|
|
|
"work": "4c4970fc-9023-f861-1392-dbf88dd89187", |
|
|
|
"affirm": "a1412d52-c72c-0612-f5b1-48874ef03943", |
|
|
|
} |
|
|
|
|
|
|
|
solarized_theme = Theme({ |
|
|
|
"base03": "bright_black", |
|
|
|
"base02": "black", |
|
|
|
"base01": "bright_green", |
|
|
|
"base00": "bright_yellow", |
|
|
|
"base0": "bright_blue", |
|
|
|
"base1": "bright_cyan", |
|
|
|
"base2": "white", |
|
|
|
"base3": "bright_white", |
|
|
|
"orange": "bright_red", |
|
|
|
"violet": "bright_magenta", |
|
|
|
"red": "red", |
|
|
|
"bold red": "bold red", |
|
|
|
"underline base03": "underline bright_black", |
|
|
|
"underline base02": "underline black", |
|
|
|
"underline base01": "underline bright_green", |
|
|
|
"underline base00": "underline bright_yellow", |
|
|
|
"underline base0": "underline bright_blue", |
|
|
|
"underline base1": "underline bright_cyan", |
|
|
|
"underline base2": "underline white", |
|
|
|
"underline base3": "underline bright_white", |
|
|
|
"underline orange": "bright_red underline", |
|
|
|
"underline violet": "bright_magenta underline", |
|
|
|
"bold base03": "bold bright_black", |
|
|
|
"bold base02": "bold black", |
|
|
|
"bold base01": "bold bright_green", |
|
|
|
"bold base00": "bold bright_yellow", |
|
|
|
"bold base0": "bold bright_blue", |
|
|
|
"bold base1": "bold bright_cyan", |
|
|
|
"bold base2": "bold white", |
|
|
|
"bold base3": "bold bright_white", |
|
|
|
"bold orange": "bright_red bold", |
|
|
|
"bold violet": "bright_magenta bold", |
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
console = Console(highlight=False, theme=solarized_theme) |
|
|
|
logging.basicConfig(handlers=[RichHandler(level="NOTSET", console=console)]) |
|
|
|
logger = logging.getLogger('rich') |
|
|
|
logger.setLevel(logging.INFO) |
|
|
|
|
|
|
|
|
|
|
|
# helpers {{{ |
|
|
|
|
|
|
|
|
|
|
|
def get_ordinal(n): |
|
|
|
if 10 <= n % 100 <= 20: |
|
|
|
suffix = 'th' |
|
|
|
else: |
|
|
|
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th') |
|
|
|
return str(n) + suffix |
|
|
|
|
|
|
|
|
|
|
|
def get_today(): |
|
|
|
now = datetime.now() |
|
|
|
return now.strftime("%a, %b %d, %Y") |
|
|
|
# return now.strftime(f"%a {get_ordinal(now.day)} %b") |
|
|
|
|
|
|
|
def get_sunday(): |
|
|
|
now = datetime.now() |
|
|
|
sunday = now - timedelta(days=now.weekday()) - timedelta(days=1) |
|
|
|
return sunday.strftime("%a, %b %d, %Y") |
|
|
|
# return now.strftime(f"%a {get_ordinal(now.day)} %b") |
|
|
|
|
|
|
|
|
|
|
|
def generate_uuid(): |
|
|
|
return str(uuid.uuid4()) |
|
|
|
|
|
|
|
|
|
|
|
def load_storage(): |
|
|
|
if os.path.exists(STORAGE_FILE): |
|
|
|
logger.debug(f"Loading storage from {STORAGE_FILE}") |
|
|
|
with open(STORAGE_FILE, "r") as f: |
|
|
|
return json.load(f) |
|
|
|
return {} |
|
|
|
|
|
|
|
|
|
|
|
def save_storage(data): |
|
|
|
with open(STORAGE_FILE, "w") as f: |
|
|
|
json.dump(data, f) |
|
|
|
|
|
|
|
|
|
|
|
def save_to_storage(key, value): |
|
|
|
storage = load_storage() |
|
|
|
storage[key] = value |
|
|
|
save_storage(storage) |
|
|
|
|
|
|
|
|
|
|
|
def load_from_storage(key): |
|
|
|
storage = load_storage() |
|
|
|
if key in storage: |
|
|
|
return storage[key] |
|
|
|
return {} |
|
|
|
|
|
|
|
|
|
|
|
def clear_storage(): |
|
|
|
if os.path.exists(STORAGE_FILE): |
|
|
|
os.remove(STORAGE_FILE) |
|
|
|
|
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def refresh_cookie(): # {{{ |
|
|
|
logger.debug("Refreshing session cookie") |
|
|
|
cookies = browser_cookie3.chrome() |
|
|
|
session_cookie = None |
|
|
|
for cookie in cookies: |
|
|
|
if cookie.name == "sessionid" and "workflowy.com" in cookie.domain: |
|
|
|
session_cookie = cookie.value |
|
|
|
break |
|
|
|
if session_cookie: |
|
|
|
logger.debug(f"Found session cookie: {session_cookie}") |
|
|
|
save_to_storage("session_cookie", session_cookie) |
|
|
|
return True |
|
|
|
else: |
|
|
|
logger.error("Session cookie not found. Are you logged into Workflowy?") |
|
|
|
return False |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def check_cookie(): # {{{ |
|
|
|
session_cookie = load_from_storage("session_cookie") |
|
|
|
if session_cookie: |
|
|
|
logger.debug(f"Session cookie found: {session_cookie}") |
|
|
|
else: |
|
|
|
logger.error("Session cookie not found. Run refresh_cookie() first.") |
|
|
|
return False |
|
|
|
|
|
|
|
url = "https://workflowy.com/get_initialization_data?client_version=15" |
|
|
|
headers = {"Cookie": f"sessionid={session_cookie}"} |
|
|
|
response = requests.get(url, headers=headers) |
|
|
|
if response.status_code == 200: |
|
|
|
logger.debug("Session cookie is valid.") |
|
|
|
return True |
|
|
|
else: |
|
|
|
logger.error(f"Session cookie is invalid. Status code {response.status_code}") |
|
|
|
return False |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def refresh_workflowy_data(): # {{{ |
|
|
|
session_cookie = load_from_storage("session_cookie") |
|
|
|
if not session_cookie: |
|
|
|
console.log("Session cookie not found. Run refresh_cookie() first.") |
|
|
|
return |
|
|
|
|
|
|
|
url = "https://workflowy.com/get_initialization_data?client_version=15" |
|
|
|
headers = {"Cookie": f"sessionid={session_cookie}"} |
|
|
|
response = requests.get(url, headers=headers) |
|
|
|
|
|
|
|
if response.status_code == 200: |
|
|
|
try: |
|
|
|
data = response.json() |
|
|
|
globals_data = {item[0]: item[1] for item in data["globals"]} |
|
|
|
|
|
|
|
storage = load_storage() |
|
|
|
storage["userid"] = globals_data["USER_ID"] |
|
|
|
storage["joined"] = data["projectTreeData"]["mainProjectTreeInfo"][ |
|
|
|
"dateJoinedTimestampInSeconds" |
|
|
|
] |
|
|
|
storage["transid"] = data["projectTreeData"]["mainProjectTreeInfo"][ |
|
|
|
"initialMostRecentOperationTransactionId" |
|
|
|
] |
|
|
|
storage["pollid"] = generate_uuid() # Simulate g() + g() |
|
|
|
storage["root"] = {"nm": "root", "ch": data["projectTreeData"]["mainProjectTreeInfo"]["rootProjectChildren"]} |
|
|
|
save_storage(storage) |
|
|
|
console.log("Successfully refreshed and saved Workflowy data.") |
|
|
|
return True |
|
|
|
except Exception as e: |
|
|
|
console.log(f"Error parsing response: {e}") |
|
|
|
return False |
|
|
|
else: |
|
|
|
console.log(f"Error fetching Workflowy data: Status code {response.status_code}") |
|
|
|
return False |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def check_workflowy_data(): # {{{ |
|
|
|
storage = load_storage() |
|
|
|
if not storage: |
|
|
|
console.log("Workflowy data is not initialized. Run the initialization first.") |
|
|
|
return False |
|
|
|
if not storage.get("userid") or not storage.get("transid") or not storage.get( |
|
|
|
"pollid" |
|
|
|
): |
|
|
|
console.log("Workflowy data is incomplete. Run the initialization again.") |
|
|
|
return False |
|
|
|
return True |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def clip_to_workflowy(name, description, parent_id): # {{{ |
|
|
|
storage = load_storage() |
|
|
|
if not storage: |
|
|
|
console.log("Workflowy data is not initialized. Run the initialization first.") |
|
|
|
return |
|
|
|
|
|
|
|
new_uuid = generate_uuid() |
|
|
|
timestamp = int(time.time()) - storage.get("joined", 0) |
|
|
|
|
|
|
|
request = [ |
|
|
|
{ |
|
|
|
"most_recent_operation_transaction_id": storage.get("transid"), |
|
|
|
"operations": [ |
|
|
|
{ |
|
|
|
"type": "create", |
|
|
|
"data": { |
|
|
|
"projectid": new_uuid, |
|
|
|
"parentid": parent_id, |
|
|
|
"priority": 9999, |
|
|
|
}, |
|
|
|
"client_timestamp": timestamp, |
|
|
|
"undo_data": {}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
"type": "edit", |
|
|
|
"data": { |
|
|
|
"projectid": new_uuid, |
|
|
|
"name": name, |
|
|
|
"description": description, |
|
|
|
}, |
|
|
|
"client_timestamp": timestamp, |
|
|
|
"undo_data": { |
|
|
|
"previous_last_modified": timestamp, |
|
|
|
"previous_name": "", |
|
|
|
"previous_description": "", |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
} |
|
|
|
] |
|
|
|
|
|
|
|
data = { |
|
|
|
"client_id": "2015-11-17 19:25:15.397732", |
|
|
|
"client_version": 15, |
|
|
|
"push_poll_id": storage.get("pollid"), |
|
|
|
"push_poll_data": json.dumps(request), |
|
|
|
"crosscheck_user_id": storage.get("userid"), |
|
|
|
} |
|
|
|
|
|
|
|
headers = {"Cookie": f"sessionid={storage.get('session_cookie')}"} |
|
|
|
|
|
|
|
response = requests.post("https://workflowy.com/push_and_poll", data=data, headers=headers) |
|
|
|
if response.status_code == 200: |
|
|
|
resp_obj = response.json() |
|
|
|
if resp_obj.get("logged_out"): |
|
|
|
console.log("Error: Logged out of Workflowy!") |
|
|
|
elif not resp_obj.get("results"): |
|
|
|
console.log("Error: Unknown error!") |
|
|
|
else: |
|
|
|
storage["transid"] = resp_obj["results"][0][ |
|
|
|
"new_most_recent_operation_transaction_id" |
|
|
|
] |
|
|
|
save_storage(storage) |
|
|
|
console.print("[green]Successfully clipped to Workflowy![/green]") |
|
|
|
else: |
|
|
|
console.log(f"Error: Failed with status code {response.status_code}") |
|
|
|
|
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def simplify_project(project_data, full_data, follow_mirrors=False): # {{{ |
|
|
|
if project_data.get("metadata", {}).get("mirror", {}).get("originalId"): |
|
|
|
if follow_mirrors: |
|
|
|
originalId = project_data["metadata"]["mirror"]["originalId"] |
|
|
|
project_data, full_data = find_project_by_id(full_data, full_data, originalId) |
|
|
|
else: |
|
|
|
return None, full_data |
|
|
|
if project_data.get("metadata", {}).get("isReferencesRoot"): |
|
|
|
if project_data.get("ch"): |
|
|
|
if len(project_data["ch"]) > 0: |
|
|
|
project_data["nm"] = "backlinks" |
|
|
|
project_data["metadata"]["layoutMode"] = "h1" |
|
|
|
else: |
|
|
|
return None, full_data |
|
|
|
else: |
|
|
|
return None, full_data |
|
|
|
if project_data: |
|
|
|
simplified_project = { |
|
|
|
"name": project_data.get("nm", ""), |
|
|
|
"id": project_data.get("id", ""), |
|
|
|
"children": [], |
|
|
|
"description": project_data.get("no", "").rstrip().replace("\n$", ""), |
|
|
|
"format": project_data.get("metadata", {}).get("layoutMode", None) |
|
|
|
} |
|
|
|
children = project_data.get("ch", []) |
|
|
|
for child in children: |
|
|
|
simplified_child, full_data = simplify_project(child, full_data, follow_mirrors=follow_mirrors) |
|
|
|
if simplified_child: |
|
|
|
simplified_project["children"].append(simplified_child) |
|
|
|
return simplified_project, full_data |
|
|
|
return None, full_data |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def flatten(children): # {{{ |
|
|
|
flattened = [] |
|
|
|
for child in children: |
|
|
|
try: |
|
|
|
grand_children = child.get("children", []) |
|
|
|
except Exception as e: |
|
|
|
print(f"child: {child} {e}") |
|
|
|
grand_children = [] |
|
|
|
child["children"] = [] |
|
|
|
flattened.append(child) |
|
|
|
flattened.extend(flatten(grand_children)) |
|
|
|
return flattened |
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def flatten_project(project_data): # {{{ |
|
|
|
project_data["children"] = flatten(project_data.get("children", [])) |
|
|
|
return project_data |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
# def filter_project_any(project_data, filters): # {{{ |
|
|
|
# filtered_project = { |
|
|
|
# "name": project_data["name"], |
|
|
|
# "id": project_data.get("id", ""), |
|
|
|
# # "description": project_data["description"], |
|
|
|
# "children": [], |
|
|
|
# "format": project_data["format"] |
|
|
|
# } |
|
|
|
# children = project_data.get("children", []) |
|
|
|
# for child in children: |
|
|
|
# include = False |
|
|
|
# for filter_text in filters: |
|
|
|
# if filter_text.lower() in child["name"].lower(): |
|
|
|
# include = True |
|
|
|
# break |
|
|
|
# if include: |
|
|
|
# filtered_project["children"].append(filter_project_any(child, filters)) |
|
|
|
# return filtered_project |
|
|
|
|
|
|
|
def filter_project_any(project_data, filters, include_headers=False, all_children=False): # {{{ |
|
|
|
include = False |
|
|
|
if include_headers: |
|
|
|
if project_data["format"] == "h1" or project_data["format"] == "h2": |
|
|
|
include = True |
|
|
|
for filter_text in filters: |
|
|
|
if filter_text in project_data["name"]: |
|
|
|
include = True |
|
|
|
break |
|
|
|
|
|
|
|
children = [] |
|
|
|
for child in project_data.get("children", []): |
|
|
|
if all_children and include: |
|
|
|
logger.debug(f"Not filtering children of {project_data['name']}") |
|
|
|
pass |
|
|
|
else: |
|
|
|
child = filter_project_any(child, filters, include_headers=include_headers, all_children=all_children) |
|
|
|
if child: |
|
|
|
children.append(child) |
|
|
|
|
|
|
|
project_data["children"] = children |
|
|
|
|
|
|
|
if include or children: |
|
|
|
return project_data |
|
|
|
else: |
|
|
|
return None |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def filter_project_all(project_data, filters, include_headers=False, all_children=False): # {{{ |
|
|
|
include = True |
|
|
|
if include_headers and (project_data["format"] == "h1" or project_data["format"] == "h2"): |
|
|
|
pass |
|
|
|
else: |
|
|
|
for filter_text in filters: |
|
|
|
if filter_text not in project_data["name"]: |
|
|
|
if filter_text not in project_data.get("description", ""): |
|
|
|
include = False |
|
|
|
break |
|
|
|
if include: |
|
|
|
logger.debug(f"Including {project_data['name']}") |
|
|
|
logger.debug(f"all_children: {all_children}") |
|
|
|
children = [] |
|
|
|
for child in project_data.get("children", []): |
|
|
|
if all_children and include: |
|
|
|
logger.debug(f"Not filtering children of {project_data['name']}") |
|
|
|
pass |
|
|
|
else: |
|
|
|
child = filter_project_all(child, filters, include_headers=include_headers, all_children=all_children) |
|
|
|
if child: |
|
|
|
children.append(child) |
|
|
|
|
|
|
|
project_data["children"] = children |
|
|
|
|
|
|
|
if include or children: |
|
|
|
return project_data |
|
|
|
else: |
|
|
|
return None |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def replace(project_data, regex, replacement): # {{{ |
|
|
|
project_data["name"] = re.sub(regex, replacement, project_data["name"]) |
|
|
|
children = project_data.get("children", []) |
|
|
|
for child in children: |
|
|
|
replace(child, regex, replacement) |
|
|
|
return project_data |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def strip(project_data, regex): # {{{ |
|
|
|
project_data = replace(project_data, regex, "") |
|
|
|
return project_data |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def remove_double_spaces(project_data): # {{{ |
|
|
|
# project_data["name"] = re.sub(r"\s+", " ", project_data["name"]) |
|
|
|
project_data["name"] = project_data["name"].replace(" ", " ") |
|
|
|
children = project_data.get("children", []) |
|
|
|
for child in children: |
|
|
|
remove_double_spaces(child) |
|
|
|
return project_data |
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
highlights = { |
|
|
|
"@done": "green", |
|
|
|
"@missed": "red", |
|
|
|
"@na": "blue", |
|
|
|
"#WORK": "red", |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
def highlight(project_data): # {{{ |
|
|
|
for key, value in highlights.items(): |
|
|
|
regex = f"{key}\\b" |
|
|
|
project_data["name"] = re.sub(regex, f"[{value}]{key}[/]", project_data["name"]) |
|
|
|
children = project_data.get("children", []) |
|
|
|
for child in children: |
|
|
|
highlight(child) |
|
|
|
return project_data |
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
colors1 = [ |
|
|
|
[["xTASK", "#READY"], "blue", True], |
|
|
|
[["xTASK", "#MAYBE"], "violet", True], |
|
|
|
[["xTASK", "#WAITING"], "cyan", True], |
|
|
|
[["xTASK", "#DAILY"], "green", True], |
|
|
|
[["xTASK", "#WEEKLY"], "green", True], |
|
|
|
[["xTASK", "#IRREGULAR"], "green", True], |
|
|
|
[["xPROJECT", "#ACTIVE"], "magenta", True], |
|
|
|
[["xPROJECT", "#STALLED"], "cyan", True], |
|
|
|
[["xPROJECT", "#PLANT"], "orange", True], |
|
|
|
[["xSOMEDAY"], "violet", True], |
|
|
|
[["xHABIT"], "orange", True], |
|
|
|
[["xSTORY"], "cyan", True], |
|
|
|
[["xGOAL"], "yellow", True], |
|
|
|
[["xVIS"], "red", True], |
|
|
|
[["xRESPONSIBILITY"], "red", True], |
|
|
|
[["Sunday"], "underline violet", False], |
|
|
|
[["Monday"], "underline red", False], |
|
|
|
[["Tuesday"], "underline cyan", False], |
|
|
|
[["Wednesday"], "underline magenta", False], |
|
|
|
[["Thursday"], "underline green", False], |
|
|
|
[["Friday"], "underline yellow", False], |
|
|
|
[["Saturday"], "underline blue", False], |
|
|
|
[["#r"], "black on blue", False], |
|
|
|
[["#g"], "black on yellow", False], |
|
|
|
[["#w"], "red", False], |
|
|
|
[["#p"], "green", False], |
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
def recolor(project_data, colors): # {{{ |
|
|
|
for rule in colors: |
|
|
|
keywords = rule[0] |
|
|
|
color = rule[1] |
|
|
|
hide = rule[2] |
|
|
|
match = True |
|
|
|
for keyword in keywords: |
|
|
|
if keyword not in project_data["name"]: |
|
|
|
match = False |
|
|
|
break |
|
|
|
if match: |
|
|
|
project_data["name"] = f"[{color}]{project_data['name']}[/]" |
|
|
|
if hide: |
|
|
|
for keyword in keywords: |
|
|
|
project_data["name"] = project_data["name"].replace(keyword, "") |
|
|
|
project_data["name"] = project_data["name"].rstrip() + f" [base01]{" ".join(keywords)}[/]" |
|
|
|
|
|
|
|
children = project_data.get("children", []) |
|
|
|
for child in children: |
|
|
|
recolor(child, colors) |
|
|
|
return project_data |
|
|
|
# }}} |
|
|
|
|
|
|
|
def print_pretty(data, indent=0, color="grey", show_description=True, show_id=False): # {{{ |
|
|
|
try: |
|
|
|
for item in data["children"]: |
|
|
|
if item["name"] == "backlinks": |
|
|
|
# console.print(" " * indent + "[base01]• backlinks[/]") |
|
|
|
continue |
|
|
|
console.print(" " * indent + f"[base3]•[/] [{color}]{item['name']}[/][base01]{' ' + item['id'].split('-')[4] if show_id else ''}[/]") |
|
|
|
if item["description"] and show_description: |
|
|
|
console.print(" " * (indent + 1) + f"[base01]{item['description'].replace('\n', '\n' + ' ' * (indent + 1))}[/]") |
|
|
|
if item["children"]: |
|
|
|
print_pretty(item, indent + 1, color, show_description=show_description, show_id=show_id) |
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
console.log(f"Error: {e}") |
|
|
|
|
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def generate_d3_mindmap_html(data, show_description=True, show_id=False): # {{{ |
|
|
|
import json |
|
|
|
import re |
|
|
|
|
|
|
|
# Map of Rich tag colors to hex values or CSS color names |
|
|
|
color_map = { |
|
|
|
"base3": "#073642", |
|
|
|
"base2": "#002b36", |
|
|
|
"base1": "#586e75", |
|
|
|
"base0": "#657b83", |
|
|
|
"base00": "#839496", |
|
|
|
"base01": "#93a1a1", |
|
|
|
"base02": "#eee8d5", |
|
|
|
"base03": "#fdf6e3", |
|
|
|
"yellow": "#b58900", |
|
|
|
"orange": "#cb4b16", |
|
|
|
"red": "#dc322f", |
|
|
|
"magenta": "#d33682", |
|
|
|
"violet": "#6c71c4", |
|
|
|
"blue": "#268bd2", |
|
|
|
"cyan": "#2aa198", |
|
|
|
"green": "#859900", |
|
|
|
} |
|
|
|
|
|
|
|
def parse_rich_tags(text): |
|
|
|
text = re.sub(r"\[(underline)? ?([a-zA-Z0-9]+)?\](.*?)\[/\]", |
|
|
|
lambda m: f'<tspan style="{"text-decoration: underline;" if m.group(1) else ""}' |
|
|
|
f'{"fill: " + color_map[m.group(2)] + ";" if m.group(2) else ""}">{m.group(3)}</tspan>', |
|
|
|
text) |
|
|
|
return text |
|
|
|
|
|
|
|
def remove_rich_tags(text): |
|
|
|
text = re.sub(r"\[(underline)? ?([a-zA-Z0-9]+)?\](.*?)\[/\]", r"\3", text) |
|
|
|
return text |
|
|
|
|
|
|
|
# Recursively transform data to add the parsed Rich-style text |
|
|
|
def transform_data(item): |
|
|
|
node = { |
|
|
|
"name": remove_rich_tags(parse_rich_tags(item["name"])), # Parse Rich tags in name |
|
|
|
"id": item["id"].split("-")[4] if show_id else "", |
|
|
|
"description": parse_rich_tags(item["description"]) if show_description else "" |
|
|
|
} |
|
|
|
if item["children"]: |
|
|
|
node["children"] = [transform_data(child) for child in item["children"]] |
|
|
|
return node |
|
|
|
|
|
|
|
transformed_data = transform_data(data) |
|
|
|
|
|
|
|
# Create the HTML content with D3.js code |
|
|
|
html_content = f""" |
|
|
|
<!DOCTYPE html> |
|
|
|
<html lang="en"> |
|
|
|
<head> |
|
|
|
<meta charset="UTF-8"> |
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
|
<title>Mind Map</title> |
|
|
|
<script src="https://d3js.org/d3.v6.min.js"></script> |
|
|
|
<style> |
|
|
|
body {{ |
|
|
|
font-family: Arial, sans-serif; |
|
|
|
background-color: #073642; |
|
|
|
color: #fdf6e3; |
|
|
|
}} |
|
|
|
.node {{ |
|
|
|
cursor: pointer; |
|
|
|
}} |
|
|
|
.node circle {{ |
|
|
|
fill: #586e75; |
|
|
|
stroke: #586e75; |
|
|
|
stroke-width: 3px; |
|
|
|
}} |
|
|
|
.node text {{ |
|
|
|
# color: #fdf6e3 !important; |
|
|
|
fill: #fdf6e3; |
|
|
|
font: 12px sans-serif; |
|
|
|
}} |
|
|
|
.link {{ |
|
|
|
fill: none; |
|
|
|
stroke: #586e75; |
|
|
|
stroke-width: 2px; |
|
|
|
}} |
|
|
|
</style> |
|
|
|
</head> |
|
|
|
<body> |
|
|
|
<svg width="100vw" height="100vh"></svg> |
|
|
|
|
|
|
|
<script> |
|
|
|
var data = {json.dumps(transformed_data)}; |
|
|
|
|
|
|
|
var svg = d3.select("svg") |
|
|
|
.attr("width", "100vw") |
|
|
|
.attr("height", "100vh"); |
|
|
|
|
|
|
|
var zoom = d3.zoom() |
|
|
|
.scaleExtent([0.5, 5]) |
|
|
|
.on("zoom", function(event) {{ |
|
|
|
g.attr("transform", "translate(" + event.transform.x + "," + event.transform.y + ") scale(" + event.transform.k + ") rotate(" + currentRotation + ")"); |
|
|
|
currentTransform = event.transform; |
|
|
|
}}); |
|
|
|
|
|
|
|
svg.call(zoom); |
|
|
|
|
|
|
|
var g = svg.append("g"); |
|
|
|
|
|
|
|
// .attr("transform", "translate(" + window.innerWidth / 2 + "," + window.innerHeight / 2 + ")"); |
|
|
|
|
|
|
|
var currentRotation = 0; |
|
|
|
var currentTransform = d3.zoomIdentity; |
|
|
|
var isRotating = false; |
|
|
|
var lastX = 0; |
|
|
|
|
|
|
|
document.addEventListener("keydown", function(event) {{ |
|
|
|
if (event.key === "r" || event.key === "R") {{ |
|
|
|
isRotating = !isRotating; |
|
|
|
}} |
|
|
|
}}); |
|
|
|
|
|
|
|
svg.on("mousedown", function(event) {{ |
|
|
|
if (isRotating) {{ |
|
|
|
lastX = event.clientX; |
|
|
|
}} |
|
|
|
}}); |
|
|
|
|
|
|
|
svg.on("mousemove", function(event) {{ |
|
|
|
if (isRotating) {{ |
|
|
|
var dx = event.clientX - lastX; |
|
|
|
currentRotation += dx * 0.1; |
|
|
|
g.attr("transform", "translate(" + currentTransform.x + "," + currentTransform.y + ") scale(" + currentTransform.k + ") rotate(" + currentRotation + ")"); |
|
|
|
lastX = event.clientX; |
|
|
|
}} |
|
|
|
}}); |
|
|
|
|
|
|
|
svg.on("mouseup", function() {{ |
|
|
|
isRotating = false; |
|
|
|
}}); |
|
|
|
|
|
|
|
var tree = d3.tree() |
|
|
|
.size([360, Math.min(window.innerWidth, window.innerHeight) / 2 - 100]) |
|
|
|
.separation(function(a, b) {{ return (a.parent === b.parent ? 1 : 2) / a.depth; }}); |
|
|
|
|
|
|
|
var root = d3.hierarchy(data); |
|
|
|
|
|
|
|
tree(root); |
|
|
|
|
|
|
|
var link = g.selectAll(".link") |
|
|
|
.data(root.links()) |
|
|
|
.enter().append("path") |
|
|
|
.attr("class", "link") |
|
|
|
.attr("d", d3.linkRadial() |
|
|
|
.angle(function(d) {{ return d.x / 180 * Math.PI; }}) |
|
|
|
.radius(function(d) {{ return d.y; }})); |
|
|
|
|
|
|
|
var node = g.selectAll(".node") |
|
|
|
.data(root.descendants()) |
|
|
|
.enter().append("g") |
|
|
|
.attr("class", "node") |
|
|
|
.attr("transform", function(d) {{ return "translate(" + radialPoint(d.x, d.y) + ")"; }}); |
|
|
|
|
|
|
|
node.append("circle") |
|
|
|
.attr("r", 4.5); |
|
|
|
|
|
|
|
node.append("text") |
|
|
|
.attr("dy", ".31em") |
|
|
|
.attr("x", 10) |
|
|
|
.attr("text-anchor", "start") |
|
|
|
.attr("transform", function(d) {{ |
|
|
|
return "rotate(" + (d.x - 90) + ") translate(10, 0)"; |
|
|
|
}}) |
|
|
|
.html(function(d) {{ |
|
|
|
return d.data.name; |
|
|
|
}}); |
|
|
|
|
|
|
|
function radialPoint(x, y) {{ |
|
|
|
return [(y = +y) * Math.cos((x - 90) / 180 * Math.PI), y * Math.sin((x - 90) / 180 * Math.PI)]; |
|
|
|
}} |
|
|
|
</script> |
|
|
|
</body> |
|
|
|
</html> |
|
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Write the HTML content to a file |
|
|
|
with open("d3_mindmap.html", "w") as f: |
|
|
|
f.write(html_content) |
|
|
|
|
|
|
|
print("D3.js mind map HTML generated as 'd3_mindmap.html'.") |
|
|
|
|
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def find_project_by_id(project_data, full_data, target_id): # {{{ |
|
|
|
if project_data.get("id"): |
|
|
|
if target_id in project_data.get("id"): |
|
|
|
return project_data, full_data |
|
|
|
for child in project_data.get("ch", []): |
|
|
|
result, full_data = find_project_by_id(child, full_data, target_id) |
|
|
|
if result: |
|
|
|
return result, full_data |
|
|
|
return None, full_data |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def show(parent_id, flat=False, filters_all=None, filters_any=None, color="grey", follow_mirrors=False, include_headers=False, show_description=True, show_id=False, all_children=False): # {{{ |
|
|
|
root_data = load_from_storage("root") |
|
|
|
project_data, root_data = find_project_by_id(root_data, root_data, parent_id) |
|
|
|
project_data, root_data = simplify_project(project_data, root_data, follow_mirrors=follow_mirrors) |
|
|
|
if flat: |
|
|
|
project_data = flatten_project(project_data) |
|
|
|
if filters_all is not None: |
|
|
|
project_data = filter_project_all(project_data, filters_all, include_headers=include_headers, all_children=all_children) |
|
|
|
if filters_any is not None: |
|
|
|
project_data = filter_project_any(project_data, filters_any, include_headers=include_headers, all_children=all_children) |
|
|
|
project_data = replace(project_data, r" *<", "<") |
|
|
|
project_data = replace(project_data, r" *$", "") |
|
|
|
project_data = recolor(project_data, colors1) |
|
|
|
project_data = strip(project_data, r"<a href=\".*\">.*</a>") |
|
|
|
project_data = strip(project_data, r"<time .*</time>") |
|
|
|
project_data = strip(project_data, r"<[^>]*>") |
|
|
|
project_data = strip(project_data, r"</[^>]*>") |
|
|
|
project_data = highlight(project_data) |
|
|
|
# project_data = recolorBacklinks(project_data) |
|
|
|
project_data = remove_double_spaces(project_data) |
|
|
|
# console.print(f"\n[base3][bold]{project_data['name']}[/][/]") |
|
|
|
# if project_data.get("description") and show_description: |
|
|
|
# console.print(f"[base01]{project_data['description']}[/]") |
|
|
|
console.print("") |
|
|
|
print_pretty(project_data, color=color, show_description=show_description, show_id=show_id) |
|
|
|
console.print("") |
|
|
|
# generate_d3_mindmap_html(project_data, show_description=show_description, show_id=show_id) |
|
|
|
return True |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
def dump(): # {{{ |
|
|
|
storage = load_storage() |
|
|
|
print(json.dumps(storage, indent=2)) |
|
|
|
|
|
|
|
# }}} |
|
|
|
|
|
|
|
|
|
|
|
@click.group(invoke_without_command=True) |
|
|
|
@click.option("--refresh", is_flag=True, help="Refresh session cookie and Workflowy data") |
|
|
|
@click.option("--debug", is_flag=True, help="Enable debug mode") |
|
|
|
@click.pass_context |
|
|
|
def cli(ctx, refresh, debug): |
|
|
|
""" |
|
|
|
Workdlowy CLI |
|
|
|
""" |
|
|
|
if debug: |
|
|
|
logger.setLevel(logging.DEBUG) |
|
|
|
logger.debug("Debug mode enabled") |
|
|
|
if refresh: |
|
|
|
refresh_cookie() |
|
|
|
refresh_workflowy_data() |
|
|
|
if ctx.invoked_subcommand is None: |
|
|
|
click.echo(ctx.get_help()) |
|
|
|
|
|
|
|
|
|
|
|
@cli.command() |
|
|
|
@click.argument("name", nargs=-1) |
|
|
|
def inbox(name): |
|
|
|
"""Inbox commands""" |
|
|
|
if name: |
|
|
|
name_text = ' '.join(name) |
|
|
|
clip_to_workflowy(name_text, "", INBOX_ID) |
|
|
|
else: |
|
|
|
show(INBOX_ID) |
|
|
|
|
|
|
|
|
|
|
|
@cli.command() |
|
|
|
@click.option("--hide-comments", is_flag=True, help="Do not show comments") |
|
|
|
@click.option("--hide-headers", is_flag=True, help="Hide headers") |
|
|
|
@click.option("--show-id", is_flag=True, help="Show item id") |
|
|
|
@click.argument("filter", nargs=-1) |
|
|
|
def tasks(filter, hide_comments, hide_headers, show_id): |
|
|
|
"""Tasks commands""" |
|
|
|
if filter: |
|
|
|
show( |
|
|
|
TASKS_ID, |
|
|
|
filters_all=filter, |
|
|
|
flat=True, |
|
|
|
follow_mirrors=True, |
|
|
|
include_headers=True, |
|
|
|
show_description=not hide_comments, |
|
|
|
show_id=show_id, |
|
|
|
) |
|
|
|
else: |
|
|
|
show( |
|
|
|
TASKS_ID, |
|
|
|
follow_mirrors=True, |
|
|
|
show_description=not hide_comments, |
|
|
|
show_id=show_id, |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
@cli.command() |
|
|
|
@click.argument("filter", nargs=-1) |
|
|
|
def today(filter): |
|
|
|
"""Today commands""" |
|
|
|
t = get_today() |
|
|
|
if filter: |
|
|
|
show( |
|
|
|
TASKS_ID, |
|
|
|
filters_all=[t] + list(filter), |
|
|
|
flat=False, |
|
|
|
follow_mirrors=True, |
|
|
|
include_headers=True, |
|
|
|
show_description=True, |
|
|
|
show_id=False, |
|
|
|
) |
|
|
|
else: |
|
|
|
show( |
|
|
|
TASKS_ID, |
|
|
|
filters_all=[t], |
|
|
|
flat=False, |
|
|
|
follow_mirrors=True, |
|
|
|
include_headers=True, |
|
|
|
show_description=True, |
|
|
|
show_id=False, |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
@cli.command() |
|
|
|
def week(): |
|
|
|
"""Today commands""" |
|
|
|
sunday = get_sunday() |
|
|
|
logger.debug(f"Sunday: {sunday}") |
|
|
|
show( |
|
|
|
DIARY_ID, |
|
|
|
filters_all=[sunday], |
|
|
|
flat=False, |
|
|
|
follow_mirrors=True, |
|
|
|
include_headers=True, |
|
|
|
show_description=False, |
|
|
|
show_id=False, |
|
|
|
all_children=True |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
@cli.command(name="dump") |
|
|
|
def dump_cmd(): |
|
|
|
"""Dump storage""" |
|
|
|
dump() |
|
|
|
|
|
|
|
|
|
|
|
@cli.command(name="refresh") |
|
|
|
def refresh_cmd(): |
|
|
|
"""Refresh session cookie and Workflowy data""" |
|
|
|
refresh_cookie() |
|
|
|
refresh_workflowy_data() |
|
|
|
|
|
|
|
|
|
|
|
# Dynamically add commands for each planner in PLANNER_IDS. |
|
|
|
def create_planner_command(planner_name, planner_id): |
|
|
|
@click.command(name=planner_name, help=f"{planner_name.capitalize()} commands") |
|
|
|
@click.option("--hide-comments", is_flag=True, help="Do not show comments") |
|
|
|
@click.option("--flat", is_flag=True, help="Show flat list") |
|
|
|
@click.option("--hide-headers", is_flag=True, help="Hide headers") |
|
|
|
@click.option("--show-id", is_flag=True, help="Show item id") |
|
|
|
@click.argument("filter", nargs=-1) |
|
|
|
def planner(filter, hide_comments, flat, hide_headers, show_id): |
|
|
|
if filter: |
|
|
|
show( |
|
|
|
planner_id, |
|
|
|
filters_all=filter, |
|
|
|
show_description=not hide_comments, |
|
|
|
flat=flat, |
|
|
|
include_headers=not hide_headers, |
|
|
|
show_id=show_id, |
|
|
|
) |
|
|
|
else: |
|
|
|
show( |
|
|
|
planner_id, |
|
|
|
show_description=not hide_comments, |
|
|
|
flat=flat, |
|
|
|
include_headers=not hide_headers, |
|
|
|
show_id=show_id, |
|
|
|
) |
|
|
|
return planner |
|
|
|
|
|
|
|
|
|
|
|
for planner_name, planner_id in PLANNER_IDS.items(): |
|
|
|
cli.add_command(create_planner_command(planner_name, planner_id)) |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
cli() |