491facec2e
The update loop previously rewrote the entire CSV in remote order, bundling all row reordering and premature deletions into the first contributor's commit. Now maintains an ordered list, mutating only the specific row per operation. Also fixes double-@ emails for email-based contributors. Made-with: Cursor Entire-Checkpoint: d550254f429d
329 lines
11 KiB
Bash
Executable File
329 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# Script to generate contributor commits from prompts.csv
|
|
# Fetches latest prompts from prompts.chat/prompts.csv
|
|
# Compares with existing prompts.csv and creates commits only for new prompts
|
|
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|
CSV_FILE="$PROJECT_DIR/prompts.csv"
|
|
REMOTE_CSV="$PROJECT_DIR/prompts.csv.remote"
|
|
REMOTE_CSV_URL="https://prompts.chat/prompts.csv"
|
|
|
|
# Fetch latest prompts.csv from prompts.chat
|
|
echo "Fetching latest prompts.csv from $REMOTE_CSV_URL..."
|
|
if ! curl -fsSL "$REMOTE_CSV_URL" -o "$REMOTE_CSV"; then
|
|
echo "Error: Failed to fetch prompts.csv from $REMOTE_CSV_URL"
|
|
echo "Make sure prompts.chat is running and the endpoint is available."
|
|
exit 1
|
|
fi
|
|
echo "Successfully fetched remote prompts.csv"
|
|
|
|
# Initialize local CSV if it doesn't exist
|
|
if [ ! -f "$CSV_FILE" ]; then
|
|
echo "Local prompts.csv not found, initializing with header..."
|
|
head -1 "$REMOTE_CSV" > "$CSV_FILE"
|
|
git add "$CSV_FILE"
|
|
git commit -m "Initialize prompts.csv with header" --allow-empty 2>/dev/null || true
|
|
fi
|
|
|
|
echo ""
|
|
echo "Comparing local and remote prompts.csv..."
|
|
|
|
# Process diffs and create commits for new prompts
|
|
export PROJECT_DIR
|
|
set +e # Temporarily allow non-zero exit
|
|
python3 << 'PYTHON_SCRIPT'
|
|
import csv
|
|
import subprocess
|
|
import os
|
|
import sys
|
|
import re
|
|
|
|
csv.field_size_limit(sys.maxsize)
|
|
|
|
project_dir = os.environ.get('PROJECT_DIR', '.')
|
|
csv_file = os.path.join(project_dir, 'prompts.csv')
|
|
remote_csv = os.path.join(project_dir, 'prompts.csv.remote')
|
|
prompts_md_path = os.path.join(project_dir, 'PROMPTS.md')
|
|
|
|
# --- CSV I/O (ordered, roundtrip-safe) ---
|
|
|
|
def read_csv(path):
|
|
"""Read CSV into an ordered list of rows and an act->index map."""
|
|
rows = []
|
|
index = {}
|
|
skipped = 0
|
|
with open(path, 'r', newline='', encoding='utf-8') as f:
|
|
reader = csv.DictReader(f)
|
|
fieldnames = list(reader.fieldnames) if reader.fieldnames else []
|
|
while True:
|
|
try:
|
|
row = next(reader)
|
|
act = (row.get('act') or '').strip()
|
|
if act:
|
|
index[act] = len(rows)
|
|
rows.append(row)
|
|
except csv.Error as e:
|
|
skipped += 1
|
|
print(f"Skipping row due to CSV error: {e}")
|
|
except StopIteration:
|
|
break
|
|
return rows, index, fieldnames, skipped
|
|
|
|
def write_csv(rows, fieldnames, path):
|
|
"""Write CSV deterministically — unchanged rows produce identical bytes."""
|
|
with open(path, 'w', newline='', encoding='utf-8') as f:
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
for row in rows:
|
|
writer.writerow(row)
|
|
|
|
def rebuild_index(rows):
|
|
"""Rebuild the act->index map after mutations."""
|
|
return {(r.get('act') or '').strip(): i for i, r in enumerate(rows)}
|
|
|
|
def normalize(s):
|
|
"""Normalize a string for comparison only."""
|
|
return (s or '').replace('\r\n', '\n').replace('\r', '\n').strip()
|
|
|
|
# --- Contributor helpers ---
|
|
|
|
def parse_contributors(field):
|
|
if not field:
|
|
return 'anonymous', []
|
|
parts = [c.strip() for c in field.split(',') if c.strip()]
|
|
if not parts:
|
|
return 'anonymous', []
|
|
return parts[0], parts[1:]
|
|
|
|
def make_email(name):
|
|
"""Build an email — don't double-suffix if the contributor is already an email."""
|
|
if '@' in name:
|
|
return name
|
|
return f'{name}@users.noreply.github.com'
|
|
|
|
def build_commit_msg(action, act, co_authors):
|
|
msg = f'{action} prompt: {act}'
|
|
if co_authors:
|
|
msg += '\n\n'
|
|
for ca in co_authors:
|
|
msg += f'Co-authored-by: {ca} <{make_email(ca)}>\n'
|
|
return msg
|
|
|
|
def git_commit(author_name, author_email, message):
|
|
"""Stage CSV + PROMPTS.md and commit if there are real changes. Returns True if committed."""
|
|
subprocess.run(['git', 'add', csv_file, prompts_md_path], check=True)
|
|
if subprocess.run(['git', 'diff', '--cached', '--quiet'], capture_output=True).returncode == 0:
|
|
return False
|
|
env = os.environ.copy()
|
|
env['GIT_AUTHOR_NAME'] = author_name
|
|
env['GIT_AUTHOR_EMAIL'] = author_email
|
|
env['GIT_COMMITTER_NAME'] = author_name
|
|
env['GIT_COMMITTER_EMAIL'] = author_email
|
|
subprocess.run([
|
|
'git', 'commit', '-m', message,
|
|
f'--author={author_name} <{author_email}>'
|
|
], env=env, check=True)
|
|
return True
|
|
|
|
# --- PROMPTS.md helpers ---
|
|
|
|
def format_contributor_links(contributor_field):
|
|
if not contributor_field:
|
|
return '@anonymous'
|
|
contributors = [c.strip() for c in contributor_field.split(',') if c.strip()]
|
|
if not contributors:
|
|
return '@anonymous'
|
|
return ', '.join([f'[@{c}](https://github.com/{c})' for c in contributors])
|
|
|
|
def generate_prompt_block(row):
|
|
act = row.get('act', 'Untitled')
|
|
prompt = row.get('prompt', '')
|
|
contributor = row.get('contributor', '')
|
|
prompt_type = (row.get('type') or 'TEXT').upper()
|
|
|
|
lang = {'TEXT': 'md', 'JSON': 'json', 'YAML': 'yaml'}.get(prompt_type, 'md')
|
|
contributor_links = format_contributor_links(contributor)
|
|
|
|
return (
|
|
f'<details>\n'
|
|
f'<summary><strong>{act}</strong></summary>\n\n'
|
|
f'## {act}\n\n'
|
|
f'Contributed by {contributor_links}\n\n'
|
|
f'```{lang}\n'
|
|
f'{prompt}\n'
|
|
f'```\n\n'
|
|
f'</details>\n\n'
|
|
)
|
|
|
|
def init_prompts_md():
|
|
if not os.path.exists(prompts_md_path):
|
|
with open(prompts_md_path, 'w', encoding='utf-8') as f:
|
|
f.write('# prompts.chat\n\n')
|
|
f.write('> A curated list of prompts for ChatGPT and other AI models.\n\n')
|
|
f.write('---\n\n')
|
|
|
|
def append_prompt_to_md(row):
|
|
init_prompts_md()
|
|
with open(prompts_md_path, 'a', encoding='utf-8') as f:
|
|
f.write(generate_prompt_block(row))
|
|
|
|
def update_prompt_in_md(row):
|
|
act = row.get('act', '')
|
|
if not os.path.exists(prompts_md_path):
|
|
append_prompt_to_md(row)
|
|
return
|
|
with open(prompts_md_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
pattern = rf'<details>\n<summary><strong>{re.escape(act)}</strong></summary>.*?</details>\n\n'
|
|
new_content, count = re.subn(pattern, generate_prompt_block(row), content, flags=re.DOTALL)
|
|
if count > 0:
|
|
with open(prompts_md_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_content)
|
|
else:
|
|
append_prompt_to_md(row)
|
|
|
|
def remove_prompt_from_md(act):
|
|
if not os.path.exists(prompts_md_path):
|
|
return
|
|
with open(prompts_md_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
pattern = rf'<details>\n<summary><strong>{re.escape(act)}</strong></summary>.*?</details>\n\n'
|
|
new_content = re.sub(pattern, '', content, flags=re.DOTALL)
|
|
with open(prompts_md_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_content)
|
|
|
|
# --- Main ---
|
|
|
|
local_rows, local_index, fieldnames, skipped_l = read_csv(csv_file)
|
|
remote_rows, remote_index, remote_fieldnames, skipped_r = read_csv(remote_csv)
|
|
|
|
if not fieldnames:
|
|
fieldnames = remote_fieldnames
|
|
|
|
print(f"Local: {len(local_rows)} prompts" + (f" (skipped {skipped_l})" if skipped_l else ""))
|
|
print(f"Remote: {len(remote_rows)} prompts" + (f" (skipped {skipped_r})" if skipped_r else ""))
|
|
|
|
# Compute diffs
|
|
remote_acts = set(remote_index.keys())
|
|
new_prompts = []
|
|
updated_prompts = []
|
|
deleted_prompts = []
|
|
|
|
for row in remote_rows:
|
|
act = (row.get('act') or '').strip()
|
|
if not act:
|
|
continue
|
|
if act not in local_index:
|
|
new_prompts.append(row)
|
|
else:
|
|
local_row = local_rows[local_index[act]]
|
|
if (normalize(row.get('prompt')) != normalize(local_row.get('prompt')) or
|
|
normalize(row.get('contributor')) != normalize(local_row.get('contributor'))):
|
|
updated_prompts.append(row)
|
|
|
|
for act in list(local_index):
|
|
if act not in remote_acts:
|
|
deleted_prompts.append(local_rows[local_index[act]])
|
|
|
|
print(f"\nNew: {len(new_prompts)}, Updated: {len(updated_prompts)}, Deleted: {len(deleted_prompts)}")
|
|
|
|
if not new_prompts and not updated_prompts and not deleted_prompts:
|
|
print("Already up to date!")
|
|
sys.exit(0)
|
|
|
|
counts = {'add': 0, 'update': 0, 'remove': 0}
|
|
|
|
# 1) Deletes — remove one row at a time, keeping other rows untouched
|
|
if deleted_prompts:
|
|
print("\nRemoving unlisted/deleted prompts...")
|
|
for i, row in enumerate(deleted_prompts, 1):
|
|
act = (row.get('act') or '').strip()
|
|
contributor = (row.get('contributor') or '').strip()
|
|
primary, coauthors = parse_contributors(contributor)
|
|
email = make_email(primary)
|
|
|
|
if act in local_index:
|
|
local_rows.pop(local_index[act])
|
|
local_index = rebuild_index(local_rows)
|
|
|
|
remove_prompt_from_md(act)
|
|
write_csv(local_rows, fieldnames, csv_file)
|
|
|
|
msg = build_commit_msg('Remove', act, coauthors)
|
|
if git_commit(primary, email, msg):
|
|
ca = f" (+ {', '.join(coauthors)})" if coauthors else ""
|
|
print(f" [{i}/{len(deleted_prompts)}] {primary}{ca}: {act}")
|
|
counts['remove'] += 1
|
|
else:
|
|
print(f" [{i}/{len(deleted_prompts)}] {act} — no changes, skipping")
|
|
|
|
# 2) Updates — replace only the specific row in-place
|
|
if updated_prompts:
|
|
print("\nUpdating existing prompts...")
|
|
for i, row in enumerate(updated_prompts, 1):
|
|
act = (row.get('act') or '').strip()
|
|
contributor = (row.get('contributor') or '').strip()
|
|
primary, coauthors = parse_contributors(contributor)
|
|
email = make_email(primary)
|
|
|
|
if act in local_index:
|
|
local_rows[local_index[act]] = row
|
|
|
|
update_prompt_in_md(row)
|
|
write_csv(local_rows, fieldnames, csv_file)
|
|
|
|
msg = build_commit_msg('Update', act, coauthors)
|
|
if git_commit(primary, email, msg):
|
|
ca = f" (+ {', '.join(coauthors)})" if coauthors else ""
|
|
print(f" [{i}/{len(updated_prompts)}] {primary}{ca}: {act}")
|
|
counts['update'] += 1
|
|
else:
|
|
print(f" [{i}/{len(updated_prompts)}] {act} — no changes, skipping")
|
|
|
|
# 3) New prompts — append one at a time
|
|
if new_prompts:
|
|
print("\nAdding new prompts...")
|
|
for i, row in enumerate(new_prompts, 1):
|
|
act = (row.get('act') or '').strip()
|
|
contributor = (row.get('contributor') or '').strip()
|
|
primary, coauthors = parse_contributors(contributor)
|
|
email = make_email(primary)
|
|
|
|
local_rows.append(row)
|
|
local_index[act] = len(local_rows) - 1
|
|
|
|
append_prompt_to_md(row)
|
|
write_csv(local_rows, fieldnames, csv_file)
|
|
|
|
msg = build_commit_msg('Add', act, coauthors)
|
|
if git_commit(primary, email, msg):
|
|
ca = f" (+ {', '.join(coauthors)})" if coauthors else ""
|
|
print(f" [{i}/{len(new_prompts)}] {primary}{ca}: {act}")
|
|
counts['add'] += 1
|
|
else:
|
|
print(f" [{i}/{len(new_prompts)}] {act} — no changes, skipping")
|
|
|
|
print(f"\nDone! {counts['add']} added, {counts['update']} updated, {counts['remove']} removed.")
|
|
|
|
PYTHON_SCRIPT
|
|
PYTHON_EXIT=$?
|
|
set -e # Re-enable exit on error
|
|
|
|
# Clean up
|
|
rm -f "$REMOTE_CSV"
|
|
|
|
# Check for actual Python errors
|
|
if [ $PYTHON_EXIT -ne 0 ]; then
|
|
echo "Error: Script failed with exit code $PYTHON_EXIT"
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
echo "Review with: git log --oneline prompts.csv PROMPTS.md"
|
|
echo ""
|
|
echo "To push: git push origin main"
|