kbgate
v0.5.1
Published
Universal content management system. Optimized for geeks and LLM
Readme
KBGate (Knowledge Base Gate)
This document describes not existing reality but what should be at the very end.
A Markdown knowledge base which acts as universal content management system with a toolset that keeps those Markdown files in sync with other systems.
List of other systems
GitHub is supported natively since git tool can download and upload everything in this repo
- Confluence: Atlassian Wiki which allows to host documents
- Jira: Task Management system (download connector working — see below)
- Google Docs: Google documents which allows to host any documents
- Google Sheets: spreadsheets, mirrored either as forms or as tables (two-way — see below)
- Google Slides: presentations, mirrored as the text of the deck (download only — see below)
- Gmail: pulls unread mail (grouped by conversation) into Markdown (download only — see below)
- Any internal web app behind a corporate login: pulled through a browser session instead of an API token (download only — see below)
The repository is two things in one:
- The docs — admin and end-user documentation for the target project written as plain Markdown so it is easy to read, review, and diff.
- The tooling — Node.js scripts that facilitate upload and download from/to this repository to various systems.
New here? See USAGE.md for full setup, commands, and troubleshooting. The quickest start is
npm run init: it lists the systems below, explains how to get the credentials for the one you pick, and writes the.remote.ymlclient for it.
How it works on example of Confluence
Confluence stores pages in its own rich format (ADF). This project treats the local Markdown as the source of truth and converts in both directions:
- Download: Confluence page (ADF) →
adf-to-markdown→<Title>/readme.md. Images are saved inside the page folder and internal links point to local paths. - Upload:
<Title>/readme.md→markdown-to-adf→ Confluence page.
The filesystem is the source of truth:
each page is a folder, its identity (pageId + title) lives in metadata.md,
and the page hierarchy is folder nesting. See USAGE.md for the layout.
A page's writable properties are plain YAML frontmatter, reconciled with the
live page on every push:
| Key | What it is | How it is stored in Confluence |
| ------------ | ------------------------------ | -------------------------------------------- |
| owner | page owner | the page's ownerId |
| labels | labels | content labels |
| emoji | the emoji before the title | the emoji-title-published content property |
| properties | the Page Properties macro rows | the details macro in the page body |
| status | the status badge | content state — read-only, see below |
An absent key leaves the live value alone rather than clearing it: frontmatter
states what a field should be, not everything it isn't. To say a field should be
nothing, write emoji: null (a bare emoji: means the same in YAML) — pull
then drops the key again, so it clears itself.
properties: is a mirror of an existing Page Properties macro, not a way to
create one. The macro itself is never converted to Markdown — it stays a parked
grommet (see below) — and push copies that parked node with only its value
cells rewritten, so the table's structure and column widths survive. A macro
that holds anything richer than plain key/value text (a link, a mention, several
paragraphs in one cell) is left entirely alone and gets no frontmatter.
The content state (the status badge) is the exception: like a Jira status it
is written through a trailing ! directive instead, so there is one
writable path per field:
---
!In review
<optional free-text comment, after a blank line>!<state name>— set the page's content state.!-— remove it.status:in the frontmatter is written bypulland ignored bypush.- Unlike Jira's, a Confluence directive takes no property lines — every other writable field already lives in frontmatter.
Confluence has no workflow and no transitions: a state is a plain value, and
any allowed state can replace any other. So the scan trans writes for a
Confluence client is just the allowed list per space, not a graph:
npm run trans wiki_dev ENG # one space
npm run trans wiki_dev ENG DOCS # severalA space admin defines up to five space states and may also let authors add
their own custom states; trans records both, and a space where states are
switched off is omitted from <client>.trans.yml entirely. push resolves the
directive's name against that file — and against the space's real settings —
before anything is sent, so a typo or a stale scan aborts the push rather than
being silently mis-resolved. Setting a state publishes a new page version with
an unchanged body, so a state that already matches is never re-sent.
How it works on example of Jira
Jira issue description (and comments) come back as ADF natively in REST API
v3, so the same adf-to-markdown converter used for Confluence handles the
issue body. Unlike Confluence pages, issues have no hierarchy — a project is
a flat list of issues, and each issue folder is named after its stable,
unique issue key (no rename-tracking needed):
Download: Jira issue →
adf-to-markdown→<ProjectFolder>/<ISSUE-KEY>/readme.md, withsummary/type/priority/labels/components/team/Prioritization_commentas YAML frontmatter properties ahead of the description body.Tracked history (status transitions, changes to the configured property/payload fields and description, comments) is clustered into sessions — events no more than 20 minutes apart — and written next to the readme. Edits made within 20 minutes of the issue's own creation are still drafting the ticket, not history, so they are dropped rather than recorded as a session. A shared
.history.mdindex (a##heading, then one list item per session, newest first, no blank lines between them) linking one.<YYYYMMDD_HHMM>.<client>.mddetail file per session, dot-prefixed like the index, named after the session's end time. The index is shared across every remote the issue is mirrored to — each download only regenerates its own client's sessions and merges them back into the same chronology, reverse-sorted regardless of which remote a session came from. Details carry property changes as code blocks, comment bodies, and git-style diffs for rich-text fields. Each comment is its own event, headed### @friendlyrhino, 2026-05-17 13:45 UTC, reply to @mega-builder— author and reply tags resolve through the remote'sUsers:dictionary, the "reply to" annotation coming from Jira Cloud's convention of opening a reply with an @mention (its comments have no real threading). History files live at the issue level (same place Jira puts attachments added through a comment) and are regenerated on every download; they are download-only, like comments.Uploading a new comment is supported: a trailing
\n\n---\n\n<text>block at the very end of the readme (seebugs/JM-1/readme.md) is posted as a Jira comment onpush, then the issue is re-downloaded so the local separator+text disappear. Headings weaker than###are not allowed in that trailing text (outside fenced code). Re-uploading an existing comment (editing a comment already folded into.history.md) is not implemented.That same trailing block can instead be a
!directive, whichpushexecutes as a Jira workflow transition (and/or a plain field change) rather than posting it as a comment — status can never be written as a plain field PUT (a transition is a different kind of write), so this is the only way to move an issue's status throughpush:--- !<transition name or target status name> prop1: new value1 prop2: new value2 <optional free-text comment, after a blank line>!<name>— resolves<name>against the issue's current status: either a transition name, or (if unambiguous) the name of the status it leads to.!alone (nothing after it) — no status change, just apply the properties below.!!alone — change the issue's type; the first two property lines must be, in that order,type: <new type>thenstatus: <new status>. Not every type change is a field write: Jira accepts only a type at the same hierarchy level whose workflow already holds the issue's current status. Neither is visible in the local files, sopushasks the issue's own edit screen before sending anything and reports what is actually on offer instead of Jira's flatThe issue type selected is invalid.400 — see below.- Properties and the trailing comment are both optional in every form; a
markdown image as the very first line (
) is never mistaken for a directive, even though it also starts with!.
Resolving a directive needs to know the target workflow's statuses and transitions, scanned ahead of time with
trans:npm run trans jira_dev JM # every issue type in project JM npm run trans jira_dev JM Bug # only JM/Bugtranswrites<client>.trans.ymlnext to the.remote.ymlfile — a flat, deduplicated list of workflows by name, each with itsstatusesandtransitions(name/from/to). It never touches.remote.ymlitself; a project'stypes.<type>entry must separately name which of those scanned workflows it uses:Projects: JM: types: Bug: workflow: Software Simplified Workflow for Project JMWith that in place,
pushresolves a directive in two steps: it first validates the named workflow's own shape (exists, every transition'sfrom/tois one of its statuses, no duplicate(from, name)pair, and no status/transition name collision — e.g. a transition literally named "Done" that doesn't actually lead to the status "Done"), then resolves the directive's target against that validated workflow and the issue's actual current status. Either step failing aborts the push with a clear error before anything is sent to Jira — a typo'd status/transition name, or a stale.trans.yml, is caught rather than silently mis-resolved.A
!!type change is the one directive move no local file can predict, since Jira's answer depends on hierarchy levels and workflows the scan does not see. Sopushreads the issue's edit screen first and refuses locally, naming the types Jira does offer:✗ GFENG-90254: Jira will not change "Task" to "Service Request" — from status "In Progress" it offers only: Task, Spike. A type at another hierarchy level, or one whose workflow lacks that status, needs Jira's own Move.When Jira offers no type at all — an Epic (hierarchy level 1) is the usual case, since no standard type shares its level — the message says so and points at Jira's Move, which is a different operation than a field write and has no REST equivalent here. The check runs in
--dry-runtoo: learning that a!!cannot work is exactly what dry-running one is for.A transition can also carry a screen — its own form of fields, which Jira accepts only together with the transition, never through a plain field update. What is on that screen is not in the workflow scan (reading screens needs Jira admin), so
pushasks the live issue for the fields of the one transition it is about to run, and routes each directive property by membership: on the screen → sent with the transition, otherwise → the normal field update. Requiredness does not decide this — an optional screen field such asresolutionstill has to ride the transition.To pin that split by hand instead — a transition Jira reports differently than it behaves, or a screen you want spelled out in the file — give the transition either list in
.trans.yml; naming any field at all switches that transition off the live metadata:transitions: - name: Resolve from: In Progress to: Verify required_fields: [resolution] optional_fields: [priority]Both lists take property names as written in the readme, and a name may appear in only one of them.
required_fieldsdoes not change routing; it is used to explain a rejected transition, naming the screen fields the directive left unset.
Custom field IDs (customfield_NNNNN) differ per Jira site, so they are never
hardcoded — a remote's Fields: block maps our property name to that
environment's field ID (see jira_dev.remote.yml's Projects:/Fields:
blocks for the pattern any future custom-field-bearing connector should
follow).
A field's type is not declared anywhere: push reads it from Jira and
sends each value in the shape that type wants — a number as a number, a date as
YYYY-MM-DD, a select as { value: … }, a single label as a one-element array.
This matters because a value typed in a ! directive is plain text:
LoE_Estimate: 5 is the string "5" until the field's type says otherwise, and
a number field answers a string with "Specify a number for the custom field". A
value that cannot be that type is rejected locally, naming the property as the
readme spells it, before anything is written.
The types ride along with the issue the push is about to change
(expand=schema on the issue fetch), so they cost no extra call and need no
special permission. Only creating an issue has no issue to read them off:
that path asks for the site-wide field list (GET /rest/api/3/field) once per
run instead. When neither can be read, push falls back to sending values as
typed, which is what it always did.
A component is the one value a push must never invent. Jira does not reject
an unknown component name when the caller may administer the project — it
creates the component — so a typo would quietly add one to the project. push
reads the project's own components instead and rejects anything else, naming
what it does know:
✗ GFENG-90254: Components: "Okta CIS" is not a component of GFENG —
known: Jira, Okta WIC, Okta CIC, SRE-1P&Cloud, SRE-EnterpriseThat list is read once per project per run, and only when a readme names the field at all.
What the readme says is checked whether or not it differs from the live
issue. Checking only what changed is how the original bug hid: once a push had
created [SRE-1P&Cloud] and put it on the issue, the readme and the issue
agreed, so the next push reported no changes and never looked at the value
again.
A list value belongs in the frontmatter, written as a YAML list. A !
directive line is plain text, so Components: [SRE-1P&Cloud] there is a single
value whose name includes the brackets — which is exactly how a component named
[SRE-1P&Cloud] comes into existence. Any list-typed field written that way is
rejected, with the form to use instead:
✗ GFENG-90254: Components is a list — a directive line is plain text, so
"[SRE-1P&Cloud]" is one literal value. Write it in the frontmatter as:
Components:
- SRE-1P&CloudSome fields hold an opaque id rather than the name they display. Jira's
Team field is one: sending FamilyTeam fails with "Cannot assign a
non-existing team", because the field wants the team's UUID. A remote's
optional Dictionaries: block supplies it:
Dictionaries:
Team: # a property_fields name
FamilyTeam: 55e351a0-f4ed-4f34-8b7e-3e59a287b5f4push then sends the id in place of the name, and rejects a value the block
does not list — naming the ones it does — instead of letting Jira answer with
an opaque 400. pull writes whatever name the API reports, so a key here must
be spelled exactly as that name: this is a lookup table, not a place for
aliases. The ids for a field are quickest to read off
GET /rest/api/3/jql/autocompletedata/suggestions?fieldName=<field>.
Issue links are frontmatter lists of issue keys. A link is one record in
Jira that both issues show, each from its own side: "GF-113 implements GF-111"
is Implements on GF-113 and Implemented By on GF-111. A remote's optional
Links: block names one key per side of a link type:
Links:
Implements: { type: Implements, direction: outward }
Implemented By: { type: Implements, direction: inward }
Projects:
GF:
folder: gf
property_fields: [priority, Implements, Implemented By]# gf/GF-111/readme.md # gf/GF-113/readme.md
Implemented By: Implements:
- GF-112 - GF-111
- GF-113typeis the link type's name;directionis the side the issue itself is on. Link types differ per site:npm run trans <client> <PROJECT>prints a readyLinks:block with both sides of every type. Paste it, delete what you do not need. Configuring only one side is fine — the other is then neither read nor written.- A key works only where a project (or issue type) lists it in
property_fields, like any other field. pushcompares the list with the live links, ignoring order: a new key creates a link, a removed key deletes one. An absent key leaves the links alone;Implemented By: []removes all links of that side. Links are also made on a newly created issue.- Each entry must be an issue key, and not the issue itself. Links are set in
the frontmatter only, never under a
!directive. - The other issue's readme does not change until it is pulled again —
pushnames those issues. Link changes are not part of the tracked history.
Children of an issue (the issues whose parent it is — stories of an epic,
sub-tasks of a story) are a frontmatter list of issue keys too. There is no
config block: list the reserved name children in property_fields.
Projects:
GF:
folder: gf
property_fields: [priority, parent, children]# gf/GF-10/readme.md (an epic)
children:
- GF-11
- GF-12pullfinds the children with the JQLparent = GF-10, in the same project only. Children in other projects are not shown and not changed.pushcompares the list with the live children, ignoring order. A new key sets that issue'sparentto this issue — this moves it away from its old parent.pushreads the old parents first and warns (moving GF-12 (now under GF-20) here), also on--dry-run. A new key that Jira does not find stops the push before anything is written. A removed key clears that issue'sparent. Jira refuses to clear the parent of a sub-task; the push then stops with Jira's error.- An absent
childrenleaves the children alone;children: []detaches all. Each entry must be an issue key of the same project, and not the issue itself. Set it in the frontmatter only, never under a!directive. - The children's readmes, and the old parents of moved children, are behind
until they are pulled again —
pushnames them. - ⚠️ If one push sets
parent: GF-20on GF-12 and also lists GF-12 underchildrenof GF-10, the issue pushed last wins. When GF-10 is pushed last, the move warning shows it; the other order is not detected. - ⚠️ Checked on Jira Cloud only. On Data Center, epic children use the
Epic Linkfield, whichchildrendoes not read or write. - The name
childrencannot be used inpayload_fields,Links:orFields:.
A remote's optional Filters: block maps a name to either a JQL string
(shorthand, all other settings default) or an object:
Filters:
Quick_Filter: labels = urgent # shorthand
Epics_In_Progress: # full form
jql: issuetype = Epic AND status in ("Analyzing", "Approved") ORDER BY Rank ASC
property_fields: [type, status, summary] # default: [type, status, summary]
swimlanes: assignee # optional; any scalar field name; default: none
instructions: Action for each ticket might be added below # this is also the defaultA filter target is project-scoped as <PROJECT>:<name> (or <PROJECT>:filter:<id> for
a saved Jira filter by id): its JQL runs as project=<KEY> AND (<jql>), the
matched issues are downloaded normally, and a folder named after the filter is
written next to them holding a ## Filter <name> (N) index, N the total
matched issue count. Each matched issue is its own #### heading,
#### [KEY](../KEY/readme.md): v1 | v2 | ... — one value per
property_fields name, pipe-separated. When swimlanes is set, tickets are
grouped first under ### <field>: <value> (N) headings (### is reserved
for these swimlanes, N that group's issue count), ordered by first
appearance, with a trailing ### <field>: n/a (N) group for issues with no
value. The folder's
.<client>/metadata.yml records the full project-scoped JQL that was
actually run (project=<KEY> AND ...) and when it was downloaded.
A folder is read as such an index — by pull and push alike — only through
links that point at this remote: a relative ../KEY/readme.md, or an
absolute URL on the remote's own host. A key-shaped link to another Jira site,
or to any other system, is ordinary body text, so an issue can cite
[JRACLOUD-81901](https://jira.atlassian.com/browse/JRACLOUD-81901) and still be
pulled, pushed, and created like any other.
npm run pull jira_dev CJ-27 # one issue
npm run pull jira_dev CJ # whole project
npm run pull jira_dev CJ:Epics_In_Progress # a configured filter
npm run pull jira_dev CJ:filter:10042 # a saved filter by idHow it works on example of Google Docs
Google Docs has no ADF. Instead of a custom converter pair, the connector
delegates conversion to Drive itself: pull asks Drive to export the
document as Markdown (files.export?mimeType=text/markdown), and push
sends the local Markdown back as a media upload that Drive converts into a
Google Doc. The interim dump is therefore Markdown, kept in the gitignored
.inbox//.outbox/ folders like the ADF dumps of the other connectors.
- Download: Google Doc → Drive Markdown export →
<Doc_Name>/readme.md. The Drive file name becomes the#title heading; inlined base64 images are extracted into real files next to the readme; body#headings are shifted down one level (recorded asheadingShiftin metadata and reversed on push). A Drive folder tree pulls as nested local folders — a folder dir carries only afolderIdin its.<client>/metadata.yml, a doc dir carriesdocId,modifiedTime, andcontentHash. - Upload:
<Doc_Name>/readme.md→ local images re-inlined as data URIs → Drive Markdown import. A dir with adocIdis updated (renamed too when the#title changed); one with a readme but nodocIdis created under the nearest ancestor Drive folder, and new local dirs on the way become Drive folders. No-op pushes are detected by comparing the readme's sha256 against thecontentHashrecorded at pull time — the round-trip is lossy, so re-export comparison would always differ. If the remotemodifiedTimemoved since the last pull, push overwrites it with a warning.
One-time setup: create a Desktop app OAuth client in Google Cloud
Console, enable the Drive API, run node src/tools/gdocs-auth.mjs, and put
GDOCS_CLIENT_ID/GDOCS_CLIENT_SECRET/GDOCS_REFRESH_TOKEN into .env
(see gdocs_dev.remote.yml and USAGE.md).
Decision: round-trip through Drive's own Markdown converter is lossy
Because a push replaces the whole document with re-imported Markdown, any Google-side formatting that Markdown cannot express is destroyed on push — the same "filesystem is the source of truth" stance as the Confluence connector.
| Construct | Survives the round-trip? | | --------------------------------------------- | ----------------------------------------------------------------- | | Headings, lists | ✅ verified live | | Images | ✅ verified live — re-inlined as base64 data URIs, layout reset | | Bold/italic/strikethrough | expected ✅ (not yet live-verified) | | Tables (structure), links, blockquotes, rules | expected ✅, column widths/cell styling reset (not live-verified) | | Code blocks | expected structure ✅; fence language likely lost (not verified) | | Comments / suggestions | ❌ not exported, gone after push | | Fonts, colors, page breaks, headers/footers | ❌ no Markdown representation | | Drawings, charts, footnotes | ❌ dropped or rasterized (not yet live-verified) | | Tabs beyond the first (multi-tab docs) | ❌ only the first tab is exported (not yet live-verified) |
The export is capped at 10 MB by Google; larger docs fail with the raw HTTP error. Documents and folders are matched by id, so local renames survive a re-pull, mirroring the Confluence layout logic.
How it works on example of Google Sheets
A spreadsheet is not one document but a workbook of tabs, and a tab is used in one of two ways, which one connector cannot serve. So there are two remote types, and a client picks one:
GOOGLE_SHEETS_FORM— the sheet is a form: individual cells or named ranges hold the values of one record. Each property names a range, and the whole sheet becomes onereadme.md.GOOGLE_SHEETS_TABLE— the sheet is a table: the header row names the properties and every row below it is a record. Each property names a column, and each row becomes its own folder.
Drive nesting becomes directory nesting, exactly as in the Google Docs connector:
Summit_2026/ <- Drive folder (.<client>/metadata.yml: folderId)
Ukraine_Action_Summit/ <- spreadsheet (spreadsheetId, modifiedTime)
To_do_list/ <- sheet (spreadsheetId, sheetId, sheetTitle)
Book_the_venue/ <- row (TABLE) (+ rowKey, rowIndex)
readme.md
row_0007/ <- a row whose key is empty or duplicated
readme.md
Delegate_Profile/ <- sheet (FORM)
readme.md <- the record itself; no row levelA row's folder is named after its key column, which is what lets a re-pull
find the same row after it moved, was re-sorted, or the folder was renamed
locally. A key that is empty — or shared with another row, and therefore
identifying nothing — falls back to row_<NNNN>, the spreadsheet's own row
number.
Nothing is discovered at pull time. Which sheets are mirrored, and how their
cells map to properties, comes from the .remote.yml Spreadsheets: block,
validated against a scan written by trans:
npm run trans gsheets_dev https://docs.google.com/spreadsheets/d/<id>/edit
npm run trans gsheets_dev https://drive.google.com/drive/folders/<id>trans writes <client>.trans.yml next to the .remote.yml — every sheet's
sheetId and header-row columns, plus the workbook's named ranges resolved to
A1 — and prints a paste-ready Spreadsheets: block, because wiring a 40-tab
workbook by hand is the real cost of this connector. It never touches
.remote.yml itself. A sheet the config does not name is skipped with a
warning; a column or named range the scan does not know is an error before any
request goes out, rather than a silently empty property.
Rows that vanish upstream are listed, never deleted, and push creates and deletes none either: it writes values into cells that already exist. The local copy may be the only one left of a row somebody removed.
One-time setup: create a Desktop app OAuth client in Google Cloud Console,
enable the Sheets API and the Drive API, run node src/tools/gsheets-auth.mjs,
and put GSHEETS_CLIENT_ID/GSHEETS_CLIENT_SECRET/GSHEETS_REFRESH_TOKEN into
.env (see examples/gsheets-form.remote.yml and
examples/gsheets-table.remote.yml). The scopes are read-only by default — a
token that cannot write is one less way to damage a live spreadsheet — so a
client that pushes needs --write as well (see below).
Writing back: only what a human changed
push is off unless the client says Push: true, and it never sends a cell
nobody edited. That is not caution for its own sake: a pull renders
formattedValue, the text on screen rather than the value in the cell, so
re-sending an untouched cell is a lossy write. Which cells changed is answered
by .<client>/baseline.yml — one sha256 per field, written at pull time — and
each field gets a three-way verdict:
| Local vs baseline | Sheet vs baseline | What happens |
| ----------------- | ----------------- | ------------------------------------------- |
| same | — | never sent |
| changed | same | written |
| same | changed | left alone; pull brings the change in |
| changed | changed | refused, naming both values; --force wins |
The unit of atomicity is the record. If any field of a readme cannot be pushed, nothing of that record is written — not the other fields, not the metadata, not the baseline. Half a record upstream is worse than none, and the operator gets one list to fix rather than a drip.
Everything is checked locally, before the first write, because Sheets answers
200 to a write that silently destroys a formula or a chip — the API's own
answer proves nothing. A record is refused when a changed field is a formula
cell or holds a smart chip (pull mirrors both as text, and a write would replace
them), when a property value would become a formula, when a ## heading names
no configured field, when the local Markdown would not mirror back identically,
or when a TABLE row has no unique key to find it by. --force lifts only
the both-sides-changed refusal; the rest mean the write would damage data.
Two endpoints do the writing, for a reason: property cells go through
values.batchUpdate as USER_ENTERED, so a number stays a number and a date
stays a date, while payload cells go through spreadsheets.batchUpdate, the
only call that can carry textFormatRuns. They share no transaction, so the
baseline advances per call and a half-finished push resumes without re-sending.
Push resolves every address against the live workbook and never reads
<client>.trans.yml: a named range moves when rows are inserted, and a stale
address would not mis-read a value — it would write into the wrong cell. For the
same reason a named range is the safer thing to point a two-way FORM property
at; a literal C1:C2 in the config rots silently. A range covering several
cells is written at its top-left, and push says so when the value it read came
from elsewhere in that range.
Decision: rich text only in the body, never in the frontmatter
Cells are read through spreadsheets.get?includeGridData=true, not through
values.batchGet: the values endpoint returns bare strings, with no formatting,
no links and no chips. Everything rendered as Markdown lives on CellData.
Where that richness is allowed is a deliberate split:
- Property fields are always plain. YAML frontmatter has to stay scalar — a
**bold**value there is noise for a human and an LLM alike — so a property cell contributes its displayed text and nothing else. - Payload fields become Markdown.
textFormatRunsturn into emphasis, links into links. If a property turns out to need a link, move it topayload_fieldsrather than loosening the frontmatter rule.
Decision: images are not mirrored, in any form
CellData has no image field of any kind — its full field list is
userEnteredValue, effectiveValue, formattedValue, userEnteredFormat,
effectiveFormat, hyperlink, note, textFormatRuns, dataValidation,
pivotTable, dataSourceTable, dataSourceFormula, chipRuns. A picture
placed through Insert → Image, whether "in cell" or "over cells", is
therefore invisible to the API: it cannot be downloaded, and it cannot even be
detected well enough to warn about.
An =IMAGE("…") formula could be read, since the URL sits in formulaValue.
Covering only that path would make the mirror sometimes right about images,
which is worse than one that is consistently silent — so no image files are
written and no  is emitted. An =IMAGE() cell keeps its formula as
text, which at least leaves the URL readable.
| Construct | Survives the pull? |
| ---------------------------------------------------------------------------- | ----------------------------------------------- |
| Cell values (dates, currency, percentages as displayed) | ✅ via formattedValue |
| Bold / italic / strikethrough in a payload cell | ✅ from textFormatRuns |
| Links (a whole-cell hyperlink, or one run) | ✅ as [text](uri) |
| Smart chips (person, rich link) | ✅ as their email or link |
| Formatting in a property cell | ⚠️ dropped by design — frontmatter stays scalar |
| Images, every form | ❌ not supported — see above |
| Charts | ❌ sheets.charts is a spec, not a rendering |
| Cell notes | ❌ fetched but dropped |
| Formulas, merged cells, fill colour, conditional formatting, data validation | ❌ no Markdown representation |
Everything that survives the pull can be pushed back, with two exceptions: a formula cell mirrors as its formula text and a smart chip mirrors as its label, and neither can be rebuilt from what the Markdown holds, so a changed one is refused rather than flattened. A cell's own formatting — number format, colour, width — is untouched by a write; only its value and, for a payload cell, its inline marks are replaced.
How it works on example of Google Slides
Google Slides pull is download-only (no push), and it deliberately mirrors only what Markdown can hold: the text of the deck. A slide is a canvas — positions, sizes, layers, animations — and a push would have to rebuild all of that from a Markdown file, which the "filesystem is the source of truth" model cannot do. So the deck is read, never written.
One presentation is one publication entry: a folder with a readme.md and the
pictures next to it. Drive nesting becomes directory nesting, exactly as in the
Docs and Sheets connectors.
Board_decks/ <- Drive folder (.gslides/metadata.yml: folderId)
Q3_Business_Review/ <- presentation (presentationId, modifiedTime)
readme.md
Q3_Business_Review_10.png- Download: presentation →
presentations.get→<Deck_Name>/readme.md. The Drive file name is the#title, every slide is a###heading, and a slide's subtitle placeholder is####. Speaker notes follow the slide in a fenced block taggednotes— they carry no formatting, and a fence keeps them from being read as Markdown nobody wrote. - Upload: not supported.
Decision: heading levels stop at ### per slide
The mapping is # deck → ### slide → #### subtitle. ## is unused.
Google Slides has no notion of a section: presentations.get returns a flat
list of slides, and the only thing that hints at a break is a layout name,
which a custom theme is free to change. Rather than guess wrong on half the
decks, the level is left empty for a future signal that actually exists.
Inside a slide there is likewise no heading structure to read — a text box is a
text box — so only the SUBTITLE placeholder becomes #### and everything
else is paragraphs, lists and tables.
Two things the API does not order for you, and this connector does:
pageElementscome back in z-order, not reading order, so elements are sorted top-to-bottom then left-to-right before conversion.- A group nests its children under its own transform, so groups are flattened with the transforms composed; a grouped label lands where it is seen, not where the API happens to list it.
Decision: a picture is content only if it is not furniture
Everything a slide inherits from its layout or master is invisible in
slide.pageElements, so a template's frame, rules and page numbers cost
nothing. What remains are pictures placed on the slide itself, and those are
judged by three thresholds, all configurable per remote in an Images: block
because every corporate template has different proportions:
| Rule | Config key | Default |
| ----------------------------------------------------------- | -------------- | ------- |
| The slide's own pageBackgroundFill picture | — | always |
| Covers this share of the slide area or more (a backdrop) | max_area | 0.9 |
| Covers less than this share of the slide area (an icon) | min_area | 0.02 |
| The same bytes on this share of the slides or more (a logo) | repeat_ratio | 0.5 |
The repeat rule needs the bytes, so it runs after download: pictures are hashed, and identical bytes appearing on more than one slide and on enough of the deck are dropped. Every run prints how many pictures each rule rejected, so a threshold that is wrong for a template is visible rather than silent.
Identical bytes that survive are written once and linked wherever they appear.
One-time setup: create a Desktop app OAuth client in Google Cloud Console,
enable the Slides API and the Drive API, run node src/tools/gslides-auth.mjs,
and put GSLIDES_CLIENT_ID/GSLIDES_CLIENT_SECRET/GSLIDES_REFRESH_TOKEN
into .env (see examples/gslides.remote.yml and USAGE.md). Both
scopes requested are read-only — the connector never writes, so a token that
cannot write is one less way to damage a live deck.
npm run pull gslides https://docs.google.com/presentation/d/<id>/edit
npm run pull gslides https://drive.google.com/drive/folders/<id>
npm run pull gslides Board_decks/Q3_Business_ReviewA deck that vanishes upstream is listed, never deleted — and only a folder pull can tell, since pulling one deck says nothing about its neighbours.
| Construct | Survives the pull? |
| ---------------------------------------------------- | ------------------------------------------------------------------ |
| Slide titles, subtitles, paragraphs | ✅ as ### / #### / text |
| Bold, italic, strikethrough | ✅ |
| Bulleted and numbered lists, nested | ✅ ordered vs bulleted is read off the rendered glyph |
| Links | ✅ as [text](url) |
| Tables | ✅ as GFM; the first row is taken as the header |
| Pictures and linked Sheets charts | ✅ downloaded next to readme.md, unless a threshold rejects them |
| Speaker notes | ✅ in a fenced block tagged notes |
| Videos | ✅ as a link — the file itself is not mirrored |
| Underline, superscript, subscript | ⚠️ kept as literal <u>/<sup>/<sub> (no Markdown syntax) |
| Formatting in a slide title | ⚠️ dropped by design — a heading is a label, like a property |
| Merged table cells | ⚠️ text kept in the top-left cell, covered columns left blank |
| Links from one slide to another | ⚠️ plain text — there is one file, so there is nothing to point at |
| Background and decorative pictures | ⚠️ dropped by the Images: thresholds, and counted in the report |
| Sections | ❌ the API has no such concept — see above |
| Layout, sizes, positions, colours, fonts, animations | ❌ no Markdown representation |
| Comments, revision history | ❌ not exposed by presentations.get |
How it works on example of Gmail
Gmail pull is download-only (no reply/send). The unit of pull is the
thread, not the message: an unread message's whole thread is fetched in
full and rendered as one conversation transcript, so a reply on an
already-pulled thread updates the same readme.md instead of creating a
second folder.
- Download: unread messages under a configured Gmail label (
Labels:in the.remote.yml, default{ INBOX: Inbox }) are grouped by thread; each thread's messages (oldest first) become## <From> — <date>sections in<label folder>/<YYYY-MM-DD-subject-slug>/readme.md, dated by the first message. The HTML body is converted withturndown(headings, bold, italic, lists, blockquotes, links, images); inline images are extracted next to the readme (<slug>_10.png, …) and real attachments are saved as plain files..<client>/metadata.ymlcarries thethreadId, used to match the same folder on re-pull across renames. - Mark as read: off by default — pulling never mutates the mailbox.
Setting
MarkAsRead: trueclears a pulled thread'sUNREADlabel in Gmail after it is written to disk.
One-time setup: create a Desktop app OAuth client in Google Cloud
Console, enable the Gmail API, run node src/tools/gmail-auth.mjs, and put
GMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET/GMAIL_REFRESH_TOKEN into .env
(see examples/gmail.remote.yml and USAGE.md).
Decision: the HTML→Markdown conversion is lossy
Only the constructs turndown can already express survive the download; Gmail
quoted-reply blockquotes convert to > rather than being stripped.
| Construct | Survives the pull? |
| ------------------------------------------- | --------------------------------------------------------------------- |
| Headings, bold, italic, lists, links | ✅ |
| Blockquotes (incl. Gmail's quoted replies) | ✅ rendered as > |
| Inline images | ✅ extracted to real files, links rewritten |
| Real attachments | ✅ saved as files next to readme.md |
| Underline | ⚠️ kept as literal <u>…</u> inline HTML (no Markdown syntax exists) |
| Fonts, colors, signatures-as-images styling | ❌ no Markdown representation |
| Read/unread and other Gmail labels | not represented in readme.md (see MarkAsRead) |
How it works on example of a browser session
Every other connector needs a credential the operator can put in .env. A
corporate internal system usually hands out neither an API token nor an OAuth
client — access goes through the company SSO, and the only thing that proves
identity is a session cookie inside a browser. A BROWSER remote turns that
cookie into a credential:
- Login (once):
npm run login <client>opens the remote's own browser profile in a visible window at itsBASE_URL. The operator passes the SSO by hand and presses Enter; the cookies stay in the profile directory named byPROFILE:. ⚠ That directory is a live corporate session — it is gitignored and must never be committed. - Download:
npm run pull <client> <URL>opens the same profile headless, renders the page, takes the first node matching the remote'sSelectors.content, removes itsStrip:selectors, and converts the result withturndowninto<Title>/readme.md. Images are downloaded through the same session (so authenticated attachments work) and rewritten to local files..<client>/metadata.ymlcarriesurl,title,pulledAtand acontentHash; a re-pull matches the folder byurland skips writing when the Markdown has not changed. - Upload: not supported. Writing back would mean driving each system's own editor UI.
A URL outside the remote's BASE_URL origin is refused before the browser
starts — the corporate session is never sent to a host the remote was not
configured for. If a navigation ends on another origin, that is the SSO
bounce, reported as an expired session with the login command to fix it.
The connector knows nothing about the target system: what it takes and what it
throws away lives in the .remote.yml, not in the code (see
examples/browser.remote.yml).
Decision: rendered DOM, not the system's API
Reading a system's REST API converts better — it is how the Confluence and Jira connectors get real structure. It also has to be written per system, and needs an API credential the operator does not have. This connector deliberately takes the lowest common denominator instead: whatever the browser has already rendered. When one system dominates in practice, a connector speaking its API through the same session is the better next step, not more selectors.
| Construct | Survives the pull? |
| --------------------------------------------- | ------------------------------------------------------------------------------ |
| Headings, bold, italic, lists, links | ✅ |
| Tables, strikethrough | ✅ via turndown-plugin-gfm; a cell holding a list or a nested table flattens |
| Images (including behind the login) | ✅ downloaded next to readme.md, links rewritten |
| Relative links | ✅ made absolute, pointing back at the source system |
| Underline | ⚠️ kept as literal <u>…</u> inline HTML (no Markdown syntax exists) |
| Page furniture (nav, sidebars, footers) | ⚠️ removed by Strip: — a site-specific list |
| Content rendered after a click (tabs, expand) | ❌ only what is in the DOM after load is seen |
| Embedded viewers (PDF, video, dashboards) | ❌ no Markdown representation |
| Page identity, versions, comments, labels | ❌ not exposed by the rendered page |
What tooling should provide for each system
What is I need not just Confluence and GitHub? Be my guest.
The tooling can be developed for any systems.
System definition
System is defined by so called remote. Remote configuration consist of type (ATLASSIAN_CONFLUENCE, ATLASSIAN_JIRA, GOOGLE_DOCS, GMAIL, GOOGLE_SHEETS_FORM, GOOGLE_SHEETS_TABLE, GOOGLE_SLIDES and BROWSER are supported today), but might be supported more once it will be developed. Configuration is defined by .remote file. Which contains type and access.
Besides name and access any system configuration should define:
- Define main publication entry scope. e.g. for Confluence it is the page with attachments, labels and comments. Different pages or folder or whiteboard are different entries.
- Define main content of the publications. e.g. for Confluence it is a text and MD-relevant formatting on the page and labels.
- Define decorators: what is system specific decorators are that doesn't have representation in MD but exist in e.g. sizes of the columns in Confluence are in decorators, while table itself is stored in MD.
- Define grommets in Markdown: what should be left in MD to put decoration on it. E.g. tables can be referenced by captions, columns in Confluence can be converted to tags
- Define backstage: which elements in MD won't be stored to the target system and will be replaced by posters. E.g.
- Define how posters are created from MD content and how they are recognized during the download and omit overriding the backstage. e.g. mermaid diagrams will be converted to image and uploaded as image with attachment that describes mermaid as text. And during download it will be automatically picked up
- Define user dictionary: what should be the user tag which is used in GitHub to be transpiled to in the target system e.g. friendlyrhino --> [email protected]. The
Users:block in a.remote.ymlcan either be inline or a reference to a shared file (e.g.Users: users.yml), so several remotes pointed at the same site don't have to repeat the same list.
Tooling should cover following scenarios:
- Download a publication with full scope to the interim dump (for Confluence it is ADF)
- Download BLOB Attachments from the publication (download everything that enhances content of the individual publications, e.g. images and video)
- Upload the interim dump to the target system
- Update BLOB Attachment
- Extract Markdown with grommets from the interim dump
- Extract decoration to transpiler
- Compare two interim dumps with ignoring format and volatiles
- Stage Markdown to the interim dump
How to implement connector with the system?
Basically each scenario should be tested with round-trip. Initially you should have content locally (so called local mirror) We have it in Test Mirror. The round trip works as contract and unit tests:
- check connection to the target system
- download mirror
- format resulted MD files by using prettier
- check that id is retrieved
- upload mirror
- ensure no new version is created, because no change was made
- do minor change to the local MD in mirror
- run verify and make sure no mistake is found
- upload mirror and ensure that new version is created
- change structure by subordinating
Test mirror: Part 2toTest mirror: Part 1and recover references. - run upload and see that
Test mirror: Part 2is now underTest mirror: Part 1
This at first run manually, but later can be automated.
How the data is stored on the local machine
- Each publication entry represented by the folder
- the folder contains readme.md which represents everything that is most important to human operator and to LLM is stored in file readme.md
- the folder contains all attachments related to the publication entry
Browse the docs
Read the .md files directly, or run the local docs server (docsify):
npm install
npm run serve # then open http://localhost:3000Sync with Confluence
You need Node.js 18+ and a .env file with your Atlassian credentials
(ATLASSIAN_URL, ATLASSIAN_USER, ATLASSIAN_TOKEN) — copy the format from
env.example. Never commit .env; it is already ignored.
Two commands, both run as npm run <name>:
Both take a client (a <client>.remote file) as the first argument, which
selects the target system (Confluence or Jira, dispatched from the client's
.remote.yml type) and its credential set:
| Command | What it does |
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| pull | Download into folders. Confluence: space keys or page URLs (npm run pull confl eng). Jira: project key, issue key, or filter (npm run pull jira_dev CJ-27). Gmail: unread threads under a label (npm run pull gmail_dev INBOX). Google Slides: a presentation or folder URL (npm run pull gslides <url>). |
| push | Upload folders back to the remote. Confluence: page folders (npm run push confl eng/Test_mirror [--dry-run]). Jira: issue folders (npm run push jira_dev bugs/JM-27 [--dry-run]). |
Full step-by-step setup, workflows, and troubleshooting are in USAGE.md.
Tests and formatting
npm run test # unit tests for the ADF <-> Markdown converters
npm run test:e2e # full Playwright tests
npm run format # format everything with PrettierArchitecture
src/ is split by how well-tested and system-specific each part is:
src/backend/(adf-to-markdown.mjs,markdown-to-adf.mjs,adf-compare.mjs,page-store.mjs,frontmatter.mjs,transpile.mjs,remote.mjs,cell-markdown.mjs,markdown-to-cell.mjs,sheet-record.mjs,sheet-map.mjs,sheet-baseline.mjs,slides-markdown.mjs,markdown-inline.mjs) — the ADF ⇄ Markdown core (shared by both the Confluence and Jira connectors — Jira'sdescription/comment fields return ADF natively, same as Confluence page bodies), the filesystem-backed page store/frontmatter/macro-decorator parking,.remote.ymlcredential resolution, and the pure converters for the systems that have no ADF:cell-markdown.mjs(a Sheets cell) andslides-markdown.mjs(a whole presentation).markdown-to-cell.mjsis the inverse of the first — Markdown back into a cell's text andtextFormatRuns, which is what makes the Sheets connector two-way — andsheet-baseline.mjsremembers, as one hash per field, what the mirror last agreed with the spreadsheet, so a push can tell a local edit from someone else's.markdown-inline.mjsholds the inline emphasis/escaping both of those share, over a neutral mark set each connector maps its own API format onto.src/gateway/(confluence-api.mjs,jira-api.mjs,gdocs-api.mjs,gmail-api.mjs,gsheets-api.mjs,gslides-api.mjs,google-oauth.mjs) — the raw REST clients: stateless functions that takebaseUrl/authHeader(or, for Google, the OAuth creds object) and talk to each remote system's API.google-oauth.mjsis the shared refresh-token→access-token exchange used by every Google gateway.gdocs-api.mjsdoubles as the generic Drive client —getFile/listChildren/FOLDER_MIMEare not Docs-specific, and the Sheets and Slides connectors import them from there rather than growing a second copy. No local-folder or CLI concerns live here.src/frontend/(pull.mjs,push.mjs,confluence-upload.mjs,confluence-download.mjs,jira-download.mjs,jira-upload.mjs,gdocs-download.mjs,gdocs-upload.mjs,gmail-download.mjs,gsheets-download.mjs,gsheets-upload.mjs,gsheets-targets.mjs,gslides-download.mjs,drive-mirror.mjs,concurrency.mjs,test-full.mjs) — the CLI orchestration that walks local folders and wires the gateway + backend pieces together.pull.mjs/push.mjsare thenpm run pull/npm run pushentry points: they load the selected client's.remote.yml(remote.mjs) and dispatch on itstypeto the matching connector's exportedrunPull/runPush— Confluence's inconfluence-download.mjs/confluence-upload.mjs, Jira's injira-download.mjs/jira-upload.mjs, Google Docs' ingdocs-download.mjs/gdocs-upload.mjs, Gmail's (pull-only) ingmail-download.mjs, Google Sheets' (both remote types, two-way) ingsheets-download.mjs/gsheets-upload.mjs, Google Slides' (pull-only) ingslides-download.mjs.drive-mirror.mjsis what the three Drive-backed connectors share: the CLI argument shape, the target-URL parser, the scan of what is already mirrored, and the collision-free rule for naming a new directory. Each connector module stays directly runnable on its own (node src/frontend/jira-download.mjs …) for debugging.src/tools/(init.mjs,init-types.mjs,confluence-folder-to-page.mjs,google-consent.mjs,gdocs-auth.mjs,gmail-auth.mjs,gsheets-auth.mjs,gslides-auth.mjs,browser-login.mjs) — separate utilities: setting a client up (init), converting local folders into Confluence documents/pages, and the one-time Google OAuth consent flows that produceGDOCS_REFRESH_TOKEN/GMAIL_REFRESH_TOKEN/GSHEETS_REFRESH_TOKEN/GSLIDES_REFRESH_TOKEN. The four auth tools differ only in scope and.envprefix, so the loopback consent flow itself lives once ingoogle-consent.mjs.init.mjsis thenpm run initwizard — it writes a<client>.remote.ymland the.envkeys that client needs; everything that differs from one target system to the next (what it mirrors, how to get its credentials, the YAML to write) is data ininit-types.mjs, so adding a connector means adding a recipe, not a branch. Not part of the frontend, gateway, or backend.
Where does a new file go?
Ask these questions, in order, about what the file does:
- Does it talk to the network (a remote system's REST API) and nothing else
— no local folders, no CLI args? →
src/gateway/. - Does it work only with local data (ADF, Markdown, YAML, the filesystem)
with no network calls of its own? →
src/backend/. - Does it orchestrate a CLI command — parse
process.argv, walk local folders, and call intogateway//backend/to do the real work? →src/frontend/. - Is it a standalone utility that doesn't fit any of the above (its own CLI,
its own concerns, not reused by the other connectors)? →
src/tools/.
This is why, for example, jira-download.mjs and jira-upload.mjs live in
src/frontend/ (they parse CLI args and orchestrate a full download/upload
run) while jira-api.mjs lives in src/gateway/ (it only wraps Jira's REST
endpoints) and remote.mjs lives in src/backend/ (it only reads local
.remote.yml/.env files, no network calls).
Conventions
- Every
readme.mdstarts with a single# Heading; the authoritative page title istitleinmetadata.mdand the H1 is stripped on upload. - Links between pages use relative paths to the target's
readme.md; on upload they are rewritten to Confluence URLs (reconstructed from the targetpageId). - Project rules live in CLAUDE.md; Definitions of Done live in definitions_of_done/.
Decision: native Confluence Markdown export is NOT a replacement
Question evaluated: Confluence Cloud now exposes a native Markdown body
(GET /wiki/api/v2/pages/{id}?body-format=markdown). Can we drop our custom
src/backend/adf-to-markdown.mjs converter and pull pages as Markdown directly?
Verdict: No. As of July 2026 the endpoint works and is API-accessible, but the native output is lossy on exactly the constructs our converter exists to handle. Switching would silently lose all images and all internal cross-references. Keep the ADF → Markdown pipeline as the primary path.
Evidence — probed against a torture-test page carrying every edge case:
| Construct | Native body-format=markdown | Our adf-to-markdown |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| Bold / italic / strikeout, tables, nested lists, JQL autolinks | ✅ correct | ✅ |
| Images / attachments | ❌ dropped entirely — only the caption text survives, no ![]() and no attachment link | ✅ downloads the binary, writes  |
| Cross-references to our pages | ❌ flattened to plain text, so remapLinks has nothing to rewrite into a local file link | ✅ preserved as a link and remapped |
| Underscore / superscript / subscript | ❌ escaped as literal text (\<u\>…\</u\>, \<sup\>…\</sup\>) | ✅ rendered |
| Info / warning / error panels | ❌ flattened, structure lost | ✅ |
| Layout columns / [TOC] | ❌ flattened | ✅ |
| Mentions @User | ~ bare @Name, no account link | ✅ |
| Whiteboard / diagram / children macros | ❌ empty | — |
Where the native export is still useful (narrow):
- Regression oracle — pull native Markdown as a second, independent signal
in
tests/adf-to-markdown.test.mjsand diff against our output to catch drift. - Fallback for legacy pages — pages that return no
atlas_doc_formatbody (seesrc/frontend/confluence-download.mjs) can fall back to native Markdown instead of erroring.
It does not cover the reverse direction (Markdown → Confluence via
markdown-to-adf + src/frontend/confluence-upload.mjs); that stays fully on us.
