Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.1.5

- Remove the automatic Open in Obsidian and Basecamp folder footer links entirely, without adding hidden sync markers.
- Remove old footers on the next sync of selected linked notes, even when the note text is unchanged, while retaining remote-edit conflict checks.
- Pause uncertain first uploads until the user reconciles and links the existing document instead of searching for a marker in published content.

## 0.1.4

- Explain the difference between Source folder and Include directly in settings, with an Obsidian-to-Basecamp folder mapping example.
Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy

Basecamp Sync sends selected note titles and rendered text, and optionally embedded local files, to the Basecamp account and project chosen by the user. Note paths and vault names are included in source links. Readers with access to those Basecamp documents can see the published content and links.
Basecamp Sync sends selected note titles and rendered text, and optionally embedded local files, to the Basecamp account and project chosen by the user. It adds no source backlink, footer or hidden sync marker. Links written in a note may resolve to Obsidian URLs containing vault names and note paths. Readers with access to those Basecamp documents can see the published content and links.

The plugin does not collect telemetry, contact analytics services, use AI services or execute embedded third-party note renderers. It makes no network request on load. Automatic publication is opt-in on each device and only runs for configured selections while Obsidian is open.

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Built by [mkdev](https://mkdev.me). MIT licensed. Uses the [official Basecamp Ty
- Sync manually, or after a configurable delay following edits on a particular device.
- Preserve headings, emphasis, lists, checkboxes, quotes and code blocks. Convert tables to readable labeled rows.
- Upload local embedded images and files, and connect links between synced notes.
- Check for edits in Basecamp before replacing a document. Recover interrupted creates without blindly posting another copy.
- Check for edits in Basecamp before replacing a document. Pause after an uncertain first upload to prevent duplicate documents.

The plugin is designed for desktop, iOS and Android, using Obsidian APIs and browser APIs only. Obsidian **1.11.4 or later** is required for Secret storage. There is no background process, periodic polling, framework, telemetry or AI dependency. The production bundle is approximately **364 KiB** uncompressed, including third-party license notices.

Expand All @@ -31,7 +31,7 @@ The plugin is designed for desktop, iOS and Android, using Obsidian APIs and bro
5. Select **Latest version**, enable **Enable after installing the plugin**, then add the plugin. Select a specific version instead if you want to pin it.
6. Open **Settings → Basecamp Sync** and connect to Basecamp.

BRAT installs the files and can check for future releases. The same process works on desktop and mobile, without building the plugin or copying files manually. Version `0.1.4` is a development pre-release; BRAT includes pre-releases when tracking the latest version.
BRAT installs the files and can check for future releases. The same process works on desktop and mobile, without building the plugin or copying files manually. Version `0.1.5` is a development pre-release; BRAT includes pre-releases when tracking the latest version.

**Private repository access:** your GitHub account must have access to this repository. Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) with **Resource owner: mkdev-me**, **Only selected repositories: obsidian-basecamp-sync**, and **Contents: Read-only**. Complete organization approval if GitHub requires it. In BRAT's add-plugin dialog, use **GitHub token** to add/select the token through Obsidian's Secret storage. Set it up on each device; secrets are device-local. This token is for downloading plugin releases; Basecamp login is configured separately. Once the repository is public, a GitHub token is optional. See [BRAT's private repository guide](https://github.com/TfTHacker/obsidian42-brat/blob/main/BRAT-DEVELOPER-GUIDE.md#access-to-private-repositories).

Expand Down Expand Up @@ -105,6 +105,8 @@ New documents are published as active documents visible to project members, with

The plugin writes a small `basecamp_sync` object to each published note's properties. It stores the destination, document identity and sync fingerprints. Keep those properties with the note when syncing your vault between devices. They contain no login credentials and are omitted from the published body.

Published documents contain the rendered note content, without an added source link, folder link or hidden sync marker. After upgrading from a version that added a footer, the next sync removes it from selected linked documents, including unchanged notes. Existing conflict checks still protect edits made in Basecamp.

- Edit a note and run **Sync current note** or **Sync selected notes**. Unchanged notes make no network requests.
- Renaming or moving a linked note updates the same document. It does not relocate Basecamp folders or documents.
- To take over an existing page, run **Link current note to existing document** and supply its document URL or ID. The next sync replaces its title and content with the note.
Expand Down Expand Up @@ -142,7 +144,7 @@ The local formatting preview never uploads anything. Final rendering in Basecamp
- **One destination per vault configuration.** Select as many note sets as you need within it. Use another vault for an independent destination.
- **One active writer at a time.** Basecamp's document API has no atomic conditional update or create idempotency key. Checks reduce accidental overwrites, but cannot prevent two simultaneous clients from racing. Enable automatic sync on one device and wait for vault synchronization before switching devices.
- **Foreground operation.** iOS and Android may suspend Obsidian. Sync runs while the app is open. Credentials and pending login state survive restarts, but an interrupted operation may require a manual retry.
- **Unknown create outcomes.** A recovery marker is saved before creating a document. After a lost response, the plugin searches for that marker and links the matching page. If the outcome cannot be established, it stops. Inspect Basecamp and link the existing page, or remove the note's sync properties only after confirming that no page was created.
- **Unknown create outcomes.** A pending flag is saved in the local note's properties before creating a document. If the response is lost before its document ID is saved, sync stops rather than retrying the upload or guessing which document was created. Inspect Basecamp and use **Link current note to existing document**, or remove the note's sync properties only after confirming that no page was created.
- **Attachments.** Per-document attachment references are cached in plugin data, content hashes avoid re-uploading unchanged files on the same device, and the HTTP cache is bounded and kept in memory. Another device may upload an attachment again if that cache did not sync.
- **API support.** This uses the current Basecamp API, not Basecamp Classic or Basecamp 2.

Expand Down
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ The runtime consists of a small Obsidian shell around a testable one-way sync en

| Location | Contents |
| --- | --- |
| Note properties, `basecamp_sync` | Stable identity, account/project/root/vault/document IDs, generated document URL, content fingerprints and pending-create marker |
| Note properties, `basecamp_sync` | Stable identity, account/project/root/vault/document IDs, generated document URL, content fingerprints and pending-create flag |
| Plugin `data.json` | Selection and destination settings, names of user-managed secret entries, and per-document attachment hashes/references |
| Obsidian Secret storage | OAuth session, pending login, selected personal client secret or access token |
| Obsidian device-local storage | Whether automatic sync is enabled on this device |
| Memory only | Bounded HTTP body cache (64 entries, 8 MB total, 1 MB per entry), attachment digest cache and current queue |

Note properties are the cross-device source of document identity. They must arrive on another device before that device publishes the note. All note properties are excluded from the published body. Obsidian links include vault/note names and the recovery identity, so Basecamp project readers can see those names.
Note properties are the cross-device source of document identity. They must arrive on another device before that device publishes the note. All note properties are excluded from the published body. The renderer adds no footer, backlink or hidden sync marker. Links written in the note may resolve to Obsidian URLs containing vault names and note paths.

The content fingerprint includes rendered HTML and link/attachment dependencies. Version 0.1.5 changes its format so the next sync refreshes unchanged notes that were published with the old footer. Cleanup uses the normal document update and conflict checks.

## Mobile adaptation of SDK 0.16.0

Expand All @@ -42,4 +44,4 @@ Obsidian's native HTTP API cannot cancel an in-flight native request. Cancellati

The Basecamp document API has no atomic compare-and-swap. The engine checks canonical title/content fingerprints both before preparation and immediately before replacing a changed document, but another writer can still race the final request. Unchanged local content does not fetch or overwrite the remote document. Create requests are never blindly retried after ambiguous failures. Known definitive rejections clear the pending-create flag so a later manual retry is possible.

Remote identity is never inferred from a title. Interrupted creates are recovered only by an exact per-note marker; duplicate identities anywhere in the vault block publication. Existing document moves and remote deletions are deliberately not mirrored. Bulk cross-device coordination, two-way merging and server-hosted sync are outside this release.
Remote identity is never inferred from a title or content. When a create has an unknown outcome and no confirmed document ID, sync pauses until the user reconciles it in Basecamp and explicitly links the document. Duplicate identities anywhere in the vault block publication. Existing document moves and remote deletions are deliberately not mirrored. Bulk cross-device coordination, two-way merging and server-hosted sync are outside this release.
4 changes: 2 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ Use a dedicated test vault and a disposable Basecamp project. The plugin writes
2. **Authenticate.** Exercise the shared integration, a personal integration, cancelled consent, wrong/expired state, browser return, manual callback fallback and app suspension/restart during login.
3. **Refresh.** Test with an expired access token in an isolated development profile. Ensure the existing refresh token is retained if Basecamp omits a replacement. Verify reconnect after access is revoked.
4. **Selection and folder mapping.** Confirm an empty include list publishes nothing. Test an exact file, a recursive folder, nested globs, exclusions, an opted-out note and a current note outside the selection. Set Source folder to a nested vault folder and confirm only its descendants are selected, the preview shows relative Basecamp paths, and no ancestor folders are created. Check existing matching Basecamp folders are reused. Changing Source folder must keep linked documents in place.
5. **Create and update.** Use headings, paragraphs, Unicode, bold/italic/strike, task lists, nested lists, code, callouts, tables with links, wiki links, local embedded images and a PDF. Compare the local preview with the Basecamp page. Record any sanitizer differences, especially custom Obsidian URI links.
5. **Create and update.** Use headings, paragraphs, Unicode, bold/italic/strike, task lists, nested lists, code, callouts, tables with links, wiki links, local embedded images and a PDF. Compare the local preview with the Basecamp page. Confirm no source/footer links or hidden sync markers are added. Upgrade an unchanged note from 0.1.4 and confirm its next sync removes the old footer while keeping its document ID; repeat with a remote edit and confirm cleanup stops for the conflict. Record any sanitizer differences, especially custom Obsidian URI links written in the note.
6. **No-op.** Sync again without changes. Confirm no HTTP calls, no duplicate documents and no extra attachment uploads.
7. **Existing pages.** Link a pre-existing test document, sync it and confirm both title and body are replaced while the document identity, comments, visibility and subscriptions remain intact.
8. **Conflicts.** Edit the Basecamp document, then edit the note. Confirm sync stops. Reconcile the changes, explicitly relink and retry.
9. **Recovery.** Interrupt a create after the request is accepted, then retry. Confirm the HTTPS folder-link marker finds the existing page. Also test a pre-create attachment failure, a definitive 429 response, a lost update response and an ambiguous create with no matching result.
9. **Recovery.** Interrupt a create after the request is accepted, then retry. Confirm sync stops with an unknown-outcome message and does not create a duplicate. Explicitly link the existing document and confirm sync resumes. Also test a pre-create attachment failure, a definitive 429 response, a lost update response and an ambiguous create where no document exists.
10. **Cross-device continuation.** Finish a desktop sync; let note properties reach iOS through the user's chosen vault sync method; connect on iOS and edit the note. Confirm the same Basecamp document is updated. Repeat in the other direction, with one writer at a time.
11. **Moves, copies and deletion.** Rename/move a note and verify the document stays linked. Copy it with its sync properties and verify the duplicate is blocked. Delete/exclude the original and confirm nothing is deleted in Basecamp.
12. **Rate limits, offline mode and suspension.** Verify errors identify the affected notes, queued work stops on unload, and retry does not blindly duplicate an uncertain create. There is no mobile background-sync guarantee.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "basecamp-sync",
"name": "Basecamp Sync",
"version": "0.1.4",
"version": "0.1.5",
"minAppVersion": "1.11.4",
"description": "Sync selected notes and folders to formatted Basecamp documents.",
"author": "mkdev",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "basecamp-sync",
"version": "0.1.4",
"version": "0.1.5",
"description": "Sync selected Obsidian notes to Basecamp documents.",
"private": true,
"type": "module",
Expand Down
2 changes: 0 additions & 2 deletions src/basecamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export interface RemoteFolder { id: number; title: string }
export interface Gateway {
validateDestination(project: number, vault: number): Promise<void>;
getDocument(id: number): Promise<RemoteDocument>;
listDocuments(vault: number): Promise<RemoteDocument[]>;
createDocument(vault: number, title: string, html: string): Promise<RemoteDocument>;
updateDocument(id: number, title: string, html: string): Promise<RemoteDocument>;
listFolders(vault: number): Promise<RemoteFolder[]>;
Expand Down Expand Up @@ -41,7 +40,6 @@ export function gateway(account: string, accessToken: () => Promise<string>): Ga
throw new Error('Choose an active project and a destination inside that project.');
},
getDocument: id => read.documents.get(id),
listDocuments: async vault => Array.from(await read.documents.list(vault)),
createDocument: (vault, title, content) => write.documents.create(vault, {
title, content, status: 'active', subscriptions: [], visibleToClients: false,
}),
Expand Down
5 changes: 1 addition & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,7 @@ export default class BasecampSyncPlugin extends Plugin {

async render(note: Note, upload: boolean): Promise<Rendered> {
const settings = this.activeSettings || this.settings;
const sourceUrl = noteUri(this.app.vault.getName(), note.path);
const binding = note.binding;
const recoveryUrl = binding ? `https://3.basecamp.com/${binding.account}/buckets/${binding.project}/vaults/${binding.vault}?basecamp-sync-id=${binding.id}` : undefined;
return renderNote(note.markdown, { sourceUrl, recoveryUrl, resolve: async (raw, embed) => {
return renderNote(note.markdown, { resolve: async (raw, embed) => {
let target = raw;
try { target = decodeURIComponent(raw); } catch { /* A literal percent is a valid filename. */ }
if (/^[a-z][a-z\d+.-]*:/i.test(target) || target.startsWith('//'))
Expand Down
11 changes: 3 additions & 8 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ export interface LinkTarget {
}
export interface RenderOptions {
resolve: (target: string, embed: boolean) => Promise<LinkTarget>;
sourceUrl: string;
recoveryUrl?: string;
}
export interface Rendered {
html: string;
Expand Down Expand Up @@ -129,11 +127,8 @@ export async function renderNote(markdown: string, options: RenderOptions): Prom
: `<div>${headers.join(' · ')}</div>\n`;
tokens.splice(i + 1, end - i);
}
let html = md.renderer.render(tokens, md.options, {});
const hash = await sha256(`${html}\n${options.sourceUrl}\n${dependencies.join('\n')}`);
if (safeUrl(options.sourceUrl)) html += `<div><a href="${escape(options.sourceUrl)}">Open in Obsidian</a></div>`;
// A normal HTTPS folder link keeps the recovery marker even if Basecamp strips custom URI schemes.
if (options.recoveryUrl && /^https:\/\//.test(options.recoveryUrl) && safeUrl(options.recoveryUrl))
html += `<div><a href="${escape(options.recoveryUrl)}">Basecamp folder</a></div>`;
const html = md.renderer.render(tokens, md.options, {});
// Changing the fingerprint also refreshes unchanged notes published with the old footer.
const hash = await sha256(JSON.stringify([html, dependencies]));
return { html, hash, warnings: [...warnings] };
}
16 changes: 3 additions & 13 deletions src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,9 @@ export class SyncEngine {
binding.project !== positiveId(this.settings.projectId) || binding.root !== positiveId(this.settings.vaultId)))
throw new Error('This note is linked to a different destination. Restore that destination or explicitly unlink the note.');

// An interrupted create has a durable marker. Only adopt an exact remote match; never create twice.
if (binding?.pending && !binding.document) {
const matches = (await this.api.listDocuments(binding.vault))
.filter(document => document.content?.includes(`basecamp-sync-id=${binding!.id}`));
if (matches.length !== 1)
throw new Error('An earlier create has an unknown outcome. Inspect Basecamp and use “Link current note to existing document” before retrying.');
const remote = matches[0]!;
this.validateDocument(remote);
binding = { ...binding, document: remote.id, url: documentUrl(binding.account, binding.project, remote.id),
remoteHash: await remoteHash(remote), pending: false };
await this.host.saveBinding(note.path, binding);
note.binding = binding;
}
// Without a confirmed document ID, a retry could create a duplicate.
if (binding?.pending && !binding.document)
throw new Error('An earlier create has an unknown outcome. Inspect Basecamp and use “Link current note to existing document” before retrying.');

const draft = await this.host.render(note, false);
const sourceHash = await sha256(JSON.stringify([note.title, note.path, draft.hash]));
Expand Down
Loading
Loading