Bidirectional sync between org-mode files and Google Calendar via CLI.
- Import all Google Calendar events into org files
- Export org
TODO/NEXTheadings withSCHEDULED/DEADLINEto Google Calendar - Sync can create, update, and delete Google Calendar events
- Delete GCal events when org heading is marked
DONE/CANCELLED - All-day event support
- Time range support (
<start>--<end>) - Multi-calendar support via
:CALENDAR_ID:property - Opt-in export via
:EXPORT_TO_GCAL: t - Conflict resolution via
orgcal resolve - Dooing JSON sync via
orgcal dooing - Neovim integration with auto-sync on save
- Go 1.26.1+
- A Google Cloud project with Calendar API enabled
I wanted org-mode to stay as the source of truth for planning without giving up Google Calendar for sharing, reminders, and mobile visibility. Orgcal keeps the workflow CLI-first and file-based, while still handling the boring sync work.
- Go to Google Cloud Console
- Create a new project
- Enable the Google Calendar API: APIs & Services → Library → search "Google Calendar API" → Enable
- Create OAuth credentials: APIs & Services → Credentials → Create Credentials → OAuth client ID
- Application type: Desktop app
- Under Authorized redirect URIs, add:
http://127.0.0.1:8765 - Copy the Client ID and Client Secret
git clone https://github.com/angshumanhalder/orgcal
cd orgcal
go install .Add to your shell config (~/.zshrc or ~/.bashrc):
export ORGCAL_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export ORGCAL_CLIENT_SECRET="your-client-secret"Reload your shell:
source ~/.zshrcAuthenticate once:
orgcal authA browser window opens. Authorize the app. The token is saved to ~/.local/share/orgcal/token.json and auto-refreshes.
orgcal auth # authenticate with Google
orgcal sync --dir ~/org # bidirectional sync
orgcal import --dir ~/org # GCal → org only
orgcal export --dir ~/org # org → GCal only
orgcal resolve # apply choices from conflicts.json
orgcal dooing --file ~/path.json # sync dooing JSON todosImported events are written to ~/org/gcal/calendar.org.
Back up your org files before the first real sync. There is no --dry-run mode yet, and sync can create, update, and delete calendar events.
* Team Standup
SCHEDULED: <2026-06-27 Sat 10:00>--<2026-06-27 Sat 10:30>
:PROPERTIES:
:GCAL_ID: abc123xyz
:LOCATION: Conference Room A
:GCAL_UPDATED: 2026-06-27T10:00:00Z
:END:
Only headings with SCHEDULED or DEADLINE are exported. Mark with :EXPORT_TO_GCAL: t to opt in (required unless heading state is TODO/NEXT):
* TODO Team Meeting
SCHEDULED: <2026-06-27 Sat 14:00>--<2026-06-27 Sat 15:00>
:PROPERTIES:
:EXPORT_TO_GCAL: t
:END:
Discuss Q3 roadmap.
* TODO Work Review
SCHEDULED: <2026-06-27 Sat 09:00>
:PROPERTIES:
:EXPORT_TO_GCAL: t
:CALENDAR_ID: work@company.com
:END:
* DONE Team Meeting
:PROPERTIES:
:GCAL_ID: abc123xyz
:END:
Marking a heading DONE or CANCELLED with a :GCAL_ID: deletes the event from Google Calendar on next sync.
When a Google Calendar event changed remotely after Orgcal last synced it, Orgcal records the conflict in ~/.local/share/orgcal/conflicts.json instead of guessing. Set each conflict's resolution to local, gcal, or skip, then run:
orgcal resolvelocal overwrites the calendar event, gcal updates the org heading from Google Calendar, and skip drops the conflict without changing either side.
If you use the dooing Neovim plugin, Orgcal can mirror its JSON tasks into Google Calendar:
orgcal dooing
orgcal dooing --file ~/.local/share/nvim/dooing_todos.jsonTasks with a due date are exported as all-day events. Completed tasks with a stored gcal_id are deleted from Google Calendar.
Add to your init.lua:
local orgcal_dir = "~/org"
local function orgcal_run(subcmd, cb)
local cmd = { "orgcal", subcmd, "--dir", orgcal_dir }
local output = {}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
for _, line in ipairs(data) do
if line ~= "" then table.insert(output, line) end
end
end,
on_stderr = function(_, data)
for _, line in ipairs(data) do
if line ~= "" then vim.notify("orgcal: " .. line, vim.log.levels.ERROR) end
end
end,
on_exit = function(_, code)
if code == 0 then
local msg = #output > 0 and table.concat(output, " ") or subcmd .. " done"
vim.notify("orgcal: " .. msg, vim.log.levels.INFO)
if cb then cb() end
end
end,
})
end
vim.api.nvim_create_user_command("OrgCalAuth", function()
vim.fn.jobstart({ "orgcal", "auth" }, {
on_exit = function(_, code)
if code == 0 then vim.notify("orgcal: authenticated", vim.log.levels.INFO) end
end,
})
end, { desc = "Authenticate orgcal with Google Calendar" })
vim.api.nvim_create_user_command("OrgCalSync", function() orgcal_run("sync", nil) end, { desc = "Bidirectional sync" })
vim.api.nvim_create_user_command("OrgCalImport", function() orgcal_run("import", nil) end, { desc = "Import GCal → org" })
vim.api.nvim_create_user_command("OrgCalExport", function() orgcal_run("export", nil) end, { desc = "Export org → GCal" })
-- auto-sync on org file save
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = "*.org",
callback = function() orgcal_run("sync", nil) end,
})| Command | Description |
|---|---|
:OrgCalAuth |
Authenticate with Google Calendar |
:OrgCalSync |
Bidirectional sync |
:OrgCalImport |
Import GCal events to org |
:OrgCalExport |
Export org TODOs to GCal |
| Variable | Description |
|---|---|
ORGCAL_CLIENT_ID |
Google OAuth client ID |
ORGCAL_CLIENT_SECRET |
Google OAuth client secret |
MIT
