diff --git a/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/info.plist b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/info.plist new file mode 100644 index 0000000..b41dfea --- /dev/null +++ b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/info.plist @@ -0,0 +1,296 @@ + + + + + bundleid + com.tlockney.task-manager + category + Productivity + connections + + 11111111-1111-1111-1111-111111111111 + + + destinationuid + 22222222-2222-2222-2222-222222222222 + modifiers + 0 + modifiersubtext + + vitowards + + + + 33333333-3333-3333-3333-333333333333 + + + destinationuid + 44444444-4444-4444-4444-444444444444 + modifiers + 0 + modifiersubtext + + vitowards + + + + destinationuid + 55555555-5555-5555-5555-555555555555 + modifiers + 1048576 + modifiersubtext + Open in Notion + vitowards + + + + 22222222-2222-2222-2222-222222222222 + + + destinationuid + 66666666-6666-6666-6666-666666666666 + modifiers + 0 + modifiersubtext + + vitowards + + + + + createdby + Thomas Lockney + description + Quick capture and manage tasks in Notion + disabled + + name + Task Manager + objects + + + config + + argumenttype + 0 + keyword + task + subtext + Quick capture a new task + text + Add Task + withspace + + + type + alfred.workflow.input.keyword + uid + 11111111-1111-1111-1111-111111111111 + version + 1 + + + config + + concurrently + + escaping + 102 + script + ./task_add.sh "{query}" + scriptargtype + 1 + scriptfile + + type + 0 + + type + alfred.workflow.action.script + uid + 22222222-2222-2222-2222-222222222222 + version + 2 + + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 0 + argumenttreatemptyqueryasnil + + argumenttrimmode + 0 + argumenttype + 1 + escaping + 102 + keyword + tasks + queuedelaycustom + 3 + queuedelayimmediatelyalinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + Loading tasks... + script + ./task_list.sh "{query}" + scriptargtype + 1 + scriptfile + + subtext + View and manage tasks + title + Tasks + type + 0 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 33333333-3333-3333-3333-333333333333 + version + 3 + + + config + + concurrently + + escaping + 102 + script + ./task_complete.sh "{query}" + scriptargtype + 1 + scriptfile + + type + 0 + + type + alfred.workflow.action.script + uid + 44444444-4444-4444-4444-444444444444 + version + 2 + + + config + + browser + + spaces + + url + {query} + utf8 + + + type + alfred.workflow.action.openurl + uid + 55555555-5555-5555-5555-555555555555 + version + 1 + + + config + + lastpathcomponent + + onlyshowifquerypopulated + + removeextension + + text + ✅ Task added: {query} + title + Task Manager + + type + alfred.workflow.output.notification + uid + 66666666-6666-6666-6666-666666666666 + version + 1 + + + readme + Task Manager - Quick capture and manage tasks in Notion + +Keywords: +- task <title> - Quick capture a new task +- tasks - View active tasks (Ready + In Focus) + +Actions: +- Enter: Complete the selected task +- Cmd+Enter: Open task in Notion + +Filters for 'tasks' command: +- tasks - Show active tasks (Ready + In Focus) +- tasks all - Show all non-complete tasks +- tasks ready - Show Ready tasks only +- tasks focus - Show In Focus tasks only +- tasks triage - Show Triage tasks only +- tasks open - Open Notion database + uidata + + 11111111-1111-1111-1111-111111111111 + + xpos + 100 + ypos + 50 + + 22222222-2222-2222-2222-222222222222 + + xpos + 300 + ypos + 50 + + 33333333-3333-3333-3333-333333333333 + + xpos + 100 + ypos + 200 + + 44444444-4444-4444-4444-444444444444 + + xpos + 300 + ypos + 180 + + 55555555-5555-5555-5555-555555555555 + + xpos + 300 + ypos + 260 + + 66666666-6666-6666-6666-666666666666 + + xpos + 500 + ypos + 50 + + + version + 1.0 + webaddress + + + diff --git a/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_add.sh b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_add.sh new file mode 100755 index 0000000..de48887 --- /dev/null +++ b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_add.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# +# Alfred script: Add a new task +# + +QUERY="$1" + +if [[ -z "$QUERY" ]]; then + echo "No task title provided" + exit 1 +fi + +# Call the task-manager script +~/bin/task-manager add "$QUERY" >/dev/null 2>&1 + +# Output the title for the notification +echo "$QUERY" diff --git a/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_complete.sh b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_complete.sh new file mode 100755 index 0000000..2ce2194 --- /dev/null +++ b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_complete.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# +# Alfred script: Complete a task +# + +TASK_ID="$1" + +if [[ -z "$TASK_ID" ]]; then + echo "No task ID provided" + exit 1 +fi + +# Call the task-manager script +~/bin/task-manager complete "$TASK_ID" >/dev/null 2>&1 + +exit 0 diff --git a/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_list.sh b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_list.sh new file mode 100755 index 0000000..bbccb22 --- /dev/null +++ b/.config/Alfred.alfredpreferences/workflows/user.workflow.A1B2C3D4-E5F6-7890-ABCD-TASK12345678/task_list.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# +# Alfred script: List tasks +# + +QUERY="${1:-}" + +# Handle special "open" command +if [[ "$QUERY" == "open" ]]; then + ~/bin/task-manager open + # Return empty results since we're opening Notion directly + echo '{"items":[]}' + exit 0 +fi + +# Determine filter based on query +FILTER="active" +case "$QUERY" in + all|ready|focus|triage) + FILTER="$QUERY" + ;; + "") + FILTER="active" + ;; + *) + # If query doesn't match a filter, use active and let Alfred filter + FILTER="active" + ;; +esac + +# Get tasks in JSON format +RESULT=$(~/bin/task-manager --json list "$FILTER" 2>/dev/null) + +if [[ $? -ne 0 ]] || [[ -z "$RESULT" ]]; then + # Return error item + echo '{"items":[{"title":"Error loading tasks","subtitle":"Check your Notion API token and configuration","valid":false}]}' + exit 0 +fi + +# Check if we have any results +ITEM_COUNT=$(echo "$RESULT" | jq -r '.items | length') + +if [[ "$ITEM_COUNT" == "0" ]]; then + # Add helpful message when no tasks + echo '{"items":[{"title":"No tasks found","subtitle":"Try: tasks all, tasks triage, or task to add one","valid":false}]}' + exit 0 +fi + +# Output the result +echo "$RESULT" diff --git a/.config/task-manager/README.md b/.config/task-manager/README.md new file mode 100644 index 0000000..b069c3e --- /dev/null +++ b/.config/task-manager/README.md @@ -0,0 +1,170 @@ +# Task Manager + +CLI and Alfred workflow for managing tasks in Notion. + +## Setup + +### 1. Notion Integration + +1. Create an integration at [notion.so/my-integrations](https://www.notion.so/my-integrations) +2. Copy the "Internal Integration Secret" (starts with `ntn_`) +3. Share your Task Manager database with the integration + +### 2. Store Token in 1Password + +Store the Notion API token in 1Password. The default item name is "Notion API Token" with the token in a field labeled "credential". + +To use a different item name, update `op_token_item` in `config.json`. + +### 3. Configuration + +Edit `config.json` in this directory: + +```json +{ + "database_id": "your-database-id-here", + "notion_url": "https://www.notion.so/your-database-url", + "op_token_item": "Notion API Token" +} +``` + +The `database_id` is the 32-character ID from your database URL. + +## Usage + +### CLI + +```bash +# Quick capture (most common) +task "Buy groceries" + +# List active tasks (Ready + In Focus) +task-manager list + +# List with filters +task-manager list ready # Ready tasks only +task-manager list focus # In Focus tasks only +task-manager list triage # Triage tasks only +task-manager list all # All non-complete tasks + +# Complete a task +task-manager complete <task-id> + +# Open database in browser (prints URL if in SSH session) +task-manager open +``` + +### Alfred + +- `task <title>` - Quick capture a new task +- `tasks` - View active tasks (Ready + In Focus) +- `tasks all` - View all non-complete tasks +- `tasks ready` - View Ready tasks +- `tasks focus` - View In Focus tasks +- `tasks triage` - View Triage tasks +- `tasks open` - Open Notion database + +**Actions on task list:** +- `Enter` - Complete the selected task +- `Cmd+Enter` - Open task in Notion + +### Environment Variable Override + +For shell use, you can set the token directly: + +```bash +export NOTION_TOKEN="ntn_..." +task "My task" +``` + +This takes precedence over 1Password lookup. + +## Conventions for Other Clients + +When implementing task capture on other platforms (iOS Shortcuts, widgets, etc.), follow these conventions for consistency: + +### Creating Tasks + +**API Endpoint:** `POST https://api.notion.com/v1/pages` + +**Required Headers:** +``` +Authorization: Bearer <token> +Content-Type: application/json +Notion-Version: 2022-06-28 +``` + +**Minimal Payload (quick capture):** +```json +{ + "parent": { "database_id": "<database_id>" }, + "properties": { + "Title": { + "title": [{ "text": { "content": "Task title here" } }] + }, + "Status": { + "status": { "name": "📥 Triage" } + } + } +} +``` + +### Default Values + +| Field | Default | Notes | +|-------|---------|-------| +| Status | 📥 Triage | New tasks land here for processing | +| Context | (none) | Optional: "Work" or "Personal" | +| Energy/Interest | (none) | Optional | +| Time Estimate | (none) | Optional | +| Due Date | (none) | Optional | + +### Status Values + +| Key | Value | Group | +|-----|-------|-------| +| triage | 📥 Triage | To Do | +| ready | 🎯 Ready | To Do | +| waiting | ⏳ Waiting | To Do | +| focus | 🔥 In Focus | In Progress | +| complete | ✅ Complete | Complete | + +### Context Values + +- `Work` (blue) +- `Personal` (green) + +### Energy/Interest Values + +- `5 - Excited` (green) +- `4 - Interested` (blue) +- `3 - Neutral` (gray) +- `2 - Resistant` (yellow) +- `1 - Dreading` (red) + +### Time Estimate Values + +- `5 min` (green) +- `15 min` (green) +- `30 min` (yellow) +- `1 hour` (orange) +- `2+ hours` (red) + +## Files + +``` +~/.config/task-manager/ +├── config.json # Configuration (database ID, 1Password item) +└── README.md # This file + +~/bin/ +├── task-manager # Main CLI script +└── task # Quick capture wrapper + +~/.config/Alfred.alfredpreferences/workflows/ +└── user.workflow.A1B2C3D4-.../ + ├── info.plist # Workflow definition + ├── task_add.sh # Quick capture script + ├── task_list.sh # List tasks script + └── task_complete.sh # Complete task script +``` diff --git a/.config/task-manager/config.json b/.config/task-manager/config.json new file mode 100644 index 0000000..03c5695 --- /dev/null +++ b/.config/task-manager/config.json @@ -0,0 +1,24 @@ +{ + "database_id": "daaac64904a445f99bb1f54f315360c6", + "notion_url": "https://www.notion.so/dc4a2ae3aded4534bbde450f27248b2a", + "op_token_item": "Notion API Token", + "defaults": { + "status": "📥 Triage" + }, + "status_values": { + "triage": "📥 Triage", + "ready": "🎯 Ready", + "waiting": "⏳ Waiting", + "focus": "🔥 In Focus", + "complete": "✅ Complete" + }, + "context_values": ["Work", "Personal"], + "energy_values": [ + "5 - Excited", + "4 - Interested", + "3 - Neutral", + "2 - Resistant", + "1 - Dreading" + ], + "time_estimate_values": ["5 min", "15 min", "30 min", "1 hour", "2+ hours"] +} diff --git a/bin/task b/bin/task new file mode 100755 index 0000000..ee32c77 --- /dev/null +++ b/bin/task @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# +# task - Quick capture for task-manager +# +# Usage: task <title> +# +# Creates a new task in Notion with the given title. +# The task lands in Triage status for later processing. +# + +exec "$(dirname "$0")/task-manager" add "$@" diff --git a/bin/task-manager b/bin/task-manager new file mode 100755 index 0000000..681c458 --- /dev/null +++ b/bin/task-manager @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# +# task-manager - CLI for managing tasks in Notion +# +# Usage: +# task-manager add <title> Add a new task (lands in Triage) +# task-manager list [filter] List tasks (ready, focus, all) +# task-manager complete <id> Mark a task as complete +# task-manager open Open database (or print URL if in SSH) +# +# Options: +# --json Output in Alfred-compatible JSON format +# -h, --help Show this help message +# + +set -euo pipefail + +# Configuration +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/task-manager" +CONFIG_FILE="$CONFIG_DIR/config.json" + +# Notion API +NOTION_API_URL="https://api.notion.com/v1" +NOTION_API_VERSION="2022-06-28" + +# Flags +JSON_OUTPUT=false + +#------------------------------------------------------------------------------ +# Helper functions +#------------------------------------------------------------------------------ + +die() { + echo "Error: $1" >&2 + exit 1 +} + +usage() { + sed -n '3,13p' "$0" | sed 's/^# \?//' + exit 0 +} + +load_config() { + if [[ ! -f "$CONFIG_FILE" ]]; then + die "Config file not found: $CONFIG_FILE" + fi + + DATABASE_ID=$(jq -r '.database_id' "$CONFIG_FILE") + NOTION_URL=$(jq -r '.notion_url' "$CONFIG_FILE") + OP_TOKEN_ITEM=$(jq -r '.op_token_item' "$CONFIG_FILE") + DEFAULT_STATUS=$(jq -r '.defaults.status' "$CONFIG_FILE") + + if [[ -z "$DATABASE_ID" || "$DATABASE_ID" == "null" ]]; then + die "database_id not set in config" + fi +} + +get_notion_token() { + # Priority: environment variable, then 1Password + if [[ -n "${NOTION_TOKEN:-}" ]]; then + echo "$NOTION_TOKEN" + return + fi + + if ! command -v op &>/dev/null; then + die "NOTION_TOKEN not set and 1Password CLI (op) not found" + fi + + local token + token=$(op item get "$OP_TOKEN_ITEM" --fields label=credential 2>/dev/null) || \ + die "Failed to get Notion token from 1Password item: $OP_TOKEN_ITEM" + + echo "$token" +} + +notion_api() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + local token + token=$(get_notion_token) + + local args=( + -s + -X "$method" + -H "Authorization: Bearer $token" + -H "Content-Type: application/json" + -H "Notion-Version: $NOTION_API_VERSION" + ) + + if [[ -n "$data" ]]; then + args+=(-d "$data") + fi + + curl "${args[@]}" "${NOTION_API_URL}${endpoint}" +} + +#------------------------------------------------------------------------------ +# Commands +#------------------------------------------------------------------------------ + +cmd_add() { + local title="$1" + + if [[ -z "$title" ]]; then + die "Task title is required" + fi + + local payload + payload=$(jq -n \ + --arg db_id "$DATABASE_ID" \ + --arg title "$title" \ + --arg status "$DEFAULT_STATUS" \ + '{ + parent: { database_id: $db_id }, + properties: { + Title: { + title: [{ text: { content: $title } }] + }, + Status: { + status: { name: $status } + } + } + }') + + local response + response=$(notion_api POST "/pages" "$payload") + + # Check for errors + if echo "$response" | jq -e '.object == "error"' &>/dev/null; then + local msg + msg=$(echo "$response" | jq -r '.message') + die "Notion API error: $msg" + fi + + local page_id page_url + page_id=$(echo "$response" | jq -r '.id') + page_url=$(echo "$response" | jq -r '.url') + + if $JSON_OUTPUT; then + jq -n --arg id "$page_id" --arg url "$page_url" --arg title "$title" \ + '{ success: true, id: $id, url: $url, title: $title }' + else + echo "Created: $title" + echo "URL: $page_url" + fi +} + +cmd_list() { + local filter="${1:-active}" + + local filter_payload + case "$filter" in + ready) + filter_payload='{"property": "Status", "status": {"equals": "🎯 Ready"}}' + ;; + focus) + filter_payload='{"property": "Status", "status": {"equals": "🔥 In Focus"}}' + ;; + triage) + filter_payload='{"property": "Status", "status": {"equals": "📥 Triage"}}' + ;; + active) + # Ready or In Focus + filter_payload='{ + "or": [ + {"property": "Status", "status": {"equals": "🎯 Ready"}}, + {"property": "Status", "status": {"equals": "🔥 In Focus"}} + ] + }' + ;; + all) + filter_payload='{ + "or": [ + {"property": "Status", "status": {"equals": "📥 Triage"}}, + {"property": "Status", "status": {"equals": "🎯 Ready"}}, + {"property": "Status", "status": {"equals": "⏳ Waiting"}}, + {"property": "Status", "status": {"equals": "🔥 In Focus"}} + ] + }' + ;; + *) + die "Unknown filter: $filter (valid: ready, focus, triage, active, all)" + ;; + esac + + local payload + payload=$(jq -n \ + --argjson filter "$filter_payload" \ + '{ + filter: $filter, + sorts: [ + { property: "Status", direction: "ascending" }, + { property: "Created", direction: "descending" } + ] + }') + + local response + response=$(notion_api POST "/databases/$DATABASE_ID/query" "$payload") + + # Check for errors + if echo "$response" | jq -e '.object == "error"' &>/dev/null; then + local msg + msg=$(echo "$response" | jq -r '.message') + die "Notion API error: $msg" + fi + + if $JSON_OUTPUT; then + # Alfred-compatible JSON output + echo "$response" | jq '{ + items: [.results[] | { + uid: .id, + title: (.properties.Title.title[0].text.content // "Untitled"), + subtitle: ( + (.properties.Status.status.name // "") + + (if .properties.Context.select.name then " [" + .properties.Context.select.name + "]" else "" end) + + (if .properties["Time Estimate"].select.name then " " + .properties["Time Estimate"].select.name else "" end) + + (if .properties["Due Date"].date.start then " Due: " + .properties["Due Date"].date.start else "" end) + ), + arg: .id, + mods: { + cmd: { + subtitle: "Open in Notion", + arg: .url + } + }, + text: { + copy: .url, + largetype: (.properties.Title.title[0].text.content // "Untitled") + } + }] + }' + else + # Human-readable output + echo "$response" | jq -r '.results[] | + (.properties.Status.status.name // "?") + " " + + (.properties.Title.title[0].text.content // "Untitled") + + (if .properties.Context.select.name then " [" + .properties.Context.select.name + "]" else "" end) + + (if .properties["Time Estimate"].select.name then " (" + .properties["Time Estimate"].select.name + ")" else "" end) + + "\n " + .url' + fi +} + +cmd_complete() { + local page_id="$1" + + if [[ -z "$page_id" ]]; then + die "Task ID is required" + fi + + local status_complete + status_complete=$(jq -r '.status_values.complete' "$CONFIG_FILE") + + local payload + payload=$(jq -n \ + --arg status "$status_complete" \ + '{ + properties: { + Status: { + status: { name: $status } + }, + Completed: { + date: { start: (now | strftime("%Y-%m-%d")) } + } + } + }') + + local response + response=$(notion_api PATCH "/pages/$page_id" "$payload") + + # Check for errors + if echo "$response" | jq -e '.object == "error"' &>/dev/null; then + local msg + msg=$(echo "$response" | jq -r '.message') + die "Notion API error: $msg" + fi + + local title + title=$(echo "$response" | jq -r '.properties.Title.title[0].text.content // "Untitled"') + + if $JSON_OUTPUT; then + jq -n --arg id "$page_id" --arg title "$title" \ + '{ success: true, id: $id, title: $title, status: "complete" }' + else + echo "Completed: $title" + fi +} + +cmd_open() { + local url="$NOTION_URL" + + # In SSH session, just print the URL + if [[ -n "${SSH_CLIENT:-}" || -n "${SSH_TTY:-}" ]]; then + echo "$url" + return + fi + + case "$(uname -s)" in + Darwin) + open "$url" + ;; + Linux) + if command -v xdg-open &>/dev/null; then + xdg-open "$url" + else + echo "$url" + fi + ;; + *) + echo "$url" + ;; + esac +} + +#------------------------------------------------------------------------------ +# Main +#------------------------------------------------------------------------------ + +main() { + # Parse global options + while [[ $# -gt 0 ]]; do + case "$1" in + --json) + JSON_OUTPUT=true + shift + ;; + -h|--help) + usage + ;; + -*) + die "Unknown option: $1" + ;; + *) + break + ;; + esac + done + + if [[ $# -eq 0 ]]; then + usage + fi + + local command="$1" + shift + + load_config + + case "$command" in + add) + cmd_add "${*:-}" + ;; + list|ls) + cmd_list "${1:-active}" + ;; + complete|done) + cmd_complete "${1:-}" + ;; + open) + cmd_open + ;; + *) + die "Unknown command: $command" + ;; + esac +} + +main "$@"