{
  "name": "Obsidian-Git-Task-Sync",
  "description": "Obsidian-to-Notion+Morgen task sync. On GitHub push, parses task files, upserts Notion DB by hash, creates/updates/closes Morgen tasks, commits .sync-state.json back with [bot:W1] prefix.",
  "nodes": [
    {
      "id": "11ad715e-fa41-41c4-9a45-4b8dfda4704c",
      "name": "On Obsidian Vault Push",
      "type": "n8n-nodes-base.githubTrigger",
      "typeVersion": 1,
      "position": [
        100,
        300
      ],
      "parameters": {
        "authentication": "accessToken",
        "owner": {
          "__rl": true,
          "mode": "name",
          "value": "{{GITHUB_OWNER}}"
        },
        "repository": {
          "__rl": true,
          "mode": "name",
          "value": "{{GITHUB_REPO_NAME}}"
        },
        "events": [
          "push"
        ]
      }
    },
    {
      "id": "102e5117-1468-42b8-9699-2b4c437b0c96",
      "name": "Parse + Sync to Morgen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// ============================================================================\n// W1 \u2014 Parse + Sync to Morgen (formerly \"Parse + Upsert Notion DB\")\n//\n// CUTOVER 2026-05-04: Notion has been dropped from the task-sync stack.\n// This worker now syncs Obsidian \u2192 Morgen ONLY. All Notion call sites\n// (DB query loop, page create, page archive) have been removed. The\n// `__AUTH_NOTION` constant and the `'notionApi'` branch of authedRequest\n// are gone. The `notionPageId` field is preserved in sync-state entries\n// for backward-compatibility with old entries (set to `null` on update;\n// not deleted) but no code path reads or writes a Notion page.\n//\n// Inputs preserved:\n//   - File parsing (parseObsidianTasks, fenced-block aware)\n//   - Sync-state load/save (`.sync-state.json`, schema v2, _tagCache)\n//   - m-ID minting + injection (\"\ud83c\udd94 m-XXXXXXXX\")\n//   - Morgen create / update / close\n//   - GitHub file write-back (idempotent, 409-retry)\n//\n// Inputs removed:\n//   - Notion DB do-while query loop\n//   - toCreate/toArchive Notion calls (createPage, archivePage)\n//   - createdPageIds map (no longer needed)\n//   - notionPageId WRITES (left as `null` on new entries; existing\n//     non-null values are preserved untouched)\n//   - `__AUTH_NOTION` constant\n//   - `'notionApi'` branch of authedRequest (returns explicit error if\n//     anything ever calls it)\n//\n// Commit prefix: still `[bot:W1]` so the orchestrator's loop-prevention\n// check (`headCommitMsg.startsWith('[bot:')`) still skips our own commits.\n// ============================================================================\nconst __AUTH_GH = \"Bearer {{GITHUB_TOKEN}}\";\nconst __AUTH_MORGEN = \"ApiKey {{MORGEN_API_KEY}}\";\nconst authedRequest = async (credType, options) => {\n  const headers = Object.assign({}, options.headers || {});\n  if (credType === 'githubApi') {\n    headers['Authorization'] = __AUTH_GH;\n    if (!headers['Accept']) headers['Accept'] = 'application/vnd.github+json';\n  } else if (credType === 'httpHeaderAuth') {\n    headers['Authorization'] = __AUTH_MORGEN;\n  } else if (credType === 'notionApi') {\n    // 2026-05-04: Notion is dropped. Surface a loud error if any code path\n    // accidentally reaches this branch (none in this file does \u2014 kept as\n    // a tripwire only).\n    throw new Error('notionApi credType called after 2026-05-04 cutover \u2014 Notion is no longer wired');\n  } else {\n    throw new Error('Unknown credType: ' + credType);\n  }\n  return await this.helpers.httpRequest(Object.assign({}, options, { headers }));\n};\nconst PRIORITY_EMOJI_TO_INT = Object.freeze({\n  '\ud83d\udd3a': 1, '\u23eb': 2, '\ud83d\udd3c': 5, '\ud83d\udd3d': 7, '\u23ec': 9,\n});\nconst PRIORITY_INT_TO_EMOJI = Object.freeze({\n  1: '\ud83d\udd3a', 2: '\u23eb', 5: '\ud83d\udd3c', 7: '\ud83d\udd3d', 9: '\u23ec',\n});\nfunction parseObsidianPriority(emoji) {\n  if (emoji == null) return 0;\n  const k = String(emoji);\n  return Object.prototype.hasOwnProperty.call(PRIORITY_EMOJI_TO_INT, k)\n    ? PRIORITY_EMOJI_TO_INT[k] : 0;\n}\nfunction morgenPriorityToObsidian(intVal) {\n  const n = Number(intVal);\n  if (!Number.isFinite(n)) return '';\n  return Object.prototype.hasOwnProperty.call(PRIORITY_INT_TO_EMOJI, n)\n    ? PRIORITY_INT_TO_EMOJI[n] : '';\n}\n// Internal area key \u2192 file path. The \"Notion-prefixed\" Morgen tag labels\n// (e.g. \"09 LAVA-NETWORK\") are still produced by W2's Direction-D and read\n// by maketasks SKILL.md; they are kept here for tag-label parity with W2.\n// They are no longer used to write to a Notion DB \u2014 they're now just the\n// tag-label scheme W2's `notionLabelToAreaKey` recognizes.\nconst NOTION_AREAS = Object.freeze({\n  URGENT: '01 URGENT',\n  GENERAL: '02 GENERAL',\n  LORECRAFT: '03 LORECRAFT',\n  BLOOM: '04 BLOOM',\n  'CART-BLANCHE': '05 CART-BLANCHE',\n  'FIDGETCODING-CONTENT': '06 FIDGETCODING \u00b7 content',\n  'FIDGETCODING-MISC-BUILDING': '07 FIDGETCODING \u00b7 misc-building',\n  'FUTURE-SCHEDULING': '08 FUTURE-SCHEDULING',\n  'LAVA-NETWORK': '09 LAVA-NETWORK',\n  MMA: '10 MMA',\n  PARZVL: '11 PARZVL',\n  WAGMI: '12 WAGMI',\n});\nconst NOTION_AREA_TO_KEY = Object.freeze(\n  Object.fromEntries(Object.entries(NOTION_AREAS).map(([k, v]) => [v, k]))\n);\nconst AREA_TO_FILE = Object.freeze({\n  URGENT: 'TASKS-URGENT.md',\n  GENERAL: 'TASKS-GENERAL.md',\n  LORECRAFT: 'TASKS-LORECRAFT.md',\n  BLOOM: 'TASKS-BLOOM.md',\n  'CART-BLANCHE': 'TASKS-CART-BLANCHE.md',\n  'FIDGETCODING-CONTENT': 'FIDGETCODING/content/TASKS-FIDGETCODING-content.md',\n  'FIDGETCODING-MISC-BUILDING': 'FIDGETCODING/misc-building/TASKS-FIDGETCODING-misc-building.md',\n  'FUTURE-SCHEDULING': 'FUTURE-SCHEDULING/TASKS-FUTURE-SCHEDULING.md',\n  'LAVA-NETWORK': 'TASKS-LAVA-NETWORK.md',\n  MMA: 'TASKS-MMA.md',\n  PARZVL: 'TASKS-PARZVL.md',\n  WAGMI: 'TASKS-WAGMI.md',\n});\nfunction parseArea(sourceFilePath) {\n  if (sourceFilePath == null) return 'GENERAL';\n  const raw = String(sourceFilePath);\n  if (!raw) return 'GENERAL';\n  const p = raw.replace(/\\\\/g, '/').replace(/^\\.\\//, '').replace(/^05-Tasks\\//, '');\n  if (/(^|\\/)FIDGETCODING\\/content\\//.test(p)) return 'FIDGETCODING-CONTENT';\n  if (/(^|\\/)FIDGETCODING\\/misc-building\\//.test(p)) return 'FIDGETCODING-MISC-BUILDING';\n  if (/(^|\\/)FIDGETCODING\\/TASKS-FIDGETCODING\\.md$/.test(p)) return 'FIDGETCODING-CONTENT';\n  if (/(^|\\/)FUTURE-SCHEDULING\\//.test(p)) return 'FUTURE-SCHEDULING';\n  const seg = p.split('/').pop() || '';\n  const m = seg.match(/^TASKS-([A-Za-z0-9][A-Za-z0-9_-]*)\\.md$/i);\n  if (m) {\n    const key = m[1].toUpperCase();\n    if (Object.prototype.hasOwnProperty.call(AREA_TO_FILE, key)) return key;\n  }\n  return 'GENERAL';\n}\nfunction areaKeyToFile(key) {\n  return AREA_TO_FILE[key] || AREA_TO_FILE.GENERAL;\n}\nconst MORGEN_AREAS = Object.freeze({\n  URGENT: 'Urgent',\n  GENERAL: 'General',\n  LORECRAFT: 'Lorecraft',\n  BLOOM: 'Bloom',\n  'CART-BLANCHE': 'Cart-Blanche',\n  'FIDGETCODING-CONTENT': 'Fidgetcoding-Content',\n  'FIDGETCODING-MISC-BUILDING': 'Fidgetcoding-Building',\n  'FUTURE-SCHEDULING': 'Future-Scheduling',\n  'LAVA-NETWORK': 'Lava-Network',\n  MMA: 'MMA',\n  PARZVL: 'Parzvl',\n  WAGMI: 'WAGMI',\n});\nfunction areaKeyToMorgenLabel(key) {\n  return MORGEN_AREAS[key] || MORGEN_AREAS.GENERAL;\n}\nfunction getDesiredMorgenTagLabels(task) {\n  const labels = new Set();\n  labels.add(areaKeyToMorgenLabel(task && task.area));\n  const p = task && task.priority;\n  if (p === 1 || p === 2) labels.add(MORGEN_AREAS.URGENT);\n  return Array.from(labels).sort();\n}\nfunction sameTagLabelSet(a, b) {\n  if (!Array.isArray(a) || !Array.isArray(b)) return false;\n  if (a.length !== b.length) return false;\n  const sa = a.slice().sort();\n  const sb = b.slice().sort();\n  for (let i = 0; i < sa.length; i++) if (sa[i] !== sb[i]) return false;\n  return true;\n}\nconst SAFE_PATH_RE = /^(TASKS-(URGENT|GENERAL|LORECRAFT|BLOOM|CART-BLANCHE|LAVA-NETWORK|MMA|PARZVL|WAGMI)\\.md|FIDGETCODING\\/(content|misc-building)\\/TASKS-FIDGETCODING-(content|misc-building)\\.md|FIDGETCODING\\/TASKS-FIDGETCODING\\.md|FUTURE-SCHEDULING\\/TASKS-FUTURE-SCHEDULING\\.md)$/;\nfunction isSafePath(p) {\n  if (typeof p !== 'string') return false;\n  if (p.includes('..') || p.includes('\\\\') || p.startsWith('/')) return false;\n  const normalized = p.replace(/^05-Tasks\\//, '');\n  return SAFE_PATH_RE.test(normalized);\n}\nfunction computeTaskHash(input) {\n  const i = input || {};\n  const parts = [\n    i.sourceFile == null ? '' : String(i.sourceFile),\n    i.text == null ? '' : String(i.text),\n    i.priority == null ? '0' : String(parseInt(i.priority, 10) || 0),\n    i.due == null ? '' : String(i.due).slice(0, 10),\n    i.scheduled == null ? '' : String(i.scheduled).slice(0, 10),\n  ];\n  return crypto.createHash('sha256').update(parts.join('::'), 'utf8').digest('hex').slice(0, 24);\n}\nfunction computeLineHash(rawLine) {\n  const s = rawLine == null ? '' : String(rawLine).replace(/\\s+$/, '');\n  return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);\n}\nconst TASK_LINE_RE = /^(\\s*)([-*+])\\s+\\[([ xX/\\-!?*])\\]\\s+(.*)$/;\nconst DATE_RE = /(\\d{4}-\\d{2}-\\d{2})/;\nconst FENCE_RE = /^(\\s*)(```|~~~)/;\nconst PRIO_EMOJIS = ['\ud83d\udd3a', '\u23eb', '\ud83d\udd3c', '\ud83d\udd3d', '\u23ec'];\nfunction extractDate(text, emoji) {\n  if (!text) return null;\n  const idx = text.indexOf(emoji);\n  if (idx === -1) return null;\n  const tail = text.slice(idx + emoji.length, idx + emoji.length + 32);\n  const m = tail.match(DATE_RE);\n  return m ? m[1] : null;\n}\nfunction extractPriorityEmoji(text) {\n  if (!text) return '';\n  for (const e of PRIO_EMOJIS) {\n    if (text.indexOf(e) !== -1) return e;\n  }\n  return '';\n}\nfunction extractRecurrence(text) {\n  if (!text) return null;\n  const idx = text.indexOf('\ud83d\udd01');\n  if (idx === -1) return null;\n  const tail = text.slice(idx + '\ud83d\udd01'.length);\n  const stopRe = /[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94]/;\n  const si = tail.search(stopRe);\n  const rule = (si === -1 ? tail : tail.slice(0, si)).trim();\n  return rule || null;\n}\nfunction stripTaskMetadata(text) {\n  if (!text) return '';\n  let out = String(text);\n  out = out.replace(/[\ud83d\udd3a\u23eb\ud83d\udd3c\ud83d\udd3d\u23ec]/g, ' ');\n  out = out.replace(/[\ud83d\udcc5\u23f3\ud83d\udeeb\u2705]\\s*\\d{4}-\\d{2}-\\d{2}/g, ' ');\n  out = out.replace(/\ud83d\udd01[^\ud83d\udcc5\u23f3\ud83d\udeeb\u2705\u23eb\ud83d\udd3a\ud83d\udd3c\ud83d\udd3d\u23ec\ud83c\udd94\\n]*/g, ' ');\n  out = out.replace(/\ud83c\udd94\\s*[A-Za-z0-9_-]+/g, ' ');\n  return out.replace(/\\s+/g, ' ').trim();\n}\nfunction extractMorgenId(text) {\n  if (!text) return null;\n  const idx = text.indexOf('\ud83c\udd94');\n  if (idx === -1) return null;\n  const tail = text.slice(idx + '\ud83c\udd94'.length);\n  const m = tail.match(/^\\s*(m-[0-9a-f]{8})\\b/);\n  return m ? m[1] : null;\n}\nfunction generateMorgenId() {\n  return 'm-' + crypto.randomBytes(4).toString('hex');\n}\nfunction insertMorgenId(rawLine, newId) {\n  if (rawLine == null) return '';\n  if (newId == null || newId === '') return String(rawLine);\n  const raw = String(rawLine);\n  if (raw.indexOf('\ud83c\udd94') !== -1) return raw;\n  const m = raw.match(TASK_LINE_RE);\n  if (!m) return raw;\n  const indent = m[1] || '';\n  const bullet = m[2] || '-';\n  const statusChar = m[3];\n  const body = m[4] || '';\n  const prefix = indent + bullet + ' [' + statusChar + '] ';\n  const token = '\ud83c\udd94 ' + String(newId);\n  const anchors = ['\u2705', '\ud83d\udcc5', '\u23f3', '\ud83d\udeeb', '\ud83d\udd01'];\n  let insertAt = -1;\n  for (const a of anchors) {\n    const idx = body.indexOf(a);\n    if (idx !== -1 && (insertAt === -1 || idx < insertAt)) insertAt = idx;\n  }\n  let newBody;\n  if (insertAt === -1) {\n    newBody = body.replace(/\\s+$/, '') + ' ' + token;\n  } else {\n    const head = body.slice(0, insertAt).replace(/\\s+$/, '');\n    const tail = body.slice(insertAt);\n    newBody = head + ' ' + token + ' ' + tail;\n  }\n  newBody = newBody.replace(/\\s+/g, ' ').replace(/^\\s+/, '');\n  return prefix + newBody;\n}\nfunction parseObsidianTasks(markdown, sourceFilePath) {\n  const out = [];\n  if (markdown == null) return out;\n  let text;\n  try { text = String(markdown); } catch (_) { return out; }\n  text = text.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n  const lines = text.split('\\n');\n  const area = parseArea(sourceFilePath);\n  const sourceFile = sourceFilePath == null ? '' : String(sourceFilePath).replace(/^05-Tasks\\//, '');\n  let inFence = false;\n  let fenceMarker = null;\n  for (let i = 0; i < lines.length; i++) {\n    const rawLine = lines[i];\n    const fm = rawLine.match(FENCE_RE);\n    if (fm) {\n      if (!inFence) { inFence = true; fenceMarker = fm[2]; }\n      else if (rawLine.trim().startsWith(fenceMarker)) { inFence = false; fenceMarker = null; }\n      continue;\n    }\n    if (inFence) continue;\n    let m;\n    try { m = rawLine.match(TASK_LINE_RE); } catch (_) { m = null; }\n    if (!m) continue;\n    const statusChar = m[3];\n    const body = m[4] || '';\n    const done = statusChar === 'x' || statusChar === 'X';\n    const priorityEmoji = extractPriorityEmoji(body);\n    const priority = parseObsidianPriority(priorityEmoji);\n    const due = extractDate(body, '\ud83d\udcc5');\n    const scheduled = extractDate(body, '\u23f3');\n    const start = extractDate(body, '\ud83d\udeeb');\n    const doneDate = extractDate(body, '\u2705');\n    const recurrence = extractRecurrence(body);\n    const morgenId = extractMorgenId(body);\n    const cleanText = stripTaskMetadata(body);\n    const task = {\n      text: cleanText,\n      priority,\n      due: due || null,\n      scheduled: scheduled || null,\n      start: start || null,\n      done,\n      doneDate: doneDate || null,\n      recurrence,\n      morgenId: morgenId || null,\n      lineNo: i + 1,\n      rawLine,\n      area,\n      sourceFile,\n    };\n    task.hash = computeTaskHash(task);\n    out.push(task);\n  }\n  return out;\n}\nconst SYNC_STATE_VERSION = 2;\nfunction emptySyncState() {\n  return { _version: SYNC_STATE_VERSION, _tagCache: {}, entries: {} };\n}\nfunction loadSyncState(rawJsonString) {\n  if (rawJsonString == null || rawJsonString === '') return emptySyncState();\n  let parsed;\n  try { parsed = JSON.parse(String(rawJsonString)); } catch (_) { return emptySyncState(); }\n  if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) return emptySyncState();\n  const s = emptySyncState();\n  const loadedVersion = typeof parsed._version === 'number' ? parsed._version : 0;\n  // Migration v1 \u2192 v2: the first v4 push had a tag-response-parser bug that\n  // left morgenTagLabels populated even though Morgen actually got tags:[].\n  // Strip the local belief so the next run re-diffs every task and pushes\n  // the correct tag set. Also wipe _tagCache since the v1 cache only held\n  // old numbered labels anyway.\n  const needsV2Migration = loadedVersion < 2;\n  s._version = SYNC_STATE_VERSION;\n  if (!needsV2Migration && parsed._tagCache && typeof parsed._tagCache === 'object' && !Array.isArray(parsed._tagCache)) {\n    s._tagCache = Object.assign({}, parsed._tagCache);\n  }\n  if (parsed.entries && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {\n    s.entries = {};\n    for (const k of Object.keys(parsed.entries)) {\n      const v = parsed.entries[k];\n      if (v && typeof v === 'object') {\n        const copy = Object.assign({}, v);\n        if (needsV2Migration) delete copy.morgenTagLabels;\n        s.entries[k] = copy;\n      }\n    }\n  }\n  return s;\n}\nfunction serializeSyncState(state) {\n  const safe = state && typeof state === 'object' ? state : emptySyncState();\n  return JSON.stringify({\n    _version: typeof safe._version === 'number' ? safe._version : SYNC_STATE_VERSION,\n    _tagCache: safe._tagCache && typeof safe._tagCache === 'object' ? safe._tagCache : {},\n    entries: safe.entries && typeof safe.entries === 'object' ? safe.entries : {},\n  }, null, 2) + '\\n';\n}\nfunction upsertMappingEntry(state, hash, patch) {\n  const base = state && typeof state === 'object' ? state : emptySyncState();\n  const nextEntries = Object.assign({}, base.entries || {});\n  const existing = nextEntries[hash] || {};\n  const nowIso = new Date().toISOString();\n  // notionPageId is preserved as a field for backward-compat with older\n  // entries (see SYNC-STATE-FORMAT.md). Post-cutover (2026-05-04) W1 never\n  // sets it to a non-null value; existing non-null values are inherited\n  // from `existing` via Object.assign and left untouched.\n  nextEntries[hash] = Object.assign(\n    { hash, notionPageId: null, morgenTaskId: null, morgenEventId: null,\n      createdAt: existing.createdAt || nowIso, archived: false },\n    existing,\n    patch || {},\n    { hash, updatedAt: nowIso, lastSyncedAt: nowIso },\n  );\n  return {\n    _version: typeof base._version === 'number' ? base._version : SYNC_STATE_VERSION,\n    _tagCache: Object.assign({}, base._tagCache || {}),\n    entries: nextEntries,\n  };\n}\nfunction findByMorgenId(state, morgenTaskId) {\n  if (!state || !state.entries || morgenTaskId == null) return null;\n  const target = String(morgenTaskId);\n  for (const hash of Object.keys(state.entries)) {\n    const e = state.entries[hash];\n    if (e && ((e.morgenTaskId && String(e.morgenTaskId) === target) ||\n              (e.morgenEventId && String(e.morgenEventId) === target))) {\n      return { hash, entry: e };\n    }\n  }\n  return null;\n}\nfunction reconstructObsidianLine(task, existingLine) {\n  const t = task || {};\n  let indent = '';\n  let bullet = '-';\n  let statusChar = t.done ? 'x' : ' ';\n  if (typeof existingLine === 'string' && existingLine.length > 0) {\n    const m = existingLine.match(TASK_LINE_RE);\n    if (m) {\n      indent = m[1] || '';\n      bullet = m[2] || '-';\n      if (t.done === undefined) {\n        statusChar = (m[3] === 'x' || m[3] === 'X') ? 'x' : ' ';\n      }\n    }\n  }\n  const tokens = [];\n  if (t.text != null) tokens.push(String(t.text).trim());\n  const prioEmoji = morgenPriorityToObsidian(t.priority);\n  if (prioEmoji) tokens.push(prioEmoji);\n  if (t.due) tokens.push('\ud83d\udcc5 ' + String(t.due).slice(0, 10));\n  if (t.scheduled) tokens.push('\u23f3 ' + String(t.scheduled).slice(0, 10));\n  if (t.start) tokens.push('\ud83d\udeeb ' + String(t.start).slice(0, 10));\n  if (t.recurrence) tokens.push('\ud83d\udd01 ' + t.recurrence);\n  if (t.done && t.doneDate) tokens.push('\u2705 ' + String(t.doneDate).slice(0, 10));\n  return indent + bullet + ' [' + statusChar + '] ' + tokens.join(' ').replace(/\\s+/g, ' ').trim();\n}\nfunction flipTaskDone(line) {\n  if (line == null) return '';\n  const raw = String(line);\n  const m = raw.match(TASK_LINE_RE);\n  if (!m) return raw;\n  const indent = m[1] || '';\n  const bullet = m[2] || '-';\n  const body = m[4] || '';\n  const today = new Date().toISOString().slice(0, 10);\n  let newBody = body;\n  if (newBody.indexOf('\u2705') === -1) {\n    newBody = newBody.replace(/\\s+$/, '') + ' \u2705 ' + today;\n  }\n  return indent + bullet + ' [x] ' + newBody.trim();\n}\nfunction dateToMorgenLocal(dateStr) {\n  if (dateStr == null || dateStr === '') return null;\n  const s = String(dateStr).trim();\n  if (!s) return null;\n  if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return s + 'T09:00:00';\n  const m = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2})/);\n  if (m) return m[1] + 'T' + m[2];\n  const m2 = s.match(/^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2})$/);\n  if (m2) return m2[1] + 'T' + m2[2] + ':00';\n  return null;\n}\nconst BOT_COMMIT_PREFIXES = Object.freeze(['[bot:W1]', '[bot:W2]', '[bot:W3]', '[bot:backfill]']);\nfunction isBotCommitMessage(msg) {\n  if (msg == null) return false;\n  const s = String(msg);\n  return BOT_COMMIT_PREFIXES.some(p => s.startsWith(p));\n}\nconst crypto = require('crypto');\nconst REPO = 'fidgetcoding/obsidian-tasks-sync';\nconst RATE_BUDGET = 250;\nconst nowIso = new Date().toISOString();\ntry {\n  const pushBody = ($input.first() && $input.first().json && $input.first().json.body) || {};\n  const headCommitMsg = (pushBody.head_commit && pushBody.head_commit.message) || '';\n  if (headCommitMsg.startsWith('[bot:')) {\n    return [{ json: { ok: true, skipped: 'bot-loop-prevention', commit: headCommitMsg.slice(0, 80) } }];\n  }\n  const tree = await authedRequest('githubApi', {\n    method: 'GET',\n    url: 'https://api.github.com/repos/' + REPO + '/git/trees/main?recursive=1',\n    json: true,\n  });\n  const taskFiles = (tree.tree || []).filter(f =>\n    f && f.type === 'blob' && f.path && f.path.endsWith('.md') &&\n    /(^|\\/)TASKS-/.test(f.path) && !f.path.endsWith('/TASKS.md') && f.path !== 'TASKS.md'\n  );\n  const fileContents = await Promise.all(taskFiles.map(async f => {\n    const resp = await authedRequest('githubApi', {\n      method: 'GET',\n      url: 'https://api.github.com/repos/' + REPO + '/contents/' + f.path + '?ref=main',\n      json: true,\n    });\n    return { path: f.path, content: Buffer.from(resp.content, 'base64').toString('utf-8').replace(/\\r\\n/g, '\\n'), sha: resp.sha };\n  }));\n  const allTasks = [];\n  for (const f of fileContents) {\n    allTasks.push(...parseObsidianTasks(f.content, f.path));\n  }\n  const openTasks = allTasks.filter(t => !t.done);\n  const doneTasks = allTasks.filter(t => t.done);\n  let syncState = emptySyncState();\n  let syncStateSha = null;\n  try {\n    const stateResp = await authedRequest('githubApi', {\n      method: 'GET',\n      url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json?ref=main',\n      json: true,\n    });\n    const raw = Buffer.from(stateResp.content, 'base64').toString('utf-8');\n    syncState = loadSyncState(raw);\n    syncStateSha = stateResp.sha;\n  } catch (e) {\n    const msg = String((e && e.message) || '');\n    const isNotFound = msg.includes('404') || (e && (e.httpCode === '404' || e.statusCode === 404));\n    if (!isNotFound) throw e;\n  }\n  const currentHashByMorgenId = new Map();\n  for (const t of allTasks) {\n    if (t.morgenId) currentHashByMorgenId.set(t.morgenId, t.hash);\n  }\n  if (syncState.entries && Object.keys(syncState.entries).length > 0) {\n    const rekeyed = {};\n    for (const [oldHash, entry] of Object.entries(syncState.entries)) {\n      if (!entry) continue;\n      const mid = entry.morgenId;\n      if (mid && currentHashByMorgenId.has(mid)) {\n        const newHash = currentHashByMorgenId.get(mid);\n        const merged = Object.assign({}, rekeyed[newHash] || {}, entry, { hash: newHash });\n        if (rekeyed[newHash] && rekeyed[newHash].morgenTaskId && !merged.morgenTaskId) {\n          merged.morgenTaskId = rekeyed[newHash].morgenTaskId;\n        }\n        // Preserve any pre-existing notionPageId on the merged entry so old\n        // archived rows stay traceable. New entries created below will have\n        // notionPageId: null (set by upsertMappingEntry's defaults).\n        if (rekeyed[newHash] && rekeyed[newHash].notionPageId && !merged.notionPageId) {\n          merged.notionPageId = rekeyed[newHash].notionPageId;\n        }\n        rekeyed[newHash] = merged;\n      } else {\n        if (!rekeyed[oldHash]) rekeyed[oldHash] = entry;\n      }\n    }\n    syncState.entries = rekeyed;\n  }\n  // ==========================================================================\n  // 2026-05-04 cutover: removed Notion DB query loop and Notion create/archive\n  // batch loops. Mapping by Notion page ID is no longer maintained.\n  // ==========================================================================\n  const mappedByHash = new Map();\n  for (const [h, entry] of Object.entries(syncState.entries || {})) {\n    mappedByHash.set(h, entry);\n  }\n  const morgenCreate = [];\n  const morgenClose = [];\n  const dirtyFiles = new Map();\n  const existingIdSet = new Set();\n  for (const t of openTasks) if (t.morgenId) existingIdSet.add(t.morgenId);\n  for (const t of doneTasks) if (t.morgenId) existingIdSet.add(t.morgenId);\n  for (const h of Object.keys(syncState.entries || {})) {\n    const e = syncState.entries[h];\n    if (e && e.morgenId) existingIdSet.add(e.morgenId);\n  }\n  function mintUniqueId() {\n    for (let attempt = 0; attempt < 10; attempt++) {\n      const id = generateMorgenId();\n      if (!existingIdSet.has(id)) { existingIdSet.add(id); return id; }\n    }\n    throw new Error('mintUniqueId: 10 collisions');\n  }\n  const mappedByMorgenId = new Map();\n  for (const h of Object.keys(syncState.entries || {})) {\n    const e = syncState.entries[h];\n    if (e && e.morgenId) mappedByMorgenId.set(e.morgenId, { key: h, entry: e });\n  }\n  for (const task of openTasks) {\n    if (task.morgenId) continue;\n    const newId = mintUniqueId();\n    const file = fileContents.find(function (f) { return f.path === task.sourceFile; });\n    if (!file) continue;\n    if (!isSafePath(task.sourceFile)) continue;\n    const existingDirty = dirtyFiles.get(task.sourceFile);\n    const baseContent = existingDirty ? existingDirty.newContent : file.content;\n    const updatedLine = insertMorgenId(task.rawLine, newId);\n    const idx = baseContent.indexOf(task.rawLine);\n    if (idx === -1) continue;\n    const newContent = baseContent.slice(0, idx) + updatedLine + baseContent.slice(idx + task.rawLine.length);\n    dirtyFiles.set(task.sourceFile, {\n      newContent: newContent,\n      originalSha: existingDirty ? existingDirty.originalSha : file.sha,\n      count: (existingDirty ? existingDirty.count : 0) + 1,\n    });\n    task.morgenId = newId;\n    task.rawLine = updatedLine;\n  }\n  const morgenUpdate = [];\n  for (const task of openTasks) {\n    if (!task.morgenId) continue;\n    const byId = mappedByMorgenId.get(task.morgenId);\n    if (byId && byId.entry.morgenTaskId && !byId.entry.archived) {\n      const e = byId.entry;\n      const desiredLabels = getDesiredMorgenTagLabels(task);\n      const currentLabels = Array.isArray(e.morgenTagLabels) ? e.morgenTagLabels : null;\n      const tagsChanged = !currentLabels || !sameTagLabelSet(currentLabels, desiredLabels);\n      const changed =\n        (e.text !== task.text) ||\n        (e.priority !== task.priority) ||\n        ((e.due || null) !== (task.due || null)) ||\n        tagsChanged;\n      if (changed) morgenUpdate.push({ task, entry: e, desiredLabels, tagsChanged });\n      continue;\n    }\n    const mapped = mappedByHash.get(task.hash);\n    if (mapped && mapped.morgenTaskId && !mapped.archived) continue;\n    morgenCreate.push(task);\n  }\n  for (const task of doneTasks) {\n    if (task.morgenId) {\n      const byId = mappedByMorgenId.get(task.morgenId);\n      if (byId && byId.entry.morgenTaskId && !byId.entry.archived) {\n        morgenClose.push({ entry: byId.entry, oldHash: byId.key });\n        continue;\n      }\n    }\n    for (const [h, entry] of mappedByHash) {\n      if (entry.sourceFile === task.sourceFile && entry.text === task.text && entry.morgenTaskId && !entry.archived) {\n        morgenClose.push({ entry, oldHash: h });\n        break;\n      }\n    }\n  }\n  const neededAreaLabels = new Set();\n  for (const t of morgenCreate) {\n    for (const label of getDesiredMorgenTagLabels(t)) neededAreaLabels.add(label);\n  }\n  for (const u of morgenUpdate) {\n    if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n      for (const label of u.desiredLabels) neededAreaLabels.add(label);\n    }\n  }\n  let uncachedTagCount = 0;\n  for (const label of neededAreaLabels) {\n    if (!syncState._tagCache[label]) uncachedTagCount++;\n  }\n  const morgenCallCount = morgenCreate.length + morgenClose.length + morgenUpdate.length;\n  const willCallMorgen = morgenCallCount > 0 || uncachedTagCount > 0;\n  const projectedPoints = (willCallMorgen ? 10 : 0) + uncachedTagCount + morgenCallCount;\n  if (projectedPoints > RATE_BUDGET) {\n    throw new Error('ABORT [#rate-budget] projected ' + projectedPoints + 'pts > ' + RATE_BUDGET);\n  }\n  const morgenErrors = [];\n  if (willCallMorgen) {\n    const tagsResp = await authedRequest('httpHeaderAuth', {\n      method: 'GET',\n      url: 'https://api.morgen.so/v3/tags/list',\n      json: true,\n    });\n    let tags = [];\n    if (Array.isArray(tagsResp)) tags = tagsResp;\n    else if (tagsResp && Array.isArray(tagsResp.tags)) tags = tagsResp.tags;\n    else if (tagsResp && tagsResp.data && Array.isArray(tagsResp.data.tags)) tags = tagsResp.data.tags;\n    else if (tagsResp && Array.isArray(tagsResp.data)) tags = tagsResp.data;\n    for (const tag of tags) {\n      if (tag && tag.name && tag.id) syncState._tagCache[tag.name] = tag.id;\n    }\n    for (const label of neededAreaLabels) {\n      if (!syncState._tagCache[label]) {\n        try {\n          const resp = await authedRequest('httpHeaderAuth', {\n            method: 'POST',\n            url: 'https://api.morgen.so/v3/tags/create',\n            body: { name: label },\n            json: true,\n          });\n          const newId =\n            (resp && typeof resp.id === 'string' && resp.id) ||\n            (resp && resp.data && typeof resp.data.id === 'string' && resp.data.id) ||\n            (resp && resp.data && resp.data.tag && typeof resp.data.tag.id === 'string' && resp.data.tag.id) ||\n            null;\n          if (newId) {\n            syncState._tagCache[label] = newId;\n          } else {\n            morgenErrors.push('tag-create ' + label + ': unexpected response shape');\n          }\n        } catch (e) {\n          morgenErrors.push('tag-create ' + label + ': ' + String(e && e.message || e).slice(0, 80));\n        }\n      }\n    }\n    for (const task of morgenCreate) {\n      const desiredLabels = getDesiredMorgenTagLabels(task);\n      const tagIds = [];\n      for (const label of desiredLabels) {\n        const id = syncState._tagCache[label];\n        if (id) tagIds.push(id);\n      }\n      const body = {\n        title: (task.text || '').slice(0, 500),\n        description: 'From Obsidian: ' + task.sourceFile,\n        priority: task.priority,\n        taskListId: 'inbox',\n        tags: tagIds,\n      };\n      if (task.due) body.due = dateToMorgenLocal(task.due);\n      try {\n        const resp = await authedRequest('httpHeaderAuth', {\n          method: 'POST',\n          url: 'https://api.morgen.so/v3/tasks/create',\n          body,\n          json: true,\n        });\n        const createdMorgenTaskId = resp && resp.data && resp.data.id;\n        if (createdMorgenTaskId) {\n          syncState = upsertMappingEntry(syncState, task.hash, {\n            // notionPageId stays null on new entries (Notion is dropped 2026-05-04)\n            notionPageId: null,\n            morgenTaskId: createdMorgenTaskId,\n            morgenId: task.morgenId || null,\n            sourceFile: task.sourceFile,\n            lineNo: task.lineNo,\n            text: task.text,\n            area: task.area,\n            priority: task.priority,\n            due: task.due,\n            scheduled: task.scheduled,\n            lineHash: computeLineHash(task.rawLine),\n            morgenTagLabels: desiredLabels,\n          });\n        }\n      } catch (e) {\n        morgenErrors.push('task-create ' + task.hash + ': ' + String(e && e.message || e).slice(0, 80));\n      }\n    }\n    for (const u of morgenUpdate) {\n      const updBody = { id: u.entry.morgenTaskId };\n      if (u.entry.text !== u.task.text) updBody.title = (u.task.text || '').slice(0, 500);\n      if (u.entry.priority !== u.task.priority) updBody.priority = u.task.priority;\n      if ((u.entry.due || null) !== (u.task.due || null)) {\n        updBody.due = u.task.due ? dateToMorgenLocal(u.task.due) : null;\n      }\n      if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n        const tagIds = [];\n        for (const label of u.desiredLabels) {\n          const id = syncState._tagCache[label];\n          if (id) tagIds.push(id);\n        }\n        updBody.tags = tagIds;\n      }\n      try {\n        await authedRequest('httpHeaderAuth', {\n          method: 'POST',\n          url: 'https://api.morgen.so/v3/tasks/update',\n          body: updBody,\n          json: true,\n        });\n        const k = (function () {\n          for (const h of Object.keys(syncState.entries || {})) {\n            if (syncState.entries[h] && syncState.entries[h].morgenId === u.task.morgenId) return h;\n          }\n          return null;\n        })();\n        if (k) {\n          syncState.entries[k].text = u.task.text;\n          syncState.entries[k].priority = u.task.priority;\n          syncState.entries[k].due = u.task.due;\n          syncState.entries[k].scheduled = u.task.scheduled;\n          syncState.entries[k].updatedAt = new Date().toISOString();\n          syncState.entries[k].lineHash = computeLineHash(u.task.rawLine);\n          if (u.tagsChanged && Array.isArray(u.desiredLabels)) {\n            syncState.entries[k].morgenTagLabels = u.desiredLabels;\n          }\n        }\n      } catch (e) {\n        morgenErrors.push('task-update ' + u.task.morgenId + ': ' + String(e && e.message || e).slice(0, 80));\n      }\n    }\n    for (const c of morgenClose) {\n      try {\n        await authedRequest('httpHeaderAuth', {\n          method: 'POST',\n          url: 'https://api.morgen.so/v3/tasks/close',\n          body: { id: c.entry.morgenTaskId },\n          json: true,\n        });\n        syncState = upsertMappingEntry(syncState, c.oldHash, { archived: true });\n      } catch (e) {\n        morgenErrors.push('task-close ' + c.oldHash + ': ' + String(e && e.message || e).slice(0, 80));\n      }\n    }\n  }\n  // ==========================================================================\n  // 2026-05-04 cutover: removed back-fill of `notionPageId` on synced entries.\n  // The field stays at its default (null) on entries created post-cutover.\n  // Pre-cutover entries retain their non-null notionPageId values verbatim.\n  // ==========================================================================\n  const writeBackErrors = [];\n  for (const [wbPath, info] of dirtyFiles) {\n    if (!isSafePath(wbPath)) { writeBackErrors.push('unsafe-path ' + wbPath); continue; }\n    const encodedMd = Buffer.from(info.newContent, 'utf-8').toString('base64');\n    const msgMd = '[bot:W1-assign-id] inject ' + info.count + ' \ud83c\udd94 into ' + wbPath;\n    const pb = { message: msgMd, content: encodedMd, branch: 'main', sha: info.originalSha };\n    try {\n      await authedRequest('githubApi', {\n        method: 'PUT',\n        url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath,\n        body: pb,\n        json: true,\n      });\n    } catch (e) {\n      const status = e && (e.statusCode || (e.response && e.response.statusCode));\n      if (status === 409) {\n        try {\n          const fresh = await authedRequest('githubApi', {\n            method: 'GET',\n            url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath + '?ref=main',\n            json: true,\n          });\n          pb.sha = fresh.sha;\n          await authedRequest('githubApi', {\n            method: 'PUT',\n            url: 'https://api.github.com/repos/' + REPO + '/contents/' + wbPath,\n            body: pb,\n            json: true,\n          });\n        } catch (e2) {\n          writeBackErrors.push(wbPath + ' 409-retry: ' + String(e2 && e2.message || e2).slice(0, 80));\n        }\n      } else {\n        writeBackErrors.push(wbPath + ': ' + String(e && e.message || e).slice(0, 80));\n      }\n    }\n    await new Promise(function (r) { setTimeout(r, 1100); });\n  }\n  const stateJson = serializeSyncState(syncState);\n  const encoded = Buffer.from(stateJson, 'utf-8').toString('base64');\n  const commitMsg = '[bot:W1] sync-state: ' + morgenCreate.length + '+morgen, ' + morgenClose.length + '-morgen, ' + morgenUpdate.length + '~morgen';\n  const putBody = { message: commitMsg, content: encoded, branch: 'main' };\n  if (syncStateSha) putBody.sha = syncStateSha;\n  async function putWithRetry() {\n    try {\n      return await authedRequest('githubApi', {\n        method: 'PUT',\n        url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json',\n        body: putBody,\n        json: true,\n      });\n    } catch (e) {\n      const status = e.statusCode || (e.response && e.response.statusCode);\n      if (status !== 409) throw e;\n      const fresh = await authedRequest('githubApi', {\n        method: 'GET',\n        url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json?ref=main',\n        json: true,\n      });\n      putBody.sha = fresh.sha;\n      return await authedRequest('githubApi', {\n        method: 'PUT',\n        url: 'https://api.github.com/repos/' + REPO + '/contents/.sync-state.json',\n        body: putBody,\n        json: true,\n      });\n    }\n  }\n  await putWithRetry();\n  return [{\n    json: {\n      ok: true,\n      timestamp: nowIso,\n      files_processed: fileContents.length,\n      parsed_open: openTasks.length,\n      parsed_done: doneTasks.length,\n      morgen_created: morgenCreate.length,\n      morgen_closed: morgenClose.length,\n      morgen_updated: morgenUpdate.length,\n      morgen_points: projectedPoints,\n      morgen_errors: morgenErrors.slice(0, 10),\n      writeback_errors: writeBackErrors.slice(0, 10),\n      commit_message: commitMsg,\n    }\n  }];\n} catch (e) {\n  const safe = { message: String((e && e.message) || e), name: (e && e.name) || 'Error' };\n  return [{ json: { error: safe, failed: true, timestamp: new Date().toISOString() } }];\n}\n"
      }
    }
  ],
  "connections": {
    "On Obsidian Vault Push": {
      "main": [
        [
          {
            "node": "Parse + Upsert Notion DB",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": true,
    "callerPolicy": "workflowsFromSameOwner"
  }
}