/ dev / publish_osf.sh
publish_osf.sh
  1  #!/usr/bin/env bash
  2  set -uo pipefail
  3  
  4  # === Constants and Paths ===
  5  BASEDIR="$(pwd)"
  6  OSF_YAML="$BASEDIR/osf.yaml"
  7  GITFIELD_DIR="$BASEDIR/.gitfield"
  8  LOG_DIR="$GITFIELD_DIR/logs"
  9  SCAN_LOG_INIT="$GITFIELD_DIR/scan_log.json"
 10  SCAN_LOG_PUSH="$GITFIELD_DIR/push_log.json"
 11  TMP_JSON_TOKEN="$GITFIELD_DIR/tmp_token.json"
 12  TMP_JSON_PROJECT="$GITFIELD_DIR/tmp_project.json"
 13  TOKEN_PATH="$HOME/.local/gitfieldlib/osf.token"
 14  mkdir -p "$GITFIELD_DIR" "$LOG_DIR" "$(dirname "$TOKEN_PATH")"
 15  
 16  # === Logging ===
 17  log() {
 18    local level="$1" msg="$2"
 19    echo "[$(date -Iseconds)] [$level] $msg" >> "$LOG_DIR/gitfield_$(date +%Y%m%d).log"
 20    if [[ "$level" == "ERROR" || "$level" == "INFO" || "$VERBOSE" == "true" ]]; then
 21      echo "[$(date -Iseconds)] [$level] $msg" >&2
 22    fi
 23  }
 24  
 25  error() {
 26    log "ERROR" "$1"
 27    exit 1
 28  }
 29  
 30  # === Dependency Check ===
 31  require_yq() {
 32    if ! command -v yq &>/dev/null || ! yq --version 2>/dev/null | grep -q 'version v4'; then
 33      log "INFO" "Installing 'yq' (Go version)..."
 34      YQ_BIN="/usr/local/bin/yq"
 35      ARCH=$(uname -m)
 36      case $ARCH in
 37        x86_64) ARCH=amd64 ;;
 38        aarch64) ARCH=arm64 ;;
 39        *) error "Unsupported architecture: $ARCH" ;;
 40      esac
 41      curl -sL "https://github.com/mikefarah/yq/releases/download/v4.43.1/yq_linux_${ARCH}" -o yq \
 42        && chmod +x yq && sudo mv yq "$YQ_BIN"
 43      log "INFO" "'yq' installed to $YQ_BIN"
 44    fi
 45  }
 46  
 47  require_jq() {
 48    if ! command -v jq &>/dev/null; then
 49      log "INFO" "Installing 'jq'..."
 50      sudo apt update && sudo apt install -y jq
 51      log "INFO" "'jq' installed"
 52    fi
 53  }
 54  
 55  require_yq
 56  require_jq
 57  
 58  # === Token Retrieval ===
 59  get_token() {
 60    if [[ -z "${OSF_TOKEN:-}" ]]; then
 61      if [[ -f "$TOKEN_PATH" ]]; then
 62        OSF_TOKEN=$(<"$TOKEN_PATH")
 63      else
 64        echo -n "🔐 Enter your OSF_TOKEN: " >&2
 65        read -rs OSF_TOKEN
 66        echo >&2
 67        echo "$OSF_TOKEN" > "$TOKEN_PATH"
 68        chmod 600 "$TOKEN_PATH"
 69        log "INFO" "Token saved to $TOKEN_PATH"
 70      fi
 71    fi
 72    RESPONSE=$(curl -s -w "\n%{http_code}" -o "$TMP_JSON_TOKEN" "https://api.osf.io/v2/users/me/" \
 73      -H "Authorization: Bearer $OSF_TOKEN")
 74    HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
 75    [[ "$HTTP_CODE" == "200" ]] || error "Invalid OSF token (HTTP $HTTP_CODE)"
 76  }
 77  
 78  # === Auto-Generate osf.yaml ===
 79  init_mode() {
 80    log "INFO" "Scanning project directory..."
 81    mapfile -t ALL_FILES < <(find "$BASEDIR" -type f \( \
 82      -name '*.md' -o -name '*.pdf' -o -name '*.tex' -o -name '*.csv' -o -name '*.txt' \
 83      -o -name '*.rtf' -o -name '*.doc' -o -name '*.docx' -o -name '*.odt' \
 84      -o -name '*.xls' -o -name '*.xlsx' -o -name '*.ods' -o -name '*.ppt' -o -name '*.pptx' \
 85      -o -name '*.odp' -o -name '*.jpg' -o -name '*.jpeg' -o -name '*.png' -o -name '*.gif' \
 86      -o -name '*.svg' -o -name '*.tiff' -o -name '*.bmp' -o -name '*.webp' \
 87      -o -name '*.sh' -o -name '*.py' -o -name '*.rb' -o -name '*.pl' -o -name '*.js' \
 88      -o -name '*.yaml' -o -name '*.yml' -o -name '*.json' -o -name '*.xml' \
 89      -o -name 'LICENSE*' -o -name 'COPYING*' \
 90      \) ! -path "*/.git/*" ! -path "*/.gitfield/*" ! -path "*/.legacy-gitfield/*" | sort -u)
 91  
 92    if [[ ${#ALL_FILES[@]} -gt 0 ]]; then
 93      IGNORED_FILES=$(git check-ignore "${ALL_FILES[@]}" 2>/dev/null || true)
 94      if [[ -n "$IGNORED_FILES" ]]; then
 95        log "INFO" "Ignored files due to .gitignore: $IGNORED_FILES"
 96        mapfile -t ALL_FILES < <(printf '%s\n' "${ALL_FILES[@]}" | grep -vF "$IGNORED_FILES" | sort -u)
 97      fi
 98    fi
 99  
100    [[ ${#ALL_FILES[@]} -gt 0 ]] || log "WARN" "No files detected in the repository!"
101    log "INFO" "Files detected: ${ALL_FILES[*]}"
102  
103    detect_file() {
104      local keywords=("$@")
105      for file in "${ALL_FILES[@]}"; do
106        for kw in "${keywords[@]}"; do
107          if [[ "${file,,}" == *"${kw,,}"* ]]; then
108            echo "$file"
109            return 0
110          fi
111        done
112      done
113    }
114  
115    WIKI_PATH=$(detect_file "wiki.md" "wiki" "home.md")
116    README_PATH=$(detect_file "readme.md" "README.md")
117    PAPER_PATH=$(detect_file "main.pdf" "theory.pdf" "paper.pdf" "manuscript.pdf")
118  
119    DOCS=()
120    ESSAYS=()
121    IMAGES=()
122    SCRIPTS=()
123    DATA=()
124    FILES=()
125    for f in "${ALL_FILES[@]}"; do
126      case "$f" in
127        "$WIKI_PATH"|"$README_PATH"|"$PAPER_PATH") continue ;;
128      esac
129  
130      if [[ "$f" =~ \.(jpg|jpeg|png|gif|svg|tiff|bmp|webp)$ ]]; then
131        IMAGES+=("$f")
132      elif [[ "$f" =~ \.(sh|py|rb|pl|js)$ ]]; then
133        SCRIPTS+=("$f")
134      elif [[ "$f" =~ \.(csv|json|xml|yaml|yml)$ ]]; then
135        DATA+=("$f")
136      elif [[ "$f" =~ \.(md|pdf|tex|doc|docx|odt|xls|xlsx|ods|ppt|pptx|odp|txt|rtf)$ ]] || [[ "$(basename "$f")" =~ ^(LICENSE|COPYING) ]]; then
137        if [[ "$f" =~ /docs/ ]] || [[ "${f,,}" =~ (guide|tutorial|howto|manual|documentation|workflow|readme) ]]; then
138          DOCS+=("$f")
139        elif [[ "$f" =~ /essays/|/notes/ ]] || [[ "${f,,}" =~ (essay|note|draft|reflection) ]]; then
140          ESSAYS+=("$f")
141        else
142          FILES+=("$f")
143        fi
144      fi
145    done
146  
147    log "INFO" "Generating osf.yaml..."
148    {
149      echo "# osf.yaml - Configuration for publishing to OSF"
150      echo "# Generated on $(date -Iseconds)"
151      echo "# Edit this file to customize what gets published to OSF."
152      echo
153      echo "title: \"$(basename "$BASEDIR")\""
154      echo "description: \"Auto-generated by GitField OSF publisher on $(date -Iseconds)\""
155      echo "category: \"project\""
156      echo "public: false"
157      echo "tags: [gitfield, auto-generated]"
158  
159      echo
160      echo "# Wiki: Main wiki page for your OSF project (wiki.md, home.md)."
161      if [[ -n "$WIKI_PATH" ]]; then
162        echo "wiki:"
163        echo "  path: \"${WIKI_PATH#$BASEDIR/}\""
164        echo "  overwrite: true"
165      else
166        echo "# wiki: Not found. Place a 'wiki.md' in your repository to auto-detect."
167      fi
168  
169      echo
170      echo "# Readme: Main README file (readme.md, README.md)."
171      if [[ -n "$README_PATH" ]]; then
172        echo "readme:"
173        echo "  path: \"${README_PATH#$BASEDIR/}\""
174      else
175        echo "# readme: Not found. Place a 'README.md' in your repository root."
176      fi
177  
178      echo
179      echo "# Paper: Primary academic paper (main.pdf, paper.pdf)."
180      if [[ -n "$PAPER_PATH" ]]; then
181        echo "paper:"
182        echo "  path: \"${PAPER_PATH#$BASEDIR/}\""
183        echo "  name: \"$(basename "$PAPER_PATH")\""
184      else
185        echo "# paper: Not found. Place a PDF (e.g., 'main.pdf') in your repository."
186      fi
187  
188      if ((${#DOCS[@]})); then
189        echo
190        echo "# Docs: Documentation files (.md, .pdf, etc.) in docs/ or with keywords like 'guide'."
191        echo "docs:"
192        for doc in "${DOCS[@]}"; do
193          relative_path="${doc#$BASEDIR/}"
194          echo "  - path: \"$relative_path\""
195          echo "    name: \"$relative_path\""
196        done
197      fi
198  
199      if ((${#ESSAYS[@]})); then
200        echo
201        echo "# Essays: Written essays (.md, .pdf, etc.) in essays/ or with keywords like 'essay'."
202        echo "essays:"
203        for essay in "${ESSAYS[@]}"; do
204          relative_path="${essay#$BASEDIR/}"
205          echo "  - path: \"$relative_path\""
206          echo "    name: \"$relative_path\""
207        done
208      fi
209  
210      if ((${#IMAGES[@]})); then
211        echo
212        echo "# Images: Image files (.jpg, .png, etc.)."
213        echo "images:"
214        for image in "${IMAGES[@]}"; do
215          relative_path="${image#$BASEDIR/}"
216          echo "  - path: \"$relative_path\""
217          echo "    name: \"$relative_path\""
218        done
219      fi
220  
221      if ((${#SCRIPTS[@]})); then
222        echo
223        echo "# Scripts: Executable scripts (.sh, .py, etc.) in bin/, scripts/, or tools/."
224        echo "scripts:"
225        for script in "${SCRIPTS[@]}"; do
226          relative_path="${script#$BASEDIR/}"
227          echo "  - path: \"$relative_path\""
228          echo "    name: \"$relative_path\""
229        done
230      fi
231  
232      if ((${#DATA[@]})); then
233        echo
234        echo "# Data: Structured data files (.csv, .yaml, etc.)."
235        echo "data:"
236        for datum in "${DATA[@]}"; do
237          relative_path="${datum#$BASEDIR/}"
238          echo "  - path: \"$relative_path\""
239          echo "    name: \"$relative_path\""
240        done
241      fi
242  
243      if ((${#FILES[@]})); then
244        echo
245        echo "# Files: Miscellaneous files (.md, LICENSE, etc.)."
246        echo "files:"
247        for file in "${FILES[@]}"; do
248          relative_path="${file#$BASEDIR/}"
249          echo "  - path: \"$relative_path\""
250          echo "    name: \"$relative_path\""
251        done
252      fi
253    } > "$OSF_YAML"
254  
255    log "INFO" "Wiki: $WIKI_PATH, Readme: $README_PATH, Paper: $PAPER_PATH"
256    log "INFO" "Docs: ${DOCS[*]}"
257    log "INFO" "Essays: ${ESSAYS[*]}"
258    log "INFO" "Images: ${IMAGES[*]}"
259    log "INFO" "Scripts: ${SCRIPTS[*]}"
260    log "INFO" "Data: ${DATA[*]}"
261    log "INFO" "Files: ${FILES[*]}"
262  
263    jq -n \
264      --argjson all "$(printf '%s\n' "${ALL_FILES[@]}" | jq -R . | jq -s .)" \
265      --argjson docs "$(printf '%s\n' "${DOCS[@]}" | jq -R . | jq -s .)" \
266      --argjson files "$(printf '%s\n' "${FILES[@]}" | jq -R . | jq -s .)" \
267      --argjson scripts "$(printf '%s\n' "${SCRIPTS[@]}" | jq -R . | jq -s .)" \
268      --arg osf_yaml "$OSF_YAML" \
269      '{detected_files: $all, classified: {docs: $docs, files: $files, scripts: $scripts}, osf_yaml_path: $osf_yaml}' > "$SCAN_LOG_INIT"
270  
271    log "INFO" "Generated $OSF_YAML and scan log"
272    echo "✅ osf.yaml generated at $OSF_YAML." >&2
273  }
274  
275  # === Generate Default Wiki with Links ===
276  generate_wiki() {
277    local wiki_path
278    wiki_path=$(yq e '.wiki.path' "$OSF_YAML")
279    if [[ "$wiki_path" != "null" && ! -f "$wiki_path" ]]; then
280      log "INFO" "Generating default wiki at $wiki_path..."
281      mkdir -p "$(dirname "$wiki_path")"
282      {
283        echo "# Auto-Generated Wiki for $(yq e '.title' "$OSF_YAML")"
284        echo
285        echo "## Project Overview"
286        echo "$(yq e '.description' "$OSF_YAML")"
287        echo
288        echo "## Repository Info"
289        echo "- **Last Commit**: $(git log -1 --pretty=%B 2>/dev/null || echo "No git commits")"
290        echo "- **Commit Hash**: $(git rev-parse HEAD 2>/dev/null || echo "N/A")"
291        if [[ -f "$(yq e '.readme.path' "$OSF_YAML")" ]]; then
292          echo
293          echo "## README Preview"
294          head -n 10 "$(yq e '.readme.path' "$OSF_YAML")"
295        fi
296        echo
297        echo "## Internal Documents"
298        echo "Links to documents uploaded to OSF (will be populated after --push/--overwrite):"
299        for section in docs essays images scripts data files; do
300          local count
301          count=$(yq e ".${section} | length" "$OSF_YAML")
302          if [[ "$count" != "0" && "$count" != "null" ]]; then
303            echo
304            echo "### $(echo "$section" | tr '[:lower:]' '[:upper:]')"
305            for ((i = 0; i < count; i++)); do
306              local name
307              name=$(yq e ".${section}[$i].name" "$OSF_YAML")
308              echo "- [$name](https://osf.io/{NODE_ID}/files/osfstorage/$name)"
309            done
310          fi
311        done
312      } > "$wiki_path"
313      log "INFO" "Default wiki generated at $wiki_path"
314    fi
315  }
316  
317  # === Validate YAML ===
318  validate_yaml() {
319    log "INFO" "Validating $OSF_YAML..."
320    [[ -f "$OSF_YAML" ]] || init_mode
321    for field in title description category public; do
322      [[ $(yq e ".$field" "$OSF_YAML") != "null" ]] || error "Missing field: $field in $OSF_YAML"
323    done
324  }
325  
326  # === Validate and Read push_log.json ===
327  read_project_id() {
328    if [[ ! -f "$SCAN_LOG_PUSH" ]] || ! jq -e '.' "$SCAN_LOG_PUSH" >/dev/null 2>&1; then
329      log "WARN" "No valid push_log.json found"
330      echo ""
331      return
332    fi
333    NODE_ID=$(jq -r '.project_id // ""' "$SCAN_LOG_PUSH")
334    echo "$NODE_ID"
335  }
336  
337  # === Search for Existing Project by Title ===
338  find_project_by_title() {
339    local title="$1"
340    log "INFO" "Searching for project: $title"
341    if [[ "$DRY_RUN" == "true" ]]; then
342      echo "dry-run-$(uuidgen)"
343      return
344    fi
345    ENCODED_TITLE=$(jq -r -n --arg title "$title" '$title|@uri')
346    RESPONSE=$(curl -s -w "\n%{http_code}" -o "$TMP_JSON_PROJECT" "https://api.osf.io/v2/nodes/?filter[title]=$ENCODED_TITLE" \
347      -H "Authorization: Bearer $OSF_TOKEN")
348    HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
349    if [[ "$HTTP_CODE" != "200" ]]; then
350      log "WARN" "Failed to search for project (HTTP $HTTP_CODE)"
351      echo ""
352      return
353    fi
354    NODE_ID=$(jq -r '.data[0].id // ""' "$TMP_JSON_PROJECT")
355    [[ -n "$NODE_ID" ]] && log "INFO" "Found project '$title': $NODE_ID"
356    echo "$NODE_ID"
357  }
358  
359  # === Upload Helpers ===
360  sanitize_filename() {
361    local name="$1"
362    echo "$name" | tr -d '\n' | sed 's/[^[:alnum:]._-]/_/g'
363  }
364  
365  upload_file() {
366    local path="$1" name="$2"
367    local sanitized_name encoded_name
368    sanitized_name=$(sanitize_filename "$name")
369    encoded_name=$(jq -r -n --arg name "$sanitized_name" '$name|@uri')
370    log "INFO" "Uploading $name (sanitized: $sanitized_name) from $path"
371    if [[ "$DRY_RUN" == "true" ]]; then
372      return 0
373    fi
374  
375    CHECK_URL="https://api.osf.io/v2/nodes/$NODE_ID/files/osfstorage/?filter[name]=$encoded_name"
376    RESPONSE=$(curl -s -w "\n%{http_code}" -o "$TMP_JSON_PROJECT" "$CHECK_URL" \
377      -H "Authorization: Bearer $OSF_TOKEN")
378    HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
379  
380    if [[ -z "$HTTP_CODE" ]]; then
381      log "WARN" "No HTTP status for $sanitized_name check. Assuming file does not exist."
382    elif [[ "$HTTP_CODE" == "200" ]]; then
383      FILE_ID=$(jq -r '.data[0].id // ""' "$TMP_JSON_PROJECT")
384      if [[ -n "$FILE_ID" ]]; then
385        if [[ "$MODE" == "overwrite" ]]; then
386          log "INFO" "Deleting existing file $sanitized_name (ID: $FILE_ID)..."
387          DEL_RESPONSE=$(curl -s -w "%{http_code}" -X DELETE "https://api.osf.io/v2/files/$FILE_ID/" \
388            -H "Authorization: Bearer $OSF_TOKEN")
389          [[ "$DEL_RESPONSE" == "204" ]] || log "WARN" "Failed to delete $sanitized_name (HTTP $DEL_RESPONSE)"
390        else
391          log "WARN" "File $sanitized_name exists. Use --overwrite to replace."
392          return 1
393        fi
394      fi
395    elif [[ "$HTTP_CODE" != "404" ]]; then
396      log "WARN" "Check for $sanitized_name failed (HTTP $HTTP_CODE)"
397    fi
398  
399    UPLOAD_URL="https://files.osf.io/v1/resources/$NODE_ID/providers/osfstorage/?kind=file&name=$encoded_name"
400    RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT "$UPLOAD_URL" \
401      -H "Authorization: Bearer $OSF_TOKEN" \
402      -F "file=@$path")
403    HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
404    if [[ "$HTTP_CODE" != "201" ]]; then
405      log "WARN" "Failed to upload $name (HTTP $HTTP_CODE)"
406      return 1
407    fi
408    echo "📤 Uploaded $name to https://osf.io/$NODE_ID/" >&2
409    UPLOADED_FILES+=("$name")
410    return 0
411  }
412  
413  upload_group() {
414    local section="$1"
415    local count
416    count=$(yq e ".${section} | length" "$OSF_YAML")
417    log "INFO" "Uploading $section group ($count items)"
418    if [[ "$count" == "0" || "$count" == "null" ]]; then
419      return 0
420    fi
421    local success_count=0
422    for ((i = 0; i < count; i++)); do
423      local path name
424      path=$(yq e ".${section}[$i].path" "$OSF_YAML")
425      name=$(yq e ".${section}[$i].name" "$OSF_YAML")
426      if [[ -f "$BASEDIR/$path" ]]; then
427        upload_file "$BASEDIR/$path" "$name" && ((success_count++))
428      else
429        log "WARN" "File $path not found, skipping"
430      fi
431    done
432    log "INFO" "Uploaded $success_count/$count items in $section"
433    return 0
434  }
435  
436  upload_wiki() {
437    local wiki_path
438    wiki_path=$(yq e '.wiki.path' "$OSF_YAML")
439    if [[ "$wiki_path" != "null" && -f "$BASEDIR/$wiki_path" ]]; then
440      log "INFO" "Pushing wiki from $wiki_path"
441      if [[ "$DRY_RUN" == "true" ]]; then
442        return 0
443      fi
444      # Update wiki content with actual OSF links
445      local wiki_content
446      wiki_content=$(cat "$BASEDIR/$wiki_path")
447      for file in "${UPLOADED_FILES[@]}"; do
448        wiki_content=$(echo "$wiki_content" | sed "s|https://osf.io/{NODE_ID}/files/osfstorage/$file|https://osf.io/$NODE_ID/files/osfstorage/$file|g")
449      done
450      echo "$wiki_content" > "$BASEDIR/$wiki_path.updated"
451      CONTENT=$(jq -Rs . < "$BASEDIR/$wiki_path.updated")
452      RESPONSE=$(curl -s -w "\n%{http_code}" -X PATCH "https://api.osf.io/v2/nodes/$NODE_ID/wikis/home/" \
453        -H "Authorization: Bearer $OSF_TOKEN" \
454        -H "Content-Type: application/vnd.api+json" \
455        -d @- <<EOF
456  {
457    "data": {
458      "type": "wikis",
459      "attributes": {
460        "content": $CONTENT
461      }
462    }
463  }
464  EOF
465      )
466      HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
467      if [[ "$HTTP_CODE" != "200" ]]; then
468        log "WARN" "Failed to upload wiki (HTTP $HTTP_CODE)"
469        return 1
470      fi
471      echo "📜 Pushed wiki to https://osf.io/$NODE_ID/" >&2
472      rm -f "$BASEDIR/$wiki_path.updated"
473      return 0
474    fi
475    log "INFO" "No wiki to upload"
476    return 0
477  }
478  
479  # === Push Mode ===
480  push_mode() {
481    local MODE="$1"
482    validate_yaml
483    generate_wiki
484    get_token
485  
486    local title description category public
487    title=$(yq e '.title' "$OSF_YAML")
488    description=$(yq e '.description' "$OSF_YAML")
489    category=$(yq e '.category' "$OSF_YAML")
490    public=$(yq e '.public' "$OSF_YAML" | grep -E '^(true|false)$' || error "Invalid 'public' value")
491  
492    NODE_ID=""
493    if [[ "$MODE" == "overwrite" || "$MODE" == "push" ]]; then
494      NODE_ID=$(read_project_id)
495      if [[ -n "$NODE_ID" ]]; then
496        log "INFO" "Using existing OSF project ID: $NODE_ID"
497        RESPONSE=$(curl -s -w "\n%{http_code}" -o "$TMP_JSON_PROJECT" "https://api.osf.io/v2/nodes/$NODE_ID/" \
498          -H "Authorization: Bearer $OSF_TOKEN")
499        HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
500        if [[ "$HTTP_CODE" != "200" ]]; then
501          log "WARN" "Project $NODE_ID not found (HTTP $HTTP_CODE)"
502          NODE_ID=""
503        fi
504      fi
505    fi
506  
507    if [[ -z "$NODE_ID" ]] && [[ "$MODE" == "overwrite" || "$MODE" == "push" ]]; then
508      NODE_ID=$(find_project_by_title "$title")
509    fi
510  
511    if [[ -z "$NODE_ID" ]]; then
512      log "INFO" "Creating new OSF project..."
513      if [[ "$DRY_RUN" == "true" ]]; then
514        NODE_ID="dry-run-$(uuidgen)"
515      else
516        RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "https://api.osf.io/v2/nodes/" \
517          -H "Authorization: Bearer $OSF_TOKEN" \
518          -H "Content-Type: application/vnd.api+json" \
519          -d @- <<EOF
520  {
521    "data": {
522      "type": "nodes",
523      "attributes": {
524        "title": "$title",
525        "description": "$description",
526        "category": "$category",
527        "public": $public
528      }
529    }
530  }
531  EOF
532        )
533        HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
534        [[ "$HTTP_CODE" == "201" ]] || error "Project creation failed (HTTP $HTTP_CODE)"
535        NODE_ID=$(jq -r '.data.id' "$TMP_JSON_PROJECT")
536        [[ "$NODE_ID" != "null" && -n "$NODE_ID" ]] || error "No valid OSF project ID returned"
537        log "INFO" "Project created: $NODE_ID"
538      fi
539    fi
540  
541    [[ -n "$NODE_ID" ]] || error "Failed to determine OSF project ID"
542  
543    log "INFO" "Starting file uploads to project $NODE_ID"
544    declare -a UPLOADED_FILES
545    local overall_success=0
546    if [[ $(yq e '.readme.path' "$OSF_YAML") != "null" ]]; then
547      path=$(yq e '.readme.path' "$OSF_YAML")
548      [[ -f "$BASEDIR/$path" ]] && upload_file "$BASEDIR/$path" "$(basename "$path")" && overall_success=1
549    fi
550    if [[ $(yq e '.paper.path' "$OSF_YAML") != "null" ]]; then
551      path=$(yq e '.paper.path' "$OSF_YAML")
552      name=$(yq e '.paper.name' "$OSF_YAML")
553      [[ -f "$BASEDIR/$path" ]] && upload_file "$BASEDIR/$path" "$name" && overall_success=1
554    fi
555    upload_group "docs" && overall_success=1
556    upload_group "essays" && overall_success=1
557    upload_group "images" && overall_success=1
558    upload_group "scripts" && overall_success=1
559    upload_group "data" && overall_success=1
560    upload_group "files" && overall_success=1
561    upload_wiki && overall_success=1
562  
563    if [[ "$DRY_RUN" != "true" ]]; then
564      jq -n \
565        --arg node_id "$NODE_ID" \
566        --arg title "$title" \
567        --arg pushed_at "$(date -Iseconds)" \
568        '{project_id: $node_id, project_title: $title, pushed_at: $pushed_at}' > "$SCAN_LOG_PUSH"
569    fi
570  
571    if [[ "$overall_success" -eq 1 ]]; then
572      log "INFO" "OSF Push Complete! View project: https://osf.io/$NODE_ID/"
573      echo "✅ OSF Push Complete! View project: https://osf.io/$NODE_ID/" >&2
574    else
575      error "OSF Push Failed: No files uploaded"
576    fi
577  }
578  
579  # === Validate Mode ===
580  validate_mode() {
581    validate_yaml
582    log "INFO" "Checking file existence..."
583    for section in readme paper docs essays images scripts data files wiki; do
584      if [[ "$section" == "docs" || "$section" == "essays" || "$section" == "images" || "$section" == "scripts" || "$section" == "data" || "$section" == "files" ]]; then
585        local count
586        count=$(yq e ".${section} | length" "$OSF_YAML")
587        for ((i = 0; i < count; i++)); do
588          local path
589          path=$(yq e ".${section}[$i].path" "$OSF_YAML")
590          [[ -f "$BASEDIR/$path" ]] || log "WARN" "File $path in $section not found"
591        done
592      elif [[ "$section" != "wiki" ]]; then
593        local path
594        path=$(yq e ".${section}.path" "$OSF_YAML")
595        if [[ "$path" != "null" && -n "$path" && ! -f "$BASEDIR/$path" ]]; then
596          log "WARN" "File $path in $section not found"
597        fi
598      fi
599    done
600    log "INFO" "Validation complete"
601    echo "✅ Validation complete. Check logs: $LOG_DIR/gitfield_$(date +%Y%m%d).log" >&2
602  }
603  
604  # === Clean Mode ===
605  clean_mode() {
606    log "INFO" "Cleaning .gitfield directory..."
607    rm -rf "$GITFIELD_DIR"
608    mkdir -p "$GITFIELD_DIR" "$LOG_DIR"
609    log "INFO" "Cleaned .gitfield directory"
610    echo "✅ Cleaned .gitfield directory" >&2
611  }
612  
613  # === Help Menu ===
614  show_help() {
615    local verbose="$1"
616    if [[ "$verbose" == "true" ]]; then
617      cat <<EOF
618  Usage: $0 [OPTION]
619  
620  Publish content from a Git repository to OSF.
621  
622  Options:
623    --init          Generate osf.yaml and scan log without pushing to OSF
624    --push          Push to existing OSF project or create new
625    --overwrite     Reuse existing OSF project and overwrite files
626    --force         Alias for --overwrite
627    --dry-run       Simulate actions (use with --push or --overwrite)
628    --validate      Check osf.yaml and file existence without pushing
629    --clean         Remove .gitfield logs and start fresh
630    --help          Show this help message (--help --verbose for more details)
631  
632  Examples:
633    $0 --init       # Create osf.yaml based on repo contents
634    $0 --push       # Push to OSF
635    $0 --overwrite  # Push to OSF, overwriting files
636    $0 --dry-run --push  # Simulate a push
637  
638  Repository Structure and Supported Files:
639    - Wiki: wiki.md, home.md (root or docs/)
640    - Readme: readme.md, README.md (root)
641    - Paper: main.pdf, paper.pdf (root or docs/)
642    - Docs: .md, .pdf, etc., in docs/ or with keywords like 'guide'
643    - Essays: .md, .pdf, etc., in essays/ or with keywords like 'essay'
644    - Images: .jpg, .png, etc., in any directory
645    - Scripts: .sh, .py, etc., in bin/, scripts/, or tools/
646    - Data: .csv, .yaml, etc., in any directory
647    - Files: Miscellaneous files (.md, LICENSE, etc.)
648  
649  After running --init, open osf.yaml to customize.
650  EOF
651    else
652      cat <<EOF
653  Usage: $0 [OPTION]
654  
655  Publish content from a Git repository to OSF.
656  
657  Options:
658    --init          Generate osf.yaml
659    --push          Push to OSF
660    --overwrite     Push to OSF, overwrite files
661    --force         Alias for --overwrite
662    --dry-run       Simulate actions (with --push/--overwrite)
663    --validate      Check osf.yaml and files
664    --clean         Remove .gitfield logs
665    --help          Show this help (--help --verbose for more)
666  
667  Examples:
668    $0 --init       # Create osf.yaml
669    $0 --push       # Push to OSF
670  EOF
671    fi
672  }
673  
674  # === CLI Dispatcher ===
675  DRY_RUN="false"
676  VERBOSE="false"
677  MODE=""
678  while [[ $# -gt 0 ]]; do
679    case "$1" in
680      --init) MODE="init" ;;
681      --push) MODE="push" ;;
682      --overwrite|--force) MODE="overwrite" ;;
683      --dry-run) DRY_RUN="true" ;;
684      --validate) MODE="validate" ;;
685      --clean) MODE="clean" ;;
686      --verbose) VERBOSE="true" ;;
687      --help) show_help "$VERBOSE"; exit 0 ;;
688      *) echo "Unknown option: $1" >&2; show_help "false"; exit 1 ;;
689    esac
690    shift
691  done
692  
693  case "$MODE" in
694    init) init_mode ;;
695    push|overwrite) push_mode "$MODE" ;;
696    validate) validate_mode ;;
697    clean) clean_mode ;;
698    *) show_help "false"; exit 0 ;;
699  esac