The refresh token grants full read/write/delete access to the user's Drive.
The settings-tab field displayed it in plaintext, so anyone screenshotting
or screen-sharing the NeoGDSync settings tab would leak it. Masked as a
password input with a show/hide toggle.
Claude-Session: https://claude.ai/code/session_011DimwV9zDr1ViCXRCZPxC9
Co-authored-by: Claude <noreply@anthropic.com>
Correctness:
- Edits made while a sync runs are no longer dropped. Vault event handlers
now ignore only paths the sync itself wrote (Syncer.syncWrites) instead of
suppressing all events behind a global flag, and pending ops are cleared
per-op right after success instead of in a bulk sweep that also wiped
ops re-queued mid-sync.
- Renames now propagate to Drive: handlePush compares the Drive-side name
and calls files.rename, so a local a.md -> b.md no longer leaves the old
name on Drive (which later caused duplicate uploads after a rebuild).
- A 'modify' op with no index entry (index lost/reset) looks the file up on
Drive by path before uploading, preventing duplicate files on Drive.
- Excluded paths queued in pendingOps are cleared instead of sticking in
the "N pending" counter forever; exclusion logic is now shared between
event handlers and the sync engine (src/exclude.ts), so user glob
patterns apply consistently. computeDiff no longer reports
newly-excluded snapshot entries as deletions.
- Files first seen via unknown Drive changes (syncedAt=0) now go through
conflict handling instead of silently overwriting the remote copy.
- index.db is written via tmp-file + rename with recovery on load, so a
crash mid-save can no longer silently reset the index.
- Multipart upload boundary is randomized; a file containing the fixed
boundary string no longer corrupts the upload.
Auth / settings:
- authProxyUrl is actually used now (it was silently ignored) and is
editable in settings; DriveApi credentials update in place so PathIndex
never holds a stale instance; token cache is keyed by token+proxy.
- Exclude patterns are editable in settings (README advertised this but
there was no UI).
Performance / UX:
- Force Pull and pull-new-from-Drive download with a small concurrency
pool (settings.concurrency, previously dead config).
- Unknown-change resolution fetches each file's metadata once instead of
twice.
- First run with a non-empty vault shows a notice explaining that an
initial Force Push/Pull is required.
Security / hygiene:
- OAuth proxy escapes HTML in the callback pages (reflected XSS via
?error=...), and README now accurately describes that token refresh goes
through the proxy and how to self-host it.
- Removed stale compiled artifacts (src/*.js) and dead code
(listRevisions); fixed package.json metadata (MIT, author, scripts).
- Added vitest with unit tests for exclusion, snapshot diffing, and index
persistence/recovery (17 tests).
Claude-Session: https://claude.ai/code/session_011DimwV9zDr1ViCXRCZPxC9
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13: a one-time cleanup script found 3.46GB/15GB quota pinned by
keepRevisionForever=true revisions accumulated from repeated re-uploads of
unchanged binaries. Attempting to reclaim it via revisions.update
(keepForever: false) failed on 695/700 attempts with a 400 "Cannot update a
revision to false that is marked as keepForever" — Drive's API silently
does not support unpinning, contrary to its own docs. The only way to
reclaim space is revisions.delete, which is immediate and permanent.
This release prevents the backlog from re-forming:
- driveApi.ts: add deleteRevision() and pruneRevisions(driveId, keepCount),
which lists a file's revisions and deletes the oldest keepForever ones
beyond keepCount. Revisions without keepForever are left alone since
Drive already reclaims those on its own ~30-day schedule.
- syncer.ts: after each push that updates an existing file, prune its
revisions in the background (failure doesn't fail the sync).
- mime.ts: add isBinaryMime() — attachments (images/PDFs/office docs/etc.)
are usually replaced wholesale rather than edited, so they default to a
much lower keep count than notes.
- New settings: "Revisions to keep — notes" (default 5) and "Revisions to
keep — attachments" (default 1), both only shown when Keep revisions is
on. Existing installs pick up these defaults automatically via the
settings merge in loadSettings.
- runSync: log per-file errors and fatal aborts to .neogdsync/sync-errors.log
(bounded ~500 lines, excluded from sync); raise a persistent Notice when a
sync fails wholesale (>=10 errors, 0 succeeded) — 2026-07-12 incident: the
scheduled 5am sync errored on all 714 ops and the 4s notice was never seen.
- auth: send browser User-Agent to the token proxy (Cloudflare banned the
default UA on 2026-06-30) — was live in data but uncommitted.
- driveApi: cast Uint8Array.buffer for newer TS lib (ArrayBufferLike).
- tsconfig: ignoreDeprecations 6.0 (baseUrl deprecation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Worker: add GET /authorize (redirect to Google consent) and GET /callback
(exchange auth code → show refresh_token to user)
- Plugin settings: add "Connect Google Drive" button + connected/disconnect UI
- types.ts: update default authProxyUrl to neogdsync-oauth.neogdsync.workers.dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
handleRename registered TFolder moves as 'create' ops, but handlePush
early-returns on non-TFile paths without clearing the op — causing any
renamed/moved folder (e.g. Runestones/Context-Hooks) to appear as
"pending create" permanently and survive force-push.
Fix: handleRename skips pendingOps entirely for folders (they're
created on-demand by resolveParentFolder). Defense-in-depth: handlePush
now clears any stranded folder/missing-file op by adding to result.pushed.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When a new folder and files inside it are created on device A and synced
to Drive, device B's smart sync silently skipped all files in that folder.
Root cause: the unknownChanges loop in smartSync() skipped FOLDER_MIME
entries before files were processed, so when a file's parent folder was
also new (not yet in the local index), folderIdToPath.get(parentId)
returned undefined and the file was dropped with a console.warn.
Fix: split the loop into two passes — Pass 1 resolves new folders and
registers them in folderIdToPath, Pass 2 resolves files using the now-
complete folder map. This ensures files in newly-created subfolders
(e.g. 2026/Attachments/Nala Project/nala.pdf) are downloaded correctly
on all other devices without requiring a manual Rebuild Index.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Cross-folder rename: handlePush now calls drive.moveFile when op='create'
and a cached driveId exists (index.rename already ran). Previously,
updateFile only updated content — the Drive file stayed in the old parent
folder and was deleted when that folder was trashed.
New DriveApi.moveFile uses addParents/removeParents to atomically relocate
the file on Drive.
2. handleDelete no longer swallows Drive API errors silently. Failures are
now surfaced in result.errors and the index entry is preserved so the
next sync can retry, instead of orphaning the Drive file forever.
3. getChanges fields now include 'size' and confirm 'trashed' is requested
(aligns compiled JS with existing TS source).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace inline style assignments on folderInput with
.neogdsync-folder-input CSS class in styles.css.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
smartSync was checking only c.removed (permanent delete) to trigger
local deletion, but Drive changes API returns removed=false with
file.trashed=true for soft-trashed files. Other devices never received
the deletion signal when a folder/file was trashed.
Fix: treat c.file.trashed===true as removed in driveChanged map.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix: manifest description now lowercase (smart/push/pull) to match
community-plugins.json and satisfy ObsidianReviewBot check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add optional folder filter to Force Push and Force Pull so large vaults
can sync a single subtree without crawling or downloading everything.
- SyncModal: folder path input (vault-relative, e.g. 2026/2026-04)
- Syncer.forcePush(folder?): runs computeDiff first then filters pendingOps to prefix
- Syncer.forcePull(folder?): partial Drive index rebuild + scoped download
- PathIndex.rebuildFolder(folder): re-crawls one Drive subtree, removes stale
entries under that prefix, leaves rest of index intact
- PathIndex.lookupFolderOnDrive: read-only Drive folder navigation (no creation)
- normFolder / matchesFolder helpers guard prefix collisions and slash variants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. deleteFile now PATCHes trashed=true instead of HTTP DELETE.
The previous DELETE was a permanent removal that bypassed Drive trash, so any
accidental local deletion (or buggy delete propagation) became unrecoverable.
Switching to trashed=true gives users the standard ~30-day recovery window.
2. unknownChanges in smartSync now skips changes whose file is currently trashed.
When a file is trashed on Drive, the changes batch typically contains both an
earlier "modified" event (removed=false) and the later trashed event. If the
fileId was no longer in the local pathIndex (e.g. just rebuilt or a sibling
device cleaned up), the modified event walked the unknownChanges path, fetched
metadata, and re-pulled the file locally — silently undoing the trash. The fix
skips such changes when file.trashed is set on the change event, and double-
checks meta.trashed after the live metadata fetch as defence-in-depth.
DriveFileInfo and DriveChange.file gain optional `trashed?: boolean`, and the
getFileMeta + getChanges fields lists request the trashed property explicitly.
Bumps version to 0.1.19.
Previously, pullNewFromDrive used `if (this.pendingOps[path]) continue` to skip
already-handled paths. If handlePush/handleConflict returned early (missing local
file or index entry) without adding the path to result.pushed/pulled, the stale
pendingOp was never cleared and the file was permanently blocked from downloading
on the receiving device.
Fix: build a `handled` Set from result.pushed+pulled+deleted after the main
pendingOps loop, and use `handled.has(path)` instead. Only successfully processed
paths are skipped; stale pendingOps fall through to the normal pull.
Bumps version to 0.1.18.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 (smartSync — unknown fileId silently dropped):
Drive Changes whose fileId is not in the local index were silently
discarded. This causes files created/modified on another device to
never appear on a stale-index device (e.g. after reinstall). Fix:
resolve unknown fileIds via getFileMeta + parent folder reverse-lookup,
pre-populate the index entry, and let pullNewFromDrive download them.
Logs a warn if the parent folder itself is also missing from the index.
Bug 2 (handleConflict — reversed push direction):
On conflict, local offline edits were pushed to Drive, overwriting the
newer remote version. Fix: keep Drive version as canonical (pull it
locally), save local offline edits as .conflict copy for manual merge.
No push to Drive; index updated to reflect pulled Drive mtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Allow users to dismiss resolved or deleted conflict records without
manually editing data.json. ConflictModal now passes plugin reference
(not snapshot array) so the button can mutate live state and persist
via saveSettings().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- setDesc 'Google OAuth2 refresh token' → 'Refresh token for Google Drive'
- setPlaceholder '1xGNFQGB…' → 'Root folder ID'
The linter (enforceCamelCaseLower mode) flags 'OAuth2' as camelCase and
'1xGNFQGB' because the leading digit '1' matches \p{Emoji} making the
first alpha ('x') get sentence-cased to 'X'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 'Vault root folder ID' and 'Google Drive folder ID' → keep uppercase ID
- Add eslint-plugin-obsidianmd config for local pre-commit checks
- All obsidianmd lint rules now pass: 0 errors on src/*.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required fixes from ObsidianReviewBot second scan:
- Sentence case: lowercase standalone 'Drive' in all UI text (commands, settings, notices)
- Remove plugin name (NeoGDSync) from all Notice messages and modal headings
- Remove setName('NeoGDSync') from settings heading (.setHeading() only)
- Sentence case: lowercase 'ID' → 'id' in setting names/descs
- Sentence case: remove emoji prefix from modal button text
- Regex: replace \x00 control character placeholder in matchGlob with
character-by-character parser (no control characters in regex literals)
- Bump version to 0.1.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: vault.modifyBinary() fires modify events asynchronously, AFTER
this.syncing is set to false in the finally block. By then handleModify()
sees syncing=false, and the snapshot still has the old mtime (vault.getFiles()
returns stale TFile stats until events are processed).
Fix: delay clearing this.syncing by 600ms. During this window, vault events
from writeLocal are still suppressed. After the delay, re-save snapshot
with fresh TFile stats (now updated by Obsidian's event processing), then
clear the flag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
writeLocal()/modifyBinary() fires Obsidian vault modify/create events
asynchronously. These events fire AFTER the pendingOps cleanup, so
result.pulled files get re-added to pendingOps on the next tick.
Fix: skip handleModify/handleCreate entirely while this.syncing is true.
Events during sync are caused by the sync itself (writeLocal), not by
real user edits. Real offline edits are caught by mergeOfflineDiff on
next startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
writeLocal() calls vault.modifyBinary() which fires Obsidian vault modify
events. handleModify() sees the file as changed (snapshot not yet updated
at event time) and re-adds it to pendingOps. Fix: clear result.pulled from
pendingOps at the end of smartSync, forcePush, and forcePull — same as
result.pushed/deleted are already cleared.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root causes found and fixed:
1. mergeOfflineDiff() was called before onLayoutReady — vault.getFiles()
returns stale/zero stats before layout ready, causing all 6000+ files
to be flagged as modified on every startup. Moved to onLayoutReady.
2. onunload() called saveSettings() before snapshot.save(), so the fresh
snapshot was never persisted to data.json. Fixed order.
3. Snapshot contained 261 .obsidian entries (from earlier versions).
vault.getFiles() never returns .obsidian files, so all 261 were
flagged as 'delete' on every restart. Fixed: added .obsidian to
exclude(), purge in setRaw(), cleaned existing data.json.
4. Snapshot now stored in data.json (loadData/saveData) for reliability.
Removed dependency on .neogdsync/snapshot.json file write.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- onunload() now saves snapshot so restarts don't lose vault state
- mergeOfflineDiff skips flood when snapshot is empty (first run / missing file)
instead saves current vault as baseline — prevents 6000+ fake pendingOps
- snapshot.getAll() helper method added
- These 3 fixes together prevent the recurring 'all files re-queued' problem
- snapshot.save() now creates .neogdsync/ dir via adapter.mkdir()
- ensureDir switched from vault.createFolder to adapter.mkdir (works for hidden dirs)
- Conflict detection now compares driveChange.mtime vs index.driveMtime
instead of relying on changesToken presence (avoids false conflicts from stale tokens)
- forcePush initializes changesToken after first push
- smartSync initializes changesToken if missing before fetching changes