From cb8d3170e48387742c07d72aaf0a0a63299bfa9e Mon Sep 17 00:00:00 2001 From: dtf <826902+dtf0@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:49:18 -0700 Subject: [PATCH 1/4] Refactor: loop dispatch, urllib, error handling Added: - ZingtreeClient over urllib, with response capture and error handling - Agent dataclass and validating zingers.txt parser - Lookup tables (MAIN_ITEMS, SUBMENUS, OPERATIONS) for dispatch - Type hints and KeyboardInterrupt handling Changed: - Menu navigation is now a while loop, not recursion - URL path segments are percent-encoded - zingers.txt is read only when an operation needs it - Token check handles a missing variable Removed: - pycurl (now stdlib urllib); dotenv import optional - Duplicate exit helpers, dead branches, sleep delays --- zinger.py | 873 +++++++++++++++++++++++------------------------------- 1 file changed, 372 insertions(+), 501 deletions(-) diff --git a/zinger.py b/zinger.py index 97c39dd..bc56cd0 100755 --- a/zinger.py +++ b/zinger.py @@ -1,520 +1,391 @@ #!/usr/bin/env python3 +""" +zinger -- a small interactive CLI for the Zingtree API. -from dotenv import load_dotenv -from pathlib import Path -from os.path import join +API reference : https://help.zingtree.com/hc/en-us/articles/5048766502043-Zingtree-API +API key : https://zingtree.com/account/organizations.php -> `API Key` + Store it in a `.env` file as `ZINGTREE_API_TOKEN="..."`, or + temporary environment variable via executing the following: + `export ZINGTREE_API_TOKEN="..."` + +This project is libre and licenced APACHE-2.0; see the COPYING file or +https://www.apache.org/licenses/LICENSE-2.0 for more details. + +Copyright (c) 2019, Dropbox, Inc. +Author: Dillon Feeney. + +Created: 2019-08-20 +Updated: 2020-05-07 +""" + +from __future__ import annotations import os -import pycurl -import subprocess as subp import sys -import time - -################################################################ -# -# Zingtree API: -# https://zingtree.com/api/ -# -# API token available from: -# https://zingtree.com/account/organizations.php -> `API Key` -# Add token to .env file as `ZINGTREE_API_TOKEN=""` -# -# This project is libre and licenced APACHE-2.0; see the COPYING file or -# https://www.apache.org/licenses/LICENSE-2.0 for more details. -# -################################################################ - -env_path = Path(".") / ".env" -load_dotenv(dotenv_path=env_path) - -zt_list = "zingers.txt" -zt_api_url = "https://zingtree.com/api/" -zt_token = os.getenv("ZINGTREE_API_TOKEN") - -################################################################ - -def call_zt(zt_url): - """ - makes call to Zingtree API endpoint - """ - # only show first 8 chars of token - zt_url_clean = zt_url.replace(zt_token, zt_token[:5] + "…") - print("\nCalling URL... {}".format(zt_url_clean)) - pc = pycurl.Curl() - pc.setopt(pc.URL, zt_url) - pc.perform() - pc.close() - -################################################################ - -def main(): - if zt_token == "": - print("Token null, please update ZINGTREE_API_TOKEN in `.env` file.") - time.sleep(3) - quit_now() - zt_oper = "" - menu(input_menu="main") - input_menu = input("\nSelect Option from above: " + uil()) - if input_menu == "0": - menu(input_menu) - input_users = input("\nSelect Option from above: " + uil()) - if input_users == "0": - zt_oper = "agent_add" - elif input_users == "1": - zt_oper = "agent_add_inter" - elif input_users == "2": - zt_oper = "agent_tag" - elif input_users == "3": - zt_oper = "agent_remove" - elif input_users == "4": - zt_oper = "agent_remove_inter" - elif input_users.upper() == "Q": - quit_now() - else: - main() - - elif input_menu == "1": - menu(input_menu) - input_trees = input("\nSelect Option from above: " + uil()) - if input_trees == "0": - zt_oper = "search_trees" - elif input_trees == "1": - zt_oper = "get_trees" - elif input_trees == "2": - zt_oper = "get_tags" - elif input_trees == "3": - zt_oper = "get_tree_tag_any" - elif input_trees == "4": - zt_oper = "get_tree_tag_all" - elif input_trees.upper() == "Q": - quit_now() - else: - main() - - elif input_menu == "2": - menu(input_menu) - input_forms = input("\nSelect Option from above: " + uil()) - if input_forms == "0": - zt_oper = "get_form_data" - elif input_forms == "1": - zt_oper = "delete_form_data" - elif input_forms.upper() == "Q": - quit_now() - else: - main() - - elif input_menu == "3": - menu(input_menu) - input_sessions = input("\nSelect Option from above: " + uil()) - if input_sessions == "0": - zt_oper = "agent_sessions" - elif input_sessions == "1": - zt_oper = "tree_sessions" - elif input_sessions == "2": - zt_oper = "get_session_data" - elif input_sessions == "3": - zt_oper = "get_session_data_pure" - elif input_sessions == "4": - zt_oper = "get_session_notes" - elif input_sessions == "5": - zt_oper = "event_log" - elif input_sessions.upper() == "Q": - quit_now() - else: - main() - - elif input_menu == "00": - headers() - time.sleep(5) - subp.call(["clear"]) - - elif input_menu.upper() == "Q": - quit_now() - - else: - main() +from dataclasses import dataclass, field +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import urlopen - handle_decision(zt_oper) - if "agent" in zt_oper: - print("Go to agents list: https://zingtree.com/account/agents.php") - -################################################################ - -def menu(input_menu): - length_border = "=" * 80 - top_border = "\n" + "╭╭" + length_border + "╮╮\n||" - bottom_border = "\n||\n╰╰" + length_border + "╯╯" - - if input_menu == "main": - print(top_border + """ -|| zinger | main menu\n|| -|| ================\n|| -|| [0] agents (updates agents) -|| [1] trees (get info on trees) -|| [2] forms (get or remove form info entered on trees) -|| [3] sessions (tree and agent session info)\n|| -|| [00] μετά (prints meta)\n|| -|| ================\n|| -|| [Q] Q to quit""" + bottom_border) - - elif input_menu == "0": - print(top_border + """ -|| zinger | agents menu\n|| -|| ================\n|| -|| [0] auto `agent_add` (from file) -|| [1] manual `agent_add` (interactive) -|| [2] auto `agent_tag` (from file) -|| [3] auto `agent_remove` (removes agents) -|| [4] manual `agent_remove` (interactive)\n|| -|| ================\n|| -|| zingers.txt doc should be formatted like:\n|| -|| `bwinters@zingtree.com,Bob Winters,tag_0,tag_1`\n|| -|| ================\n|| -|| [Q] Q to quit""" + bottom_border) - - elif input_menu == "1": - print(top_border + """ -|| zinger | trees menu\n|| -|| ================\n|| -|| [0] `search_trees` (search all trees for matching text) -|| [1] `get_trees` (fetches all trees) -|| [2] `get_tags` (fetches all tags used on trees) -|| [3] `get_tree_tag_any` (gets trees matching \x1B[3mANY\x1B[23m tags) -|| [4] `get_tree_tag_all` (gets trees matching \033[1mALL\033[0m tags)\n|| -|| ================\n|| -|| [Q] Q to quit""" + bottom_border) - - elif input_menu == "2": - print(top_border + """ -|| zinger | forms menu\n|| -|| ================\n|| -|| [0] `get_form_data` (form values entered during a session) -|| [1] `delete_form_data` (deletes any form data)\n|| -|| ================\n|| -|| [Q] Q to quit""" + bottom_border) - - elif input_menu == "3": - print(top_border + """ -|| zinger | sessions menu\n|| -|| ================\n|| -|| [0] `agent_sessions` (lists agent sessions) -|| [1] `tree_sessions` (lists tree sessions) -|| [2] `get_session_data` (returns form values entered during session) -|| [3] `get_session_data_pure` (`get_session_data`, with linear path to tree) -|| [4] `get_session_notes` (returns agent notes from session) -|| [5] `event_log` (returns event log for date range)\n|| -|| ================\n|| -|| [Q] Q to quit""" + bottom_border) - -################################################################ - -def uil(): - """ - user_input_line(): - """ - user_input = "\n\n ==> " - return user_input +try: + from dotenv import load_dotenv +except ImportError: # dotenv is optional + def load_dotenv(*_args, **_kwargs): # noqa: D401 + return False -def input_agent_info(): - zt_name = input('\nEnter agent\'s name...' + uil()) - zt_email = input('\nEnter agent\'s email...' + uil()) - zt_tags = input('\nEnter agent\'s tags...' + uil()) - return zt_name, zt_email, zt_tags +# ---------------------------------------------------------------------------- +# Configuration +# ---------------------------------------------------------------------------- -def input_agent_email(): - zt_email = input('\nEnter agent\'s email...' + uil()) - return zt_email +API_URL = "https://zingtree.com/api/" +ENV_FILE = Path(".") / ".env" +AGENT_FILE = Path("zingers.txt") +TIMEOUT = 30 +PROMPT = "\n\n ==> " -def quit_now(): - """ - quits application nicely - """ - print("\nQuitting... Goodbye...\n") - time.sleep(0.3) - sys.exit(0) +load_dotenv(dotenv_path=ENV_FILE) -def exit_now(): - """ - quits applications not nicely - """ - print("\nExiting, bad input...\n") - sys.exit(1) - -################################################################ - -def headers(): - __copyright__ = "" - print(""" -╭╭===================================================╮╮\n|| -|| Name :: zinger -|| Source :: https://github.com/dropbox/zinger -|| Contact :: dillon. -|| Licence :: Apache-2.0\n|| -|| Copyright © 2019 Dropbox, Inc.\n|| -|| Made with 🧁 at ◇⁵.\n|| -╰╰===================================================╯╯ - """) - -def print_doc(zt_oper_en, lines): - for line in lines: - line = line.split(",") - print(""" - Name : {} - Email : {} - Tags : {} - """.format(line[1], line[0], line[2:])) - # Italicise and embolden action to be taken - confirm = input(""" This will \033[1m\x1B[3m{}\x1B[23m\033[0m the above agents...\n - Continue? (Y/n)""".format(zt_oper_en) + uil()) - return confirm - -def handle_decision(zt_oper): +# ---------------------------------------------------------------------------- +# Domain model +# ---------------------------------------------------------------------------- + +class ZingtreeError(RuntimeError): + """Raised for any expected, recoverable failure.""" + + +@dataclass +class Agent: + email: str + name: str + tags: list[str] = field(default_factory=list) + + +@dataclass +class ZingtreeClient: + """Thin wrapper over the Zingtree REST endpoints. + + The API embeds the key and all arguments in the URL path, so every + segment is percent-encoded. Commas are preserved because tag lists are + comma-delimited inside a single segment. """ - gets users from list of formatted lines in text doc - `[email@domain],[Forename Surname],[comma-separated tags]` + token: str + base_url: str = API_URL + timeout: int = TIMEOUT + + def _mask(self, url: str) -> str: + return url.replace(self.token, self.token[:5] + "...") + + def call(self, *segments: str) -> str: + path = "/".join(quote(str(s), safe=",") for s in segments) + url = self.base_url + path + print("\nCalling URL... {}".format(self._mask(url))) + try: + with urlopen(url, timeout=self.timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + except HTTPError as exc: + raise ZingtreeError("HTTP {} from {}".format(exc.code, self._mask(url))) from exc + except URLError as exc: + raise ZingtreeError("Request failed: {}".format(exc.reason)) from exc + print(body) + return body + + +def load_agents(path: Path = AGENT_FILE) -> list[Agent]: + """Parse `zingers.txt`. + + Each line: email,Forename Surname,tag_0,tag_1 """ + if not path.exists(): + raise ZingtreeError("{} not found.".format(path)) - try: - text_doc = open(zt_list).readlines() - except FileNotFoundError: - print("{} doc not found.\n".format(zt_list)) - exit_now() + agents: list[Agent] = [] + for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + raw = raw.strip() + if not raw: + continue + fields = [f.strip() for f in raw.split(",")] + if len(fields) < 2: + raise ZingtreeError("Malformed line {}: {!r}".format(lineno, raw)) + email, name, *tags = fields + agents.append(Agent(email=email, name=name, tags=tags)) - lines = [line.rstrip("\n") for line in text_doc] + if not agents: + raise ZingtreeError("{} is empty.".format(path)) + return agents - if zt_oper == "main": - main() - elif zt_oper == "agent_add": - # zt_api/agent_add/{{apikey}}/{{agent name}}/{{agent login}} - zt_oper_en = "[ADD] & [TAG]" - confirm = print_doc(zt_oper_en, lines) - if confirm.upper() == "Y": - print("Creating Zingtree agents...\n") - for line in lines: - line = line.split(",") - zt_email = line[0].strip() - zt_oper = "agent_add" - zt_name = line[1].strip() - zt_url = "{}{}/{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_name, - zt_email) - call_zt(zt_url) - zt_oper = "agent_tag" - zt_tags = "/" + ",".join(map(str, line[2:])).strip() - zt_url = "{}{}/{}/{}{}".format(zt_api_url, - zt_oper, - zt_token, - zt_email, - zt_tags) - call_zt(zt_url) - else: - quit_now() - - elif zt_oper == "agent_add_inter": - zt_oper = "agent_add" - zt_name, zt_email, zt_tags = input_agent_info() - zt_url = "{}{}/{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_name, - zt_email) - call_zt(zt_url) - zt_oper = "agent_tag" - zt_url = "{}{}/{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_email, - zt_tags) - call_zt(zt_url) - - elif zt_oper == "agent_tag": - # zt_api/agent_tag/{{apikey}}/{{agent login}}/{{tags}} - zt_oper_en = "[TAG]" - confirm = print_doc(zt_oper_en, lines) - if confirm.upper() == "Y": - print("Updating Zingtree agents' tag(s)...\n") - for line in lines: - line = line.split(",") - zt_email = line[0].strip() - zt_tags = "/" + ",".join(map(str, line[2:])).strip() - zt_url = "{}{}/{}/{}{}".format(zt_api_url, - zt_oper, - zt_token, - zt_email, - zt_tags) - call_zt(zt_url) - else: - quit_now() - - elif zt_oper == "agent_remove": - # zt_api/agent_remove/{{apikey}}/{{agent login}} - zt_oper_en = "[REMOVE]" - confirm = print_doc(zt_oper_en, lines) - if confirm.upper() == "Y": - print("Removing Zingtree agents...\n") - for line in lines: - line = line.split(",") - zt_email = line[0].strip() - zt_url = "{}{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_email) - call_zt(zt_url) - else: - quit_now() - - elif zt_oper == "agent_remove_inter": - zt_oper = "agent_remove" - zt_email = input_agent_email() - zt_url = "{}{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_email) - call_zt(zt_url) - - elif zt_oper == "get_tags": - # zt_api/tree/{{apikey}}/get_tags - print("Getting tags from tree...") - zt_url = "{}tree/{}/{}".format(zt_api_url, - zt_token, - zt_oper) - call_zt(zt_url) - - elif zt_oper == "get_trees": - # zt_api/tree/{{apikey}}/get_trees - print("Climbing trees...") - zt_url = "{}tree/{}/{}".format(zt_api_url, - zt_token, - zt_oper) - call_zt(zt_url) - - elif zt_oper == "get_tree_tag_all": - # zt_api/tree/{{apikey}}/get_tree_tag_all/{{taglist}} - print("Picking ALL tags from trees...") - zt_tags = input("\nSearch tree by comma-separated tags..." + uil()) - zt_url = "{}tree/{}/{}/{}".format(zt_api_url, - zt_token, - zt_oper, - zt_tags) - call_zt(zt_url) - - elif zt_oper == "get_tree_tag_any": - # zt_api/tree/{{apikey}}/get_tree_tag_any/{{taglist}} - print("Picking ANY tags from trees...") - zt_tags = input("\nSearch tree by comma-separated tags..." + uil()) - zt_url = "{}tree/{}/{}/{}".format(zt_api_url, - zt_token, - zt_oper, - zt_tags) - call_zt(zt_url) - - elif zt_oper == "search_trees": - # zt_api/tree/{{apikey}}/search_trees/{{search text}} - print("Scanning leaves...") - zt_query = input("\nEnter query..." + uil()) - zt_url = "{}tree/{}/{}/{}".format(zt_api_url, - zt_token, - zt_oper, - zt_query) - call_zt(zt_url) - - elif zt_oper == "get_form_data": - # zt_api/session/{{session ID}}/get_form_data - print("Getting form data...") - zt_session = input("\nEnter session_id..." + uil()) - zt_url = "{}session/{}/{}".format(zt_api_url, - zt_session, - zt_oper) - call_zt(zt_url) - - elif zt_oper == "delete_form_data": - # zt_api/session/{{session ID}}/delete_form_data - print("Deleting form data...") - zt_session = input("\nEnter session_id..." + uil()) - zt_url = "{}session/{}/{}".format(zt_api_url, - zt_session, - zt_oper) - call_zt(zt_url) - - elif zt_oper == "event_log": - # zt_api/event_log/{{apikey}}/{{start date}}/{{end date}} - print("Showing event log...") - zt_start = input("\nStart date..." + uil()) - zt_end = input("\nEnd date..." + uil()) - zt_url = "{}{}/{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_start, - zt_end) - call_zt(zt_url) - - elif zt_oper == "agent_sessions": - # zt_api/agent_sessions/{{apikey}}/{{agent}}/{{start date}}/{{end date}} - print("Gathering agent sessions...") - for line in lines: - line = line.split(",") - zt_email = line[0].strip() - zt_start = input("\nStart date - {}...".format(zt_email) + uil()) - zt_end = input("\nEnd date - {}...".format(zt_email) + uil()) - zt_url = "{}{}/{}/{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_email, - zt_start, - zt_end) - call_zt(zt_url) - - elif zt_oper == "tree_sessions": - # zt_api/tree_sessions/{{apikey}}/{{tree ID}}/{{start}}/{{end}} - print("Gathering tree sessions...") - zt_tree = input("\nEnter tree_id..." + uil()) - zt_start = input("\nStart date - {}...".format(zt_tree) + uil()) - zt_end = input("\nEnd date - {}...".format(zt_tree) + uil()) - zt_url = "{}{}/{}/{}/{}/{}".format(zt_api_url, - zt_oper, - zt_token, - zt_tree, - zt_start, - zt_end) - call_zt(zt_url) - - elif zt_oper == "get_session_data": - # zt_api/session/{{session ID}}/get_session_data - print("Getting session data...") - zt_session = input("\nEnter session_id..." + uil()) - zt_url = "{}session/{}/{}".format(zt_api_url, - zt_session, - zt_oper) - call_zt(zt_url) - - elif zt_oper == "get_session_data_pure": - # zt_api/session/{{session ID}}/get_session_data_pure - print("Getting form data...") - zt_session = input("\nEnter session_id..." + uil()) - zt_url = "{}session/{}/{}".format(zt_api_url, - zt_session, - zt_oper) - call_zt(zt_url) - - elif zt_oper == "get_session_notes": - # zt_api/session/{{session ID}}/get_session_notes - print("Getting form data...") - zt_session = input("\nEnter session_id..." + uil()) - zt_url = "{}session/{}/{}".format(zt_api_url, - zt_session, - zt_oper) - call_zt(zt_url) - - elif zt_oper.upper() == "Q": - quit_now() - - time.sleep(0.5) - main() +def confirm_agents(agents: list[Agent], action: str) -> bool: + for a in agents: + print("\n Name : {}\n Email : {}\n Tags : {}".format(a.name, a.email, a.tags)) + answer = input("\n This will {} the above agents. Continue? (y/N){}".format(action, PROMPT)) + return answer.strip().lower() == "y" + + +# ---------------------------------------------------------------------------- +# Operations +# ---------------------------------------------------------------------------- + +def op_agent_add(client: ZingtreeClient) -> None: + agents = load_agents() + if not confirm_agents(agents, "ADD and TAG"): + return + for a in agents: + client.call("agent_add", client.token, a.name, a.email) + if a.tags: + client.call("agent_tag", client.token, a.email, ",".join(a.tags)) + + +def op_agent_add_inter(client: ZingtreeClient) -> None: + name = input("\nEnter agent's name..." + PROMPT).strip() + email = input("\nEnter agent's email..." + PROMPT).strip() + tags = input("\nEnter agent's tags..." + PROMPT).strip() + client.call("agent_add", client.token, name, email) + if tags: + client.call("agent_tag", client.token, email, tags) + + +def op_agent_tag(client: ZingtreeClient) -> None: + agents = load_agents() + if not confirm_agents(agents, "TAG"): + return + for a in agents: + client.call("agent_tag", client.token, a.email, ",".join(a.tags)) + + +def op_agent_remove(client: ZingtreeClient) -> None: + agents = load_agents() + if not confirm_agents(agents, "REMOVE"): + return + for a in agents: + client.call("agent_remove", client.token, a.email) + + +def op_agent_remove_inter(client: ZingtreeClient) -> None: + email = input("\nEnter agent's email..." + PROMPT).strip() + client.call("agent_remove", client.token, email) + + +def op_search_trees(client: ZingtreeClient) -> None: + query = input("\nEnter query..." + PROMPT).strip() + client.call("tree", client.token, "search_trees", query) + + +def op_get_trees(client: ZingtreeClient) -> None: + client.call("tree", client.token, "get_trees") + + +def op_get_tags(client: ZingtreeClient) -> None: + client.call("tree", client.token, "get_tags") + + +def op_get_tree_tag_any(client: ZingtreeClient) -> None: + tags = input("\nSearch trees by comma-separated tags..." + PROMPT).strip() + client.call("tree", client.token, "get_tree_tag_any", tags) + + +def op_get_tree_tag_all(client: ZingtreeClient) -> None: + tags = input("\nSearch trees by comma-separated tags..." + PROMPT).strip() + client.call("tree", client.token, "get_tree_tag_all", tags) + + +def op_get_form_data(client: ZingtreeClient) -> None: + session = input("\nEnter session_id..." + PROMPT).strip() + client.call("session", session, "get_form_data") + + +def op_delete_form_data(client: ZingtreeClient) -> None: + session = input("\nEnter session_id..." + PROMPT).strip() + client.call("session", session, "delete_form_data") + + +def op_agent_sessions(client: ZingtreeClient) -> None: + email = input("\nEnter agent email..." + PROMPT).strip() + start = input("\nStart date (YYYY-MM-DD)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD)..." + PROMPT).strip() + client.call("agent_sessions", client.token, email, start, end) + + +def op_tree_sessions(client: ZingtreeClient) -> None: + tree = input("\nEnter tree_id..." + PROMPT).strip() + start = input("\nStart date (YYYY-MM-DD)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD)..." + PROMPT).strip() + client.call("tree_sessions", client.token, tree, start, end) + + +def op_get_session_data(client: ZingtreeClient) -> None: + session = input("\nEnter session_id..." + PROMPT).strip() + client.call("session", session, "get_session_data") + + +def op_get_session_data_pure(client: ZingtreeClient) -> None: + session = input("\nEnter session_id..." + PROMPT).strip() + client.call("session", session, "get_session_data_pure") + + +def op_get_session_notes(client: ZingtreeClient) -> None: + session = input("\nEnter session_id..." + PROMPT).strip() + client.call("session", session, "get_session_notes") + + +def op_event_log(client: ZingtreeClient) -> None: + start = input("\nStart date (YYYY-MM-DD)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD)..." + PROMPT).strip() + client.call("event_log", client.token, start, end) + + +OPERATIONS = { + "agent_add": op_agent_add, + "agent_add_inter": op_agent_add_inter, + "agent_tag": op_agent_tag, + "agent_remove": op_agent_remove, + "agent_remove_inter": op_agent_remove_inter, + "search_trees": op_search_trees, + "get_trees": op_get_trees, + "get_tags": op_get_tags, + "get_tree_tag_any": op_get_tree_tag_any, + "get_tree_tag_all": op_get_tree_tag_all, + "get_form_data": op_get_form_data, + "delete_form_data": op_delete_form_data, + "agent_sessions": op_agent_sessions, + "tree_sessions": op_tree_sessions, + "get_session_data": op_get_session_data, + "get_session_data_pure": op_get_session_data_pure, + "get_session_notes": op_get_session_notes, + "event_log": op_event_log, +} + +# ---------------------------------------------------------------------------- +# Menus +# ---------------------------------------------------------------------------- + +MAIN_ITEMS = { + "0": ("agents", "update agents"), + "1": ("trees", "get info on trees"), + "2": ("forms", "get or remove form data"), + "3": ("sessions", "tree and agent session info"), +} + +SUBMENUS = { + "0": ("agents", [ + ("auto agent_add (from file)", "agent_add"), + ("manual agent_add (interactive)", "agent_add_inter"), + ("auto agent_tag (from file)", "agent_tag"), + ("auto agent_remove (from file)", "agent_remove"), + ("manual agent_remove (interactive)", "agent_remove_inter"), + ]), + "1": ("trees", [ + ("search_trees (search all trees for matching text)", "search_trees"), + ("get_trees (fetch all trees)", "get_trees"), + ("get_tags (fetch all tags used on trees)", "get_tags"), + ("get_tree_tag_any (trees matching ANY tags)", "get_tree_tag_any"), + ("get_tree_tag_all (trees matching ALL tags)", "get_tree_tag_all"), + ]), + "2": ("forms", [ + ("get_form_data (form values entered during a session)", "get_form_data"), + ("delete_form_data (delete any form data)", "delete_form_data"), + ]), + "3": ("sessions", [ + ("agent_sessions (list agent sessions)", "agent_sessions"), + ("tree_sessions (list tree sessions)", "tree_sessions"), + ("get_session_data (form values entered during session)", "get_session_data"), + ("get_session_data_pure (linear path to tree)", "get_session_data_pure"), + ("get_session_notes (agent notes from session)", "get_session_notes"), + ("event_log (event log for a date range)", "event_log"), + ]), +} + +META = """ +╭─────────────────────────────────────────────────╮ +│ Name :: zinger +│ Source :: https://github.com/dropbox/zinger +│ Author :: Dillon Feeney +│ Licence :: Apache-2.0 +│ Copyright (c) 2019 Dropbox, Inc. +╰─────────────────────────────────────────────────╯ +""" + + +def render(title: str, lines: list[str]) -> str: + border = "═" * 70 + out = ["\n╭" + border + "╮", "│ zinger | {}".format(title), "│"] + out.extend("│ {}".format(line) for line in lines) + out += ["│", "│ [Q] quit", "╰" + border + "╯"] + return "\n".join(out) + + +def render_main() -> str: + lines = ["[{}] {:9s} ({})".format(k, name, desc) for k, (name, desc) in MAIN_ITEMS.items()] + lines.append("[00] meta (print metadata)") + return render("main menu", lines) + + +def render_submenu(title: str, items: list[tuple[str, str]]) -> str: + lines = ["[{}] {}".format(i, label) for i, (label, _) in enumerate(items)] + return render("{} menu".format(title), lines) + + +def clear() -> None: + os.system("cls" if os.name == "nt" else "clear") + + +# ---------------------------------------------------------------------------- +# Main loop +# ---------------------------------------------------------------------------- + +def run_submenu(client: ZingtreeClient, key: str) -> None: + title, items = SUBMENUS[key] + print(render_submenu(title, items)) + choice = input("\nSelect option..." + PROMPT).strip() + if choice.lower() == "q": + return + if not choice.isdigit() or not 0 <= int(choice) < len(items): + print("Invalid option.") + return + + op_key = items[int(choice)][1] + try: + OPERATIONS[op_key](client) + except ZingtreeError as exc: + print("\nError: {}".format(exc)) + return + if op_key.startswith("agent"): + print("\nAgents list: https://zingtree.com/account/agents.php") + + +def main() -> None: + clear() + token = os.getenv("ZINGTREE_API_TOKEN") + if not token: + sys.exit(" + Environment variable ZINGTREE_API_TOKEN missing or empty in `.env`. Either + set the variable manually (`export ZINGTREE_API_TOKEN='token' or in + the .env file -- this file is .gitignore'd) + ") + client = ZingtreeClient(token=token) + + while True: + print(render_main()) + choice = input("\nSelect option..." + PROMPT).strip() + + if choice.lower() == "q": + print("\nQuitting... Goodbye...\n") + return + if choice == "00": + print(META) + input("\nEnter to continue..." + PROMPT) + clear() + continue + if choice in SUBMENUS: + run_submenu(client, choice) + continue + print("Invalid option.") + if __name__ == "__main__": - subp.call(["clear"]) - main() + try: + main() + except KeyboardInterrupt: + print("\nInterrupted.\n") + sys.exit(130) From 8d7e631a00b1b165e8a5a513b60f5ab9940d8799 Mon Sep 17 00:00:00 2001 From: dtf <826902+dtf0@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:58:13 -0700 Subject: [PATCH 2/4] Align zinger with Zingtree API v1 Added: - get_tree_variables, tree_sessions_last_clicked, delete_file_upload, session_freeze, vault_files (get/delete), with a new files menu - PUT/DELETE and JSON body support in ZingtreeClient.call Changed: - Base URL to /api/v1/ - Auth to X-Api-Key header; token removed from all paths - Path encoding keeps ^ literal for search_trees ^^ queries Removed: - get_trees (no longer in the API) - Token-masking logic (token no longer in the URL) --- zinger.py | 203 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 130 insertions(+), 73 deletions(-) diff --git a/zinger.py b/zinger.py index bc56cd0..33f58c7 100755 --- a/zinger.py +++ b/zinger.py @@ -7,6 +7,8 @@ Store it in a `.env` file as `ZINGTREE_API_TOKEN="..."`, or temporary environment variable via executing the following: `export ZINGTREE_API_TOKEN="..."` +Authentication is via the X-Api-Key request header. The key is never +placed in the URL path. This project is libre and licenced APACHE-2.0; see the COPYING file or https://www.apache.org/licenses/LICENSE-2.0 for more details. @@ -20,13 +22,14 @@ from __future__ import annotations +import json import os import sys from dataclasses import dataclass, field from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import quote -from urllib.request import urlopen +from urllib.request import Request, urlopen try: from dotenv import load_dotenv @@ -38,7 +41,7 @@ def load_dotenv(*_args, **_kwargs): # noqa: D401 # Configuration # ---------------------------------------------------------------------------- -API_URL = "https://zingtree.com/api/" +API_URL = "https://zingtree.com/api/v1/" ENV_FILE = Path(".") / ".env" AGENT_FILE = Path("zingers.txt") TIMEOUT = 30 @@ -63,33 +66,42 @@ class Agent: @dataclass class ZingtreeClient: - """Thin wrapper over the Zingtree REST endpoints. + """Thin wrapper over the Zingtree v1 REST endpoints. - The API embeds the key and all arguments in the URL path, so every - segment is percent-encoded. Commas are preserved because tag lists are - comma-delimited inside a single segment. + The key travels in the X-Api-Key header. Path segments carry only the + operation arguments and are percent-encoded; commas and carets are kept + literal because tag lists are comma-delimited and search_trees uses `^^` + as an AND separator. """ token: str base_url: str = API_URL timeout: int = TIMEOUT - def _mask(self, url: str) -> str: - return url.replace(self.token, self.token[:5] + "...") - - def call(self, *segments: str) -> str: - path = "/".join(quote(str(s), safe=",") for s in segments) + def call(self, *segments: str, method: str = "GET", body: dict | None = None) -> str: + path = "/".join(quote(str(s), safe=",^") for s in segments) url = self.base_url + path - print("\nCalling URL... {}".format(self._mask(url))) + + headers = {"X-Api-Key": self.token} + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + print("\n{} {}".format(method, url)) + request = Request(url, data=data, headers=headers, method=method) try: - with urlopen(url, timeout=self.timeout) as resp: - body = resp.read().decode("utf-8", errors="replace") + with urlopen(request, timeout=self.timeout) as resp: + status = getattr(resp, "status", resp.getcode()) + payload = resp.read().decode("utf-8", errors="replace") except HTTPError as exc: - raise ZingtreeError("HTTP {} from {}".format(exc.code, self._mask(url))) from exc + detail = exc.read().decode("utf-8", errors="replace").strip() + raise ZingtreeError("HTTP {} -- {}".format(exc.code, detail or exc.reason)) from exc except URLError as exc: raise ZingtreeError("Request failed: {}".format(exc.reason)) from exc - print(body) - return body + + print(payload if payload else "OK ({})".format(status)) + return payload def load_agents(path: Path = AGENT_FILE) -> list[Agent]: @@ -132,18 +144,18 @@ def op_agent_add(client: ZingtreeClient) -> None: if not confirm_agents(agents, "ADD and TAG"): return for a in agents: - client.call("agent_add", client.token, a.name, a.email) + client.call("agent_add", a.name, a.email) if a.tags: - client.call("agent_tag", client.token, a.email, ",".join(a.tags)) + client.call("agent_tag", a.email, ",".join(a.tags)) def op_agent_add_inter(client: ZingtreeClient) -> None: name = input("\nEnter agent's name..." + PROMPT).strip() email = input("\nEnter agent's email..." + PROMPT).strip() tags = input("\nEnter agent's tags..." + PROMPT).strip() - client.call("agent_add", client.token, name, email) + client.call("agent_add", name, email) if tags: - client.call("agent_tag", client.token, email, tags) + client.call("agent_tag", email, tags) def op_agent_tag(client: ZingtreeClient) -> None: @@ -151,7 +163,7 @@ def op_agent_tag(client: ZingtreeClient) -> None: if not confirm_agents(agents, "TAG"): return for a in agents: - client.call("agent_tag", client.token, a.email, ",".join(a.tags)) + client.call("agent_tag", a.email, ",".join(a.tags)) def op_agent_remove(client: ZingtreeClient) -> None: @@ -159,35 +171,36 @@ def op_agent_remove(client: ZingtreeClient) -> None: if not confirm_agents(agents, "REMOVE"): return for a in agents: - client.call("agent_remove", client.token, a.email) + client.call("agent_remove", a.email) def op_agent_remove_inter(client: ZingtreeClient) -> None: email = input("\nEnter agent's email..." + PROMPT).strip() - client.call("agent_remove", client.token, email) + client.call("agent_remove", email) def op_search_trees(client: ZingtreeClient) -> None: - query = input("\nEnter query..." + PROMPT).strip() - client.call("tree", client.token, "search_trees", query) - - -def op_get_trees(client: ZingtreeClient) -> None: - client.call("tree", client.token, "get_trees") + query = input("\nEnter query (use ^^ to AND terms)..." + PROMPT).strip() + client.call("tree", "search_trees", query) def op_get_tags(client: ZingtreeClient) -> None: - client.call("tree", client.token, "get_tags") + client.call("tree", "get_tags") def op_get_tree_tag_any(client: ZingtreeClient) -> None: tags = input("\nSearch trees by comma-separated tags..." + PROMPT).strip() - client.call("tree", client.token, "get_tree_tag_any", tags) + client.call("tree", "get_tree_tag_any", tags) def op_get_tree_tag_all(client: ZingtreeClient) -> None: tags = input("\nSearch trees by comma-separated tags..." + PROMPT).strip() - client.call("tree", client.token, "get_tree_tag_all", tags) + client.call("tree", "get_tree_tag_all", tags) + + +def op_get_tree_variables(client: ZingtreeClient) -> None: + tree = input("\nEnter tree_id (append 000 for production)..." + PROMPT).strip() + client.call("get_tree_variables", tree) def op_get_form_data(client: ZingtreeClient) -> None: @@ -200,18 +213,31 @@ def op_delete_form_data(client: ZingtreeClient) -> None: client.call("session", session, "delete_form_data") +def op_delete_file_upload(client: ZingtreeClient) -> None: + folder = input("\nEnter folder (e.g. pdf)..." + PROMPT).strip() + name = input("\nEnter file name..." + PROMPT).strip() + client.call("delete_file_upload", folder, name) + + def op_agent_sessions(client: ZingtreeClient) -> None: - email = input("\nEnter agent email..." + PROMPT).strip() - start = input("\nStart date (YYYY-MM-DD)..." + PROMPT).strip() - end = input("\nEnd date (YYYY-MM-DD)..." + PROMPT).strip() - client.call("agent_sessions", client.token, email, start, end) + email = input("\nEnter agent email (* for all)..." + PROMPT).strip() + start = input("\nStart date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + client.call("agent_sessions", email, start, end) def op_tree_sessions(client: ZingtreeClient) -> None: - tree = input("\nEnter tree_id..." + PROMPT).strip() - start = input("\nStart date (YYYY-MM-DD)..." + PROMPT).strip() - end = input("\nEnd date (YYYY-MM-DD)..." + PROMPT).strip() - client.call("tree_sessions", client.token, tree, start, end) + tree = input("\nEnter tree_id (* for all)..." + PROMPT).strip() + start = input("\nStart date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + client.call("tree_sessions", tree, start, end) + + +def op_tree_sessions_last_clicked(client: ZingtreeClient) -> None: + tree = input("\nEnter tree_id (* for all)..." + PROMPT).strip() + start = input("\nStart date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + client.call("tree_sessions_last_clicked", tree, start, end) def op_get_session_data(client: ZingtreeClient) -> None: @@ -229,31 +255,53 @@ def op_get_session_notes(client: ZingtreeClient) -> None: client.call("session", session, "get_session_notes") +def op_session_freeze(client: ZingtreeClient) -> None: + session = input("\nEnter session_id..." + PROMPT).strip() + project = input("\nEnter project_id (tree id)..." + PROMPT).strip() + client.call("session_freeze", method="PUT", + body={"session_id": session, "project_id": project}) + + def op_event_log(client: ZingtreeClient) -> None: - start = input("\nStart date (YYYY-MM-DD)..." + PROMPT).strip() - end = input("\nEnd date (YYYY-MM-DD)..." + PROMPT).strip() - client.call("event_log", client.token, start, end) + start = input("\nStart date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + end = input("\nEnd date (YYYY-MM-DD, blank = 30d)..." + PROMPT).strip() + client.call("event_log", start, end) + + +def op_vault_get(client: ZingtreeClient) -> None: + uid = input("\nEnter file_uid..." + PROMPT).strip() + client.call("vault_files", uid) + + +def op_vault_delete(client: ZingtreeClient) -> None: + uid = input("\nEnter file_uid..." + PROMPT).strip() + client.call("vault_files", uid, method="DELETE") OPERATIONS = { - "agent_add": op_agent_add, - "agent_add_inter": op_agent_add_inter, - "agent_tag": op_agent_tag, - "agent_remove": op_agent_remove, - "agent_remove_inter": op_agent_remove_inter, - "search_trees": op_search_trees, - "get_trees": op_get_trees, - "get_tags": op_get_tags, - "get_tree_tag_any": op_get_tree_tag_any, - "get_tree_tag_all": op_get_tree_tag_all, - "get_form_data": op_get_form_data, - "delete_form_data": op_delete_form_data, - "agent_sessions": op_agent_sessions, - "tree_sessions": op_tree_sessions, - "get_session_data": op_get_session_data, - "get_session_data_pure": op_get_session_data_pure, - "get_session_notes": op_get_session_notes, - "event_log": op_event_log, + "agent_add": op_agent_add, + "agent_add_inter": op_agent_add_inter, + "agent_tag": op_agent_tag, + "agent_remove": op_agent_remove, + "agent_remove_inter": op_agent_remove_inter, + "search_trees": op_search_trees, + "get_tags": op_get_tags, + "get_tree_tag_any": op_get_tree_tag_any, + "get_tree_tag_all": op_get_tree_tag_all, + "get_tree_variables": op_get_tree_variables, + "get_form_data": op_get_form_data, + "delete_form_data": op_delete_form_data, + "delete_file_upload": op_delete_file_upload, + "agent_sessions": op_agent_sessions, + "tree_sessions": op_tree_sessions, + "tree_sessions_last_clicked": op_tree_sessions_last_clicked, + "get_session_data": op_get_session_data, + "get_session_data_pure": op_get_session_data_pure, + "get_session_notes": op_get_session_notes, + "session_freeze": op_session_freeze, + "event_log": op_event_log, + "vault_get": op_vault_get, + "vault_delete": op_vault_delete, } # ---------------------------------------------------------------------------- @@ -265,6 +313,7 @@ def op_event_log(client: ZingtreeClient) -> None: "1": ("trees", "get info on trees"), "2": ("forms", "get or remove form data"), "3": ("sessions", "tree and agent session info"), + "4": ("files", "manage uploaded and vault files"), } SUBMENUS = { @@ -276,23 +325,30 @@ def op_event_log(client: ZingtreeClient) -> None: ("manual agent_remove (interactive)", "agent_remove_inter"), ]), "1": ("trees", [ - ("search_trees (search all trees for matching text)", "search_trees"), - ("get_trees (fetch all trees)", "get_trees"), - ("get_tags (fetch all tags used on trees)", "get_tags"), - ("get_tree_tag_any (trees matching ANY tags)", "get_tree_tag_any"), - ("get_tree_tag_all (trees matching ALL tags)", "get_tree_tag_all"), + ("search_trees (search all trees for matching text)", "search_trees"), + ("get_tags (fetch all tags used on trees)", "get_tags"), + ("get_tree_tag_any (trees matching ANY tags)", "get_tree_tag_any"), + ("get_tree_tag_all (trees matching ALL tags)", "get_tree_tag_all"), + ("get_tree_variables (variables defined within a tree)", "get_tree_variables"), ]), "2": ("forms", [ ("get_form_data (form values entered during a session)", "get_form_data"), ("delete_form_data (delete any form data)", "delete_form_data"), ]), "3": ("sessions", [ - ("agent_sessions (list agent sessions)", "agent_sessions"), - ("tree_sessions (list tree sessions)", "tree_sessions"), - ("get_session_data (form values entered during session)", "get_session_data"), - ("get_session_data_pure (linear path to tree)", "get_session_data_pure"), - ("get_session_notes (agent notes from session)", "get_session_notes"), - ("event_log (event log for a date range)", "event_log"), + ("agent_sessions (list agent sessions)", "agent_sessions"), + ("tree_sessions (list tree sessions)", "tree_sessions"), + ("tree_sessions_last_clicked (by last-click time)", "tree_sessions_last_clicked"), + ("get_session_data (form values entered during session)", "get_session_data"), + ("get_session_data_pure (linear path to tree)", "get_session_data_pure"), + ("get_session_notes (agent notes from session)", "get_session_notes"), + ("session_freeze (freeze a session)", "session_freeze"), + ("event_log (event log for a date range)", "event_log"), + ]), + "4": ("files", [ + ("delete_file_upload (delete an uploaded file)", "delete_file_upload"), + ("vault_files get (fetch a vault file URL)", "vault_get"), + ("vault_files delete (delete a vault file)", "vault_delete"), ]), } @@ -303,6 +359,7 @@ def op_event_log(client: ZingtreeClient) -> None: │ Author :: Dillon Feeney │ Licence :: Apache-2.0 │ Copyright (c) 2019 Dropbox, Inc. +│ Made with 🧁. ╰─────────────────────────────────────────────────╯ """ From 44e6a89bcb994f861fa3915ace1131446ab33965 Mon Sep 17 00:00:00 2001 From: dtf <826902+dtf0@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:44:19 -0700 Subject: [PATCH 3/4] Update copyright information, add full Apache-2.0 licence to COPYING --- COPYING | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++---- README.md | 112 ++++++++++++++++++++-------- zinger.py | 22 ++++-- 3 files changed, 300 insertions(+), 48 deletions(-) diff --git a/COPYING b/COPYING index d6cac44..9f6ea36 100644 --- a/COPYING +++ b/COPYING @@ -8,25 +8,213 @@ Comment: This file documents the copyright statements and licenses for For any copyright year range specified as YYYY-ZZZZ in this file, the range specifies every single year in that closed interval. Files: * -Copyright: © 2019 Dropbox, Inc. +Copyright: 2019-2020 Dropbox, Inc. License: Apache-2.0 -Files: ./zinger.py -Copyright: © 2019 Dropbox, Inc. +Files: ./zinger.py README.md +Copyright: 2019-2020 Dropbox, Inc. +Copyright: 2026 Dillon Feeney. License: Apache-2.0 License: Apache-2.0 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright 2019 Dropbox, Inc. + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + 1. Definitions. - http://www.apache.org/licenses/LICENSE-2.0 + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index d0be7e5..82dd296 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,52 @@ # zinger -`zinger` is a command-line utility for modifying agents and retrieving session -information via the [Zingtree RESTful API](http://www.zingtree.com/api/). +`zinger` is a command-line utility for modifying agents, retrieving session +information, and querying trees via the +[Zingtree REST API (v1)](https://zingtree.com/api/). ## Getting started ### Config -Set up `.env` file at root if running from script, or configure `python-dotenv` -for your own script/program. You can use the `env_sample` and rename to `.env` -(with full-stop). Fill in your API token +Set up a `.env` file at root if running from script, or configure +`python-dotenv` for your own script/program. You can copy `env_sample` and +rename it to `.env` (with a full-stop). Fill in your API token. Alternatively, +execute `export ZINGTREE_API_TOKEN="..."` + +Requests authenticate with the `X-Api-Key` header. You do not need to build it +yourself -- `zinger` reads `ZINGTREE_API_TOKEN` from `.env` and sets the header +on every call. The token never appears in the URL. The base URL is +`https://zingtree.com/api/v1/`. + +`zingers.txt` should contain one agent config per line in the following format: +`[email@domain],[Forename Surname],[all tags separated by comma]`, example: -`zingers.txt` should contain one agent config per line in the following format: `[email@domain],[Forename Surname],[all tags separated by comma]`, example: ``` asummers@zingtree.com,Alice Summers,tag_0,tag_2 bwinters@zingtree.com,Bob Winters,tag_0,tag_1 ``` +### Requirements +- Python 3.9+ +- `python-dotenv` (optional; only used to load `.env` -- if you don't use the + `.env` file, you must add `ZINGTREE_API_TOKEN` to your environment variables). + ### Usage -Add it to your project's `lib/` or run `python3 zinger.py`. Make sure that you +Add it to your project's `lib/` or run `python3 zinger.py`. Make sure that you have a text doc setup with the proper formatting. See "Config" section above. ## Supported endpoints - `agent_add` - Adds a no-login agent to your organisation, so access must be through SSO + or Google Authentication - `agent_tag` - Set or update comma-separated tags for an agent in your organisation - `agent_remove` - Removes an agent from your organisation - `agent_sessions` - Returns a JSON structure with session information for a particular agent - (if `-`, returns all agents) and date range in ISO-8601 format (if `null`, - returns last 30 days) + (if `*`, returns all agents) and date range in `YYYY-MM-DD` format (if + blank, returns last 30 days) +- `delete_file_upload` + - Deletes an uploaded file from the Zingtree servers, identified by folder + and file name - `delete_form_data` - Deletes any form data entered into Zingtree during a session - `event_log` @@ -46,47 +64,81 @@ have a text doc setup with the proper formatting. See "Config" section above. - `get_session_notes` - Returns a JSON structure with agent-entered notes from a session - `get_tags` - - Returns a JSON structure with all tags used in your organisation's trees. -- `get_trees` - - Returns a JSON structure with information about all trees + - Returns a JSON structure with all tags used in your organisation's trees - `get_tree_tag_all` - - Returns a JSON structure with trees that have ALL tags in CSV-format + - Returns a JSON structure with trees that have ALL tags in a comma-delimited + list - `get_tree_tag_any` - - Returns a JSON structure with trees that have ANY tags in CSV-format + - Returns a JSON structure with trees that have ANY tags in a comma-delimited + list +- `get_tree_variables` + - Returns a JSON structure with the variables defined or used within a tree, + by tree ID - `search_trees` - Returns a JSON structure with information about all trees and nodes matching - query + the query. Separate terms with `^^` for an AND search +- `session_freeze` + - Freezes a session so it can no longer be resumed or shared. Issued as a PUT + with a `session_id` and `project_id` (tree ID). A resume attempt afterwards + starts a fresh session with a new ID - `tree_sessions` - - Returns a JSON structure with session information for a particular tree and - date range in ISO-8601 format (if `null`, returns last 30 days) + - Returns a JSON structure with session information for a particular tree (if + `*`, returns all trees) and date range in `YYYY-MM-DD` format (if blank, + returns last 30 days) - You can also use date and time (PST) instead of date. These would be like YYYY-MM-DD HH:MM:SS. When calling a URL like this, replace the space character with %20 (YYYY-MM-DD%20HH:MM:SS) +- `tree_sessions_last_clicked` + - As `tree_sessions`, but the date range applies to the time of the last + click rather than session start +- `vault_files` + - Retrieves or deletes a session Vault File by `file_uid`. A GET returns a + `download_url` valid for 60 seconds; a DELETE removes the file ## Misc ``` -| Zingtree parameter | Zinger variable | -| ------------------ | --------------- | -| {{op}} ‡ | zt_oper | -| {{apikey}} ‡ | zt_token | -| {{agent login}} | zt_email | -| {{agent name}} | zt_name | -| {{tags}} | zt_tags | -| {{start date}} | zt_start | -| {{end date}} | zt_end | -| {{session ID}} | zt_session | +| Zingtree parameter | zinger input | +| -------------------- | ------------- | +| X-Api-Key (header) ‡ | token (.env) | +| {{agent login}} | email | +| {{agent name}} | name | +| {{tags}} | tags | +| {{tree ID}} | tree | +| {{session ID}} | session | +| {{file_uid}} | uid | +| {{folder}} {{file}} | folder, file | +| {{start date}} | start | +| {{end date}} | end | ``` ‡ == required -Note: `count` returns 0 if operation runs into Schröd-zinger's agent +Notes: +- The operation is selected from the menu and encoded in the endpoint path; it + is no longer passed as an `op` parameter. +- For production sessions, append triple zeros (`000`) to the tree ID. +- There is a limit of 6000 API calls per day for each organisation. + +Note: `count` returns 0 if operation runs into Schröd-zinger's agent. --- ## Licence ``` -This project is libre and licenced APACHE-2.0; see the COPYING file or -https://www.apache.org/licenses/LICENSE-2.0 for more details. +Copyright 2019 Dropbox, Inc. +Copyright 2026 Dillon Feeney. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. ``` --- diff --git a/zinger.py b/zinger.py index 33f58c7..ffbba4a 100755 --- a/zinger.py +++ b/zinger.py @@ -13,11 +13,23 @@ This project is libre and licenced APACHE-2.0; see the COPYING file or https://www.apache.org/licenses/LICENSE-2.0 for more details. -Copyright (c) 2019, Dropbox, Inc. -Author: Dillon Feeney. +Copyright (c) 2019-2020, Dropbox, Inc. +Copyright (c) 2026, Dillon Feeney. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. Created: 2019-08-20 -Updated: 2020-05-07 +Updated: 2026-06-21 """ from __future__ import annotations @@ -415,11 +427,11 @@ def main() -> None: clear() token = os.getenv("ZINGTREE_API_TOKEN") if not token: - sys.exit(" + sys.exit(""" Environment variable ZINGTREE_API_TOKEN missing or empty in `.env`. Either set the variable manually (`export ZINGTREE_API_TOKEN='token' or in the .env file -- this file is .gitignore'd) - ") + """) client = ZingtreeClient(token=token) while True: From d43c36be877c7d73bc61d159bff3f692d9a45e34 Mon Sep 17 00:00:00 2001 From: dtf <826902+dtf0@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:44:56 -0700 Subject: [PATCH 4/4] Bump requirements Resolves dropbox/zinger#9 --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 79fa862..9cca3e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -pycurl==7.43.0.3 -python-dotenv==0.10.3 +python-dotenv>=1.2.2