AutomationFlowsDevOps › Obsidian Git Task Sync

Obsidian Git Task Sync

Obsidian-Git-Task-Sync. Uses githubTrigger. Event-driven trigger; 2 nodes.

Event trigger★★★★☆ complexity2 nodesGithub Trigger
DevOps Trigger: Event Nodes: 2 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "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"
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Obsidian-Git-Task-Sync. Uses githubTrigger. Event-driven trigger; 2 nodes.

Source: https://github.com/fidgetcoding/task-maxxing/blob/main/workflows/W1-obsidian-git-task-sync.json — original creator credit. Request a take-down →

More DevOps workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

DevOps

Assign Issues To Interested Contributors. Uses githubTrigger, noOp, github. Event-driven trigger; 11 nodes.

Github Trigger, GitHub
DevOps

Streamline your open source project with intelligent issue assignment automation.

GitHub, Github Trigger
DevOps

Automate Assigning Github Issues. Uses noOp, github, githubTrigger. Event-driven trigger; 10 nodes.

GitHub, Github Trigger
DevOps

Automate assigning GitHub issues. Uses noOp, github, githubTrigger. Event-driven trigger; 10 nodes.

GitHub, Github Trigger
DevOps

This workflow assigns a user to an issue if they include "assign me" when opening or commenting. To use this workflow you will need to update the credentials used for the Github nodes.

GitHub, Github Trigger