diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..76b8862e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: pre-commit/action@v3.0.1 + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y zsh vim tmux + - name: Setup environment + run: | + mkdir -p ~/src/bobwhitelock + ln -s ${{ github.workspace }} ~/src/bobwhitelock/dotfiles + - name: Run install script + run: | + set -x + cd ~/src/bobwhitelock/dotfiles + ./install + echo "Install script completed successfully" + - name: Verify zsh loads without errors + run: | + set -x + echo "=== Testing zsh lib files individually ===" + export DOTFILES="$HOME/src/bobwhitelock/dotfiles" + export ZSH_DIR="$DOTFILES/zsh" + export ZSH_LIB="$ZSH_DIR/lib" + + # First test env.sh alone + echo "Testing env.sh..." + output=$(zsh -c "source '$ZSH_DIR/env.sh'" 2>&1) || true + if [ -n "$output" ]; then + echo "env.sh produced output: $output" + fi + + # Test each lib file + for f in "$ZSH_LIB"/*; do + fname=$(basename "$f") + output=$(zsh -c " + source '$ZSH_DIR/env.sh' + source '$f' + " 2>&1) || true + if [ -n "$output" ]; then + echo "ERROR: $fname produced output:" + echo "$output" + else + echo "OK: $fname" + fi + done + + echo "=== Full zsh load test ===" + output=$(zsh -c ' + export DOTFILES="$HOME/src/bobwhitelock/dotfiles" + export ZSH_DIR="$DOTFILES/zsh" + export ZSH_LIB="$ZSH_DIR/lib" + source "$ZSH_DIR/env.sh" + for f in "$ZSH_LIB"/*; do + source "$f" + done + ' 2>&1) || true + + if [ -n "$output" ]; then + echo "ERROR: zsh produced unexpected output:" + echo "$output" + exit 1 + fi + echo "zsh loaded successfully with no output" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..0666006e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files + - id: check-executables-have-shebangs + + - repo: https://github.com/crate-ci/typos + rev: v1.28.4 + hooks: + - id: typos + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck + args: ["-x"] # follow source statements + files: \.(sh|bash)$|^bin/|^libexec/ + exclude: \.(py|pl)$|colours-to-rgb|extract_urls|git-ls-revisions|stripcolours|todoist-to-markdown|env\.private\.example\.sh + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + additional_dependencies: [] + + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: [-c, .yamllint.yml] + + - repo: local + hooks: + - id: vint + name: vint + entry: vint + language: python + additional_dependencies: [vim-vint] + files: \.vim$|vimrc$|gvimrc$ diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 00000000..f54a15da --- /dev/null +++ b/.typos.toml @@ -0,0 +1,7 @@ +[default.extend-words] +# Vim mapping ue for UltiSnipsEdit +ue = "ue" +# Project name, not underwear +underware = "underware" +# Alias for git rebase --abort +grea = "grea" diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 00000000..5916051a --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,20 @@ +extends: default + +# tmuxinator files use ERB templating which makes them invalid YAML +ignore: + - tmuxinator/ + +rules: + line-length: + max: 150 + truthy: + check-keys: false + document-start: disable + comments: + min-spaces-from-content: 1 + empty-lines: + max-start: 1 + indentation: + indent-sequences: whatever + comments-indentation: disable + key-duplicates: disable diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..881ab996 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,42 @@ +version: '3' + +tasks: + dev: + desc: Install development dependencies + cmds: + - pipx install pre-commit vint + + # TODO BW 2026-01-26: Add install and other scripts in here instead? Though + # then need `task` installed first. + + lint: + desc: Run all linters + cmds: + - pre-commit run --all-files + + integration: + desc: Run install script and verify zsh loads cleanly + cmds: + - ./install + - | + output=$(zsh -c ' + export DOTFILES="$HOME/src/bobwhitelock/dotfiles" + export ZSH_DIR="$DOTFILES/zsh" + export ZSH_LIB="$ZSH_DIR/lib" + source "$ZSH_DIR/env.sh" + for f in "$ZSH_LIB"/*; do + source "$f" + done + ' 2>&1) || true + if [ -n "$output" ]; then + echo "ERROR: zsh produced unexpected output:" + echo "$output" + exit 1 + fi + echo "zsh loaded successfully with no output" + + ci: + desc: Full CI pipeline + cmds: + - task: lint + - task: integration diff --git a/aider.conf.yml b/aider.conf.yml index 314ba7c6..855088fd 100644 --- a/aider.conf.yml +++ b/aider.conf.yml @@ -6,7 +6,9 @@ # # Can also run # ``` -# yaml_to_json /home/bob/src/Aider-AI/aider/aider/website/_data/polyglot_leaderboard.yml | jq '[.[] | select(.seconds_per_case < 30 and (.command | contains("openrouter") | not))] | sort_by(.pass_rate_2)' +# yaml_to_json .../polyglot_leaderboard.yml | \ +# jq '[.[] | select(.seconds_per_case < 30 and +# (.command | contains("openrouter") | not))] | sort_by(.pass_rate_2)' # ``` # to see models I might be able to use, that are not too slow, by speed at # Aider benchmark (but also take with a pinch of salt - a lot of people think diff --git a/bin/add_window b/bin/add_window index 557297cd..79fec9ff 100755 --- a/bin/add_window +++ b/bin/add_window @@ -11,7 +11,7 @@ IFS=$'\n\t' main() { local tmux_command - # shellcheck disable=SC1090 + # shellcheck disable=SC1090,SC1091 source "$ZSH_LIB/tmux.sh" if [ -n "$TMUX" ]; then diff --git a/bin/clone b/bin/clone index 563939db..e44e9323 100755 --- a/bin/clone +++ b/bin/clone @@ -40,7 +40,7 @@ main() { gh repo clone "$repo" fi - # shellcheck disable=SC1090 + # shellcheck disable=SC1090,SC1091 source "$ZSH_LIB/tmux.sh" add_window --vim-pane "$name" } diff --git a/bin/colours-to-rgb b/bin/colours-to-rgb index 4edae508..42dc091b 100755 --- a/bin/colours-to-rgb +++ b/bin/colours-to-rgb @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import sys + import webcolors # Convert hex or named CSS3 colours on stdin to rgb values on stdout. @@ -15,5 +16,6 @@ def main(): print(colour, *rgb) + if __name__ == "__main__": main() diff --git a/bin/extract_urls b/bin/extract_urls index 09cc201c..bd5064ab 100755 --- a/bin/extract_urls +++ b/bin/extract_urls @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -import sys import re +import sys # Extract URLs from a file given as the first argument, or stdin, and output # one URL per line. @@ -11,7 +11,7 @@ URL_PATTERN = r'https?://[^\s<>"{}|\\^`\[\]()]+' def main(): if len(sys.argv) > 1: - with open(sys.argv[1], "r") as f: + with open(sys.argv[1]) as f: extract_urls_from_lines(f) else: extract_urls_from_lines(sys.stdin) diff --git a/bin/git-about b/bin/git-about index 37e40e4e..c953e9df 100755 --- a/bin/git-about +++ b/bin/git-about @@ -4,6 +4,7 @@ IFS=$'\n\t' main() { + # shellcheck disable=SC2198 # Auto-suppressed when enabling Shellcheck. if [ ! -e "$@" ]; then echo "Error: Path does not exist: $*" exit 1 diff --git a/bin/git-ls-revisions b/bin/git-ls-revisions index 3b10421e..0c7a61be 100755 --- a/bin/git-ls-revisions +++ b/bin/git-ls-revisions @@ -3,7 +3,6 @@ import operator import subprocess - # Lists all tracked files in repo along with the number of commits which have # changed them, in order from most to least. @@ -13,34 +12,26 @@ import subprocess def main(): - tracked_files = output_lines( - ['git', 'ls-tree', '--full-tree', '--name-only', '-r', 'HEAD'] - ) + tracked_files = output_lines(["git", "ls-tree", "--full-tree", "--name-only", "-r", "HEAD"]) file_to_revisions = {} for file in tracked_files: try: - revisions = output_lines( - ['git', 'log', '--oneline', '--follow', file] - ) + revisions = output_lines(["git", "log", "--oneline", "--follow", file]) file_to_revisions[file] = len(revisions) except subprocess.CalledProcessError as ex: print(ex) - sorted_files = sorted( - file_to_revisions.items(), - key=operator.itemgetter(1), - reverse=True - ) + sorted_files = sorted(file_to_revisions.items(), key=operator.itemgetter(1), reverse=True) for file, revision_count in sorted_files: - print('{} - {}'.format(file, revision_count)) + print(f"{file} - {revision_count}") def output_lines(command): output = subprocess.check_output(command, universal_newlines=True) - return [line for line in output.split('\n') if len(line) > 0] + return [line for line in output.split("\n") if len(line) > 0] -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/bin/git-sync-branches b/bin/git-sync-branches index a494b12e..20655465 100755 --- a/bin/git-sync-branches +++ b/bin/git-sync-branches @@ -25,4 +25,3 @@ _sync_branch() { main "$@" - diff --git a/bin/git-wip b/bin/git-wip index deea3a2e..775c0cf1 100755 --- a/bin/git-wip +++ b/bin/git-wip @@ -5,6 +5,7 @@ IFS=$'\n\t' main() { local message + # shellcheck disable=SC2124 # Auto-suppressed when enabling Shellcheck. message="WIP $@" git commit --no-verify -m "$message" } diff --git a/bin/mv_notes b/bin/mv_notes index 97fd5d26..b66cbe9c 100755 --- a/bin/mv_notes +++ b/bin/mv_notes @@ -44,7 +44,8 @@ update_wikilinks() { fi # Create a temporary file for the updates - local temp_file=$(mktemp) + local temp_file + temp_file=$(mktemp) # Update wikilinks that reference the old path # This handles cases like: diff --git a/bin/replace_window b/bin/replace_window index c23666d5..63799912 100755 --- a/bin/replace_window +++ b/bin/replace_window @@ -9,7 +9,7 @@ IFS=$'\n\t' main() { local current_window new_window - # shellcheck disable=SC1090 + # shellcheck disable=SC1090,SC1091 source "$ZSH_LIB/tmux.sh" # shellcheck disable=SC2063 diff --git a/bin/rofr.sh b/bin/rofr.sh index 2a9a032d..6d8b4cd2 100755 --- a/bin/rofr.sh +++ b/bin/rofr.sh @@ -23,7 +23,7 @@ usage() -b,--browser Browser search by keyword (requires surfraw) - -q,--qalculate Persistant calculator dialog (requires libqalculate) + -q,--qalculate Persistent calculator dialog (requires libqalculate) -c,--clipboard Select previous clipboard entries (requires greenclip) @@ -79,7 +79,7 @@ for arg in "$@"; do -padding 50 -line-padding 4)" ;; -l|--logout) - if grep -q 'exec startx' $HOME/.*profile; then + if grep -q 'exec startx' "$HOME"/.*profile; then ANS="$(rofi -sep "|" -dmenu -i -p 'System' -width 20 \ -hide-scrollbar -line-padding 4 -padding 20 \ -lines 3 <<< " Lock| Reboot| Shutdown")" diff --git a/bin/run-command-on-git-revisions b/bin/run-command-on-git-revisions index 3a602505..cd467218 100755 --- a/bin/run-command-on-git-revisions +++ b/bin/run-command-on-git-revisions @@ -30,11 +30,13 @@ main() { abort_if_dirty_repo() { set +e git diff-index --quiet --cached HEAD + # shellcheck disable=SC2181 # Auto-suppressed when enabling Shellcheck. if [[ $? -ne 0 ]]; then echo "You have staged but not committed changes that would be lost! Aborting." exit 1 fi git diff-files --quiet + # shellcheck disable=SC2181 # Auto-suppressed when enabling Shellcheck. if [[ $? -ne 0 ]]; then echo "You have unstaged changes that would be lost! Aborting." exit 1 @@ -55,30 +57,31 @@ enforce_usage() { } usage() { - echo "usage: `basename $0` start_ref end_ref test_command" + echo "usage: $(basename "$0") start_ref end_ref test_command" } run_tests() { - revs=`log_command git rev-list --reverse ${start_ref}..${end_ref}` + revs=$(log_command git rev-list --reverse "${start_ref}".."${end_ref}") for rev in $revs; do - debug "Checking out: $(git log --oneline -1 $rev)" - log_command git checkout --quiet $rev - log_command $test_command + debug "Checking out: $(git log --oneline -1 "$rev")" + log_command git checkout --quiet "$rev" + log_command "$test_command" log_command git reset --hard --quiet done - log_command git checkout --quiet $end_ref + log_command git checkout --quiet "$end_ref" debug "OK for all revisions!" } log_command() { debug "=> $*" + # shellcheck disable=SC2294 # Auto-suppressed when enabling Shellcheck. eval "$@" } debug() { - if [ $verbose ]; then - echo $* >&2 + if [ "$verbose" ]; then + echo "$*" >&2 fi } diff --git a/bin/stripcolours b/bin/stripcolours index 9beb5435..0c856305 100755 --- a/bin/stripcolours +++ b/bin/stripcolours @@ -1,6 +1,6 @@ #!/usr/bin/env perl -# Remove terminal escape sequenes from stdin/file and and send to stdout; in +# Remove terminal escape sequences from stdin/file and and send to stdout; in # particular useful for stripping colour escape sequences. # From https://unix.stackexchange.com/a/14707/229081. diff --git a/bin/sync b/bin/sync index 6b287e01..38e3a958 100755 --- a/bin/sync +++ b/bin/sync @@ -21,6 +21,7 @@ main() { run() { echo echo '>' "$@" + # shellcheck disable=SC2294 # Auto-suppressed when enabling Shellcheck. eval "$@" 2>&1 | indent } diff --git a/bin/todoist-to-markdown b/bin/todoist-to-markdown index 81ba55b1..ca3c9729 100755 --- a/bin/todoist-to-markdown +++ b/bin/todoist-to-markdown @@ -1,13 +1,13 @@ #!/usr/bin/env python3 +import hashlib +import json import os import sys -import json -from urllib.request import Request, urlopen -from urllib.parse import urlencode -from urllib.error import URLError, HTTPError -import hashlib from concurrent.futures import ThreadPoolExecutor, as_completed +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen # TODO BW 2025-09-20: Comments (and maybe descriptions?) are handled badly, # multiline content will start on one line but then appear as if it is a new @@ -20,9 +20,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # Linux is `/`, so just allow everything else to preserve project names? And # maybe switch that to `\`? + def get_todoist_api_token(): """Get the Todoist API token from environment variable.""" - token = os.environ.get('TODOIST_API_TOKEN') + token = os.environ.get("TODOIST_API_TOKEN") if not token: print("Error: TODOIST_API_TOKEN environment variable not set", file=sys.stderr) sys.exit(1) @@ -32,24 +33,24 @@ def get_todoist_api_token(): def make_sync_request(resource_types, token): """Make a sync request to the Todoist API v1.""" url = "https://api.todoist.com/api/v1/sync" - data = urlencode({ - 'sync_token': '*', - 'resource_types': json.dumps(resource_types) - }) + data = urlencode({"sync_token": "*", "resource_types": json.dumps(resource_types)}) - req = Request(url, data.encode('utf-8')) - req.add_header('Authorization', f'Bearer {token}') - req.add_header('Content-Type', 'application/x-www-form-urlencoded') - req.add_header('User-Agent', 'bob.whitelock1@gmail.com') + req = Request(url, data.encode("utf-8")) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + req.add_header("User-Agent", "bob.whitelock1@gmail.com") try: with urlopen(req) as response: - return json.loads(response.read().decode('utf-8')) + return json.loads(response.read().decode("utf-8")) except HTTPError as e: if e.code == 429: - retry_after = e.headers.get('Retry-After') + retry_after = e.headers.get("Retry-After") if retry_after: - print(f"Rate limit exceeded (429). Retry after {retry_after} seconds.", file=sys.stderr) + print( + f"Rate limit exceeded (429). Retry after {retry_after} seconds.", + file=sys.stderr, + ) else: print("Rate limit exceeded (429). No retry-after header provided.", file=sys.stderr) else: @@ -63,56 +64,57 @@ def make_sync_request(resource_types, token): sys.exit(1) - def get_all_tasks_and_comments(token): """Get all tasks, comments, labels, and sections from Todoist.""" print("Getting all tasks, comments, labels, and sections...", file=sys.stderr) - sync_data = make_sync_request(['items', 'notes', 'labels', 'sections'], token) - all_tasks = sync_data.get('items', []) - all_comments = sync_data.get('notes', []) - all_labels = sync_data.get('labels', []) - all_sections = sync_data.get('sections', []) + sync_data = make_sync_request(["items", "notes", "labels", "sections"], token) + all_tasks = sync_data.get("items", []) + all_comments = sync_data.get("notes", []) + all_labels = sync_data.get("labels", []) + all_sections = sync_data.get("sections", []) return all_tasks, all_comments, all_labels, all_sections -def filter_project_tasks_and_comments(project_id, all_tasks, all_comments, all_labels, all_sections): +def filter_project_tasks_and_comments( + project_id, all_tasks, all_comments, all_labels, all_sections +): """Filter tasks, comments, labels, and sections for a specific project from pre-fetched data.""" # Filter tasks for the specific project - project_tasks = [task for task in all_tasks if task['project_id'] == project_id] + project_tasks = [task for task in all_tasks if task["project_id"] == project_id] # Filter comments for tasks in this project - task_ids = {task['id'] for task in project_tasks} - project_comments = [comment for comment in all_comments if comment.get('item_id') in task_ids] + task_ids = {task["id"] for task in project_tasks} + project_comments = [comment for comment in all_comments if comment.get("item_id") in task_ids] # Filter sections for this project - project_sections = [section for section in all_sections if section['project_id'] == project_id] + project_sections = [section for section in all_sections if section["project_id"] == project_id] return project_tasks, project_comments, all_labels, project_sections def build_task_hierarchy(tasks, comments, sections): """Build a hierarchical structure of tasks with comments, organized by sections.""" - task_dict = {task['id']: task for task in tasks} - section_dict = {section['id']: section for section in sections} + task_dict = {task["id"]: task for task in tasks} + section_dict = {section["id"]: section for section in sections} # Add children and comments lists to each task for task in tasks: - task['children'] = [] - task['comments'] = [] + task["children"] = [] + task["comments"] = [] # Associate comments with tasks for comment in comments: - item_id = comment.get('item_id') + item_id = comment.get("item_id") if item_id and item_id in task_dict: - task_dict[item_id]['comments'].append(comment) + task_dict[item_id]["comments"].append(comment) # Build the hierarchy root_tasks = [] for task in tasks: - if task.get('parent_id'): - parent = task_dict.get(task['parent_id']) + if task.get("parent_id"): + parent = task_dict.get(task["parent_id"]) if parent: - parent['children'].append(task) + parent["children"].append(task) else: root_tasks.append(task) @@ -121,9 +123,9 @@ def build_task_hierarchy(tasks, comments, sections): tasks_without_section = [] for task in root_tasks: - section_id = task.get('section_id') + section_id = task.get("section_id") if section_id and section_id in section_dict: - section_name = section_dict[section_id]['name'] + section_name = section_dict[section_id]["name"] if section_name not in sections_with_tasks: sections_with_tasks[section_name] = [] sections_with_tasks[section_name].append(task) @@ -154,8 +156,8 @@ def format_date(date_str): if not date_str: return "" # Handle both date and datetime formats - if 'T' in date_str: - return date_str.split('T')[0] + if "T" in date_str: + return date_str.split("T")[0] return date_str @@ -164,39 +166,39 @@ def format_task_metadata(task, labels_dict): parts = [] # Priority first - priority = format_priority(task.get('priority')) + priority = format_priority(task.get("priority")) if priority: parts.append(priority) # Task content - parts.append(task['content']) + parts.append(task["content"]) # Labels - labels = format_labels(task.get('labels', [])) + labels = format_labels(task.get("labels", [])) if labels: parts.append(labels) # Created date - created = task.get('added_at') + created = task.get("added_at") if created: parts.append(f"created:{format_date(created)}") # Due date - due_date = task.get('due') - if due_date and due_date.get('date'): + due_date = task.get("due") + if due_date and due_date.get("date"): parts.append(f"due:{format_date(due_date['date'])}") # Deadline - deadline = task.get('deadline') - if deadline and deadline.get('date'): + deadline = task.get("deadline") + if deadline and deadline.get("date"): parts.append(f"deadline:{format_date(deadline['date'])}") # Recurrence - if due_date and due_date.get('string') and 'every' in due_date.get('string', '').lower(): + if due_date and due_date.get("string") and "every" in due_date.get("string", "").lower(): parts.append(f"recurs:'{due_date['string']}'") # Todoist URL - task_id = task.get('id') + task_id = task.get("id") if task_id: parts.append(f"| [Todoist](https://app.todoist.com/app/task/{task_id})") @@ -213,8 +215,8 @@ def is_image_file(file_name): if not file_name: return False - image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp'} - extension = '.' + file_name.split('.')[-1].lower() if '.' in file_name else '' + image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp"} + extension = "." + file_name.split(".")[-1].lower() if "." in file_name else "" return extension in image_extensions @@ -228,7 +230,7 @@ def download_image(url, file_name, token, output_root="", current_dir=""): name_hash = hashlib.md5(url.encode()).hexdigest()[:8] base_name, extension = os.path.splitext(file_name) if not extension: - extension = '.png' # Default extension if none provided + extension = ".png" # Default extension if none provided local_filename = f"{base_name}_{name_hash}{extension}" local_path = os.path.join(attachments_dir, local_filename) @@ -245,11 +247,11 @@ def download_image(url, file_name, token, output_root="", current_dir=""): print(f"Downloading image: {url}", file=sys.stderr) # Create request with authorization req = Request(url) - req.add_header('Authorization', f'Bearer {token}') - req.add_header('User-Agent', 'bob.whitelock1@gmail.com') + req.add_header("Authorization", f"Bearer {token}") + req.add_header("User-Agent", "bob.whitelock1@gmail.com") with urlopen(req) as response: - with open(local_path, 'wb') as f: + with open(local_path, "wb") as f: f.write(response.read()) print(f"Downloaded image: {local_path}", file=sys.stderr) @@ -261,13 +263,21 @@ def download_image(url, file_name, token, output_root="", current_dir=""): except HTTPError as e: if e.code == 429: - retry_after = e.headers.get('Retry-After') + retry_after = e.headers.get("Retry-After") if retry_after: - print(f"Failed to download image {url}: Rate limit exceeded (429). Retry after {retry_after} seconds.", file=sys.stderr) + print( + f"Failed to download image {url}: Rate limit exceeded (429). Retry after {retry_after} seconds.", + file=sys.stderr, + ) else: - print(f"Failed to download image {url}: Rate limit exceeded (429). No retry-after header provided.", file=sys.stderr) + print( + f"Failed to download image {url}: Rate limit exceeded (429). No retry-after header provided.", + file=sys.stderr, + ) else: - print(f"Failed to download image {url}: HTTP Error {e.code}: {e.reason}", file=sys.stderr) + print( + f"Failed to download image {url}: HTTP Error {e.code}: {e.reason}", file=sys.stderr + ) # Return original URL if download fails return url except Exception as e: @@ -278,11 +288,11 @@ def download_image(url, file_name, token, output_root="", current_dir=""): def format_attachment_url(attachment, token, output_root="", current_dir=""): """Format attachment as a Todoist link or image markdown.""" - file_name = attachment.get('file_name', 'attachment') - file_url = attachment.get('file_url', '') + file_name = attachment.get("file_name", "attachment") + file_url = attachment.get("file_url", "") # If we have a direct file URL, use that; otherwise construct a Todoist link - if file_url and file_url.startswith('http'): + if file_url and file_url.startswith("http"): attachment_url = file_url else: attachment_url = f"https://todoist.com/app/attachment/{attachment.get('resource_type', 'file')}/{file_url or file_name}" @@ -309,47 +319,56 @@ def print_tasks_markdown(tasks, token, indent_level=0, file=None, output_root="" # If task has a description, print it as its own bullet point # In v1 API, descriptions might be in a different field or format - description = task.get('description', '') or task.get('note', '') + description = task.get("description", "") or task.get("note", "") if description: print(f"{indent} - description: {description}", file=file) # Print comments for this task - if task.get('comments'): - for comment in task['comments']: - comment_content = comment.get('content', '') + if task.get("comments"): + for comment in task["comments"]: + comment_content = comment.get("content", "") if comment_content: print(f"{indent} - comment: {comment_content}", file=file) # Handle attachments in comments as their own bullet point - attachment = comment.get('file_attachment') + attachment = comment.get("file_attachment") if attachment: - attachment_url = format_attachment_url(attachment, token, output_root, current_dir) + attachment_url = format_attachment_url( + attachment, token, output_root, current_dir + ) print(f"{indent} - attachment: {attachment_url}", file=file) # Recursively print children - if task['children']: - print_tasks_markdown(task['children'], token, indent_level + 1, file=file, output_root=output_root, current_dir=current_dir) + if task["children"]: + print_tasks_markdown( + task["children"], + token, + indent_level + 1, + file=file, + output_root=output_root, + current_dir=current_dir, + ) def get_all_projects(token): """Get all projects and build hierarchy.""" - sync_data = make_sync_request(['projects'], token) - projects = sync_data.get('projects', []) + sync_data = make_sync_request(["projects"], token) + projects = sync_data.get("projects", []) # Build project hierarchy - project_dict = {p['id']: p for p in projects} + project_dict = {p["id"]: p for p in projects} root_projects = [] # Add children lists to each project for project in projects: - project['children'] = [] + project["children"] = [] # Build the hierarchy for project in projects: - if project.get('parent_id'): - parent = project_dict.get(project['parent_id']) + if project.get("parent_id"): + parent = project_dict.get(project["parent_id"]) if parent: - parent['children'].append(project) + parent["children"].append(project) else: root_projects.append(project) @@ -360,22 +379,22 @@ def sanitize_filename(name): """Sanitize a project name for use as a filename.""" # Replace problematic filesystem characters with safe alternatives, but preserve emojis and Unicode problematic_chars = '<>:"/\\|?*' - return "".join('_' if c in problematic_chars else c for c in name).strip() + return "".join("_" if c in problematic_chars else c for c in name).strip() -def export_project_to_file(project, token, all_tasks, all_comments, all_labels, all_sections, base_path="", output_root=""): +def export_project_to_file( + project, token, all_tasks, all_comments, all_labels, all_sections, base_path="", output_root="" +): """Export a single project to a markdown file.""" # Filter tasks, comments, labels, and sections for this project tasks, comments, labels, sections = filter_project_tasks_and_comments( - project['id'], all_tasks, all_comments, all_labels, all_sections) + project["id"], all_tasks, all_comments, all_labels, all_sections + ) # Skip creating markdown file if project has no tasks but has child projects - if not tasks and project.get('children'): + if not tasks and project.get("children"): return None - # Build label lookup dictionary - labels_dict = {label['id']: label for label in labels} - # Build task hierarchy organized by sections sections_with_tasks, tasks_without_section = build_task_hierarchy(tasks, comments, sections) @@ -388,16 +407,20 @@ def export_project_to_file(project, token, all_tasks, all_comments, all_labels, os.makedirs(base_path, exist_ok=True) # Write to file - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, "w", encoding="utf-8") as f: # Print tasks without sections first if tasks_without_section: - print_tasks_markdown(tasks_without_section, token, file=f, output_root=output_root, current_dir=base_path) + print_tasks_markdown( + tasks_without_section, token, file=f, output_root=output_root, current_dir=base_path + ) # Print sections with their tasks for section_name, section_tasks in sections_with_tasks.items(): if section_tasks: # Only print section if it has tasks print(f"\n# {section_name}\n", file=f) - print_tasks_markdown(section_tasks, token, file=f, output_root=output_root, current_dir=base_path) + print_tasks_markdown( + section_tasks, token, file=f, output_root=output_root, current_dir=base_path + ) return filepath @@ -410,10 +433,10 @@ def flatten_projects_with_paths(projects, base_path=""): result.append((project, base_path)) # If project has children, add them recursively in subdirectory - if project['children']: - subdir_name = sanitize_filename(project['name']) + if project["children"]: + subdir_name = sanitize_filename(project["name"]) subdir_path = os.path.join(base_path, subdir_name) if base_path else subdir_name - result.extend(flatten_projects_with_paths(project['children'], subdir_path)) + result.extend(flatten_projects_with_paths(project["children"], subdir_path)) return result @@ -432,7 +455,17 @@ def export_all_projects(root_projects, token, base_dir="", max_workers=4): # Submit all export tasks with project info for tracking future_to_project = {} for project, path in projects_with_paths: - future = executor.submit(export_project_to_file, project, token, all_tasks, all_comments, all_labels, all_sections, path, base_dir) + future = executor.submit( + export_project_to_file, + project, + token, + all_tasks, + all_comments, + all_labels, + all_sections, + path, + base_dir, + ) future_to_project[future] = project # Wait for all exports to complete and track progress @@ -447,7 +480,10 @@ def export_all_projects(root_projects, token, base_dir="", max_workers=4): filename = os.path.basename(filepath) print(f"Exported {completed}/{total_projects}: {filename}", file=sys.stderr) else: - print(f"Skipped {completed}/{total_projects}: {project['name']} (no tasks, has child projects)", file=sys.stderr) + print( + f"Skipped {completed}/{total_projects}: {project['name']} (no tasks, has child projects)", + file=sys.stderr, + ) except Exception as e: print(f"Error exporting project {project['name']}: {e}", file=sys.stderr) @@ -456,7 +492,10 @@ def main(): # Check for directory argument if len(sys.argv) != 2: print("Usage: todoist-to-markdown ", file=sys.stderr) - print("Exports all Todoist projects to markdown files under the specified directory", file=sys.stderr) + print( + "Exports all Todoist projects to markdown files under the specified directory", + file=sys.stderr, + ) sys.exit(1) output_dir = sys.argv[1] diff --git a/config/coc/extensions/package.json b/config/coc/extensions/package.json index fc39547e..5919bf5a 100644 --- a/config/coc/extensions/package.json +++ b/config/coc/extensions/package.json @@ -11,4 +11,4 @@ "coc-tsserver": ">=1.8.6", "coc-vimlsp": ">=0.12.4" } -} \ No newline at end of file +} diff --git a/config/openbox/rc.xml b/config/openbox/rc.xml index 017332d5..fac66a24 100644 --- a/config/openbox/rc.xml +++ b/config/openbox/rc.xml @@ -45,7 +45,7 @@ NL