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 →
{
"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"
}
}
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Assign Issues To Interested Contributors. Uses githubTrigger, noOp, github. Event-driven trigger; 11 nodes.
Streamline your open source project with intelligent issue assignment automation.
Automate Assigning Github Issues. Uses noOp, github, githubTrigger. Event-driven trigger; 10 nodes.
Automate assigning GitHub issues. Uses noOp, github, githubTrigger. Event-driven trigger; 10 nodes.
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.