From 2534b9c3972c8ed4df07cea668d1485c3d783f02 Mon Sep 17 00:00:00 2001 From: Kirill Shirinkin Date: Tue, 8 Sep 2026 12:38:57 +0200 Subject: [PATCH] Remove sync footer links from published notes --- CHANGELOG.md | 6 ++++ PRIVACY.md | 2 +- README.md | 8 +++-- docs/architecture.md | 8 +++-- docs/releasing.md | 4 +-- manifest.json | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/basecamp.ts | 2 -- src/main.ts | 5 +-- src/render.ts | 11 ++----- src/sync.ts | 16 ++-------- tests/core.test.ts | 76 ++++++++++++++++++++++++++++++++------------ versions.json | 2 +- 14 files changed, 87 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec587a..4389327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/PRIVACY.md b/PRIVACY.md index 5fe6036..fd37bf5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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. diff --git a/README.md b/README.md index 47a4f5b..148784f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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). @@ -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. @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 2413771..ce3ed63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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. diff --git a/docs/releasing.md b/docs/releasing.md index 735b100..58d1094 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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. diff --git a/manifest.json b/manifest.json index a1384cf..3a6a52d 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 4e1c4ae..6aecf21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "basecamp-sync", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "basecamp-sync", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "@37signals/basecamp": "0.16.0", diff --git a/package.json b/package.json index 7bb3118..c4303e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/basecamp.ts b/src/basecamp.ts index 369cd7a..6abad3e 100644 --- a/src/basecamp.ts +++ b/src/basecamp.ts @@ -13,7 +13,6 @@ export interface RemoteFolder { id: number; title: string } export interface Gateway { validateDestination(project: number, vault: number): Promise; getDocument(id: number): Promise; - listDocuments(vault: number): Promise; createDocument(vault: number, title: string, html: string): Promise; updateDocument(id: number, title: string, html: string): Promise; listFolders(vault: number): Promise; @@ -41,7 +40,6 @@ export function gateway(account: string, accessToken: () => Promise): 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, }), diff --git a/src/main.ts b/src/main.ts index b52ca1d..1d2df2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -143,10 +143,7 @@ export default class BasecampSyncPlugin extends Plugin { async render(note: Note, upload: boolean): Promise { 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('//')) diff --git a/src/render.ts b/src/render.ts index ab273ba..d5ae360 100644 --- a/src/render.ts +++ b/src/render.ts @@ -11,8 +11,6 @@ export interface LinkTarget { } export interface RenderOptions { resolve: (target: string, embed: boolean) => Promise; - sourceUrl: string; - recoveryUrl?: string; } export interface Rendered { html: string; @@ -129,11 +127,8 @@ export async function renderNote(markdown: string, options: RenderOptions): Prom : `
${headers.join(' · ')}
\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 += ``; - // 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 += ``; + 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] }; } diff --git a/src/sync.ts b/src/sync.ts index 39679ae..115dac6 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -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])); diff --git a/tests/core.test.ts b/tests/core.test.ts index 7750957..453f294 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -6,8 +6,7 @@ import { renderNote } from '../src/render'; import { remoteHash, SyncEngine } from '../src/sync'; const settings = { ...DEFAULT_SETTINGS, accountId: '1', projectId: '2', vaultId: '3', includes: ['Work'] }; -const render = (note: Note) => renderNote(note.markdown, { sourceUrl: `obsidian://open?file=${note.path}`, - recoveryUrl: note.binding ? `https://3.basecamp.com/1/buckets/2/vaults/3?basecamp-sync-id=${note.binding.id}` : undefined, +const render = (note: Note) => renderNote(note.markdown, { resolve: async () => ({ url: 'https://example.com/note' }) }); function setup() { @@ -16,7 +15,6 @@ function setup() { const api: Gateway = { validateDestination: vi.fn(async () => {}), getDocument: vi.fn(async id => { if (!documents.has(id)) throw new Error('Not found'); return documents.get(id)!; }), - listDocuments: vi.fn(async () => [...documents.values()]), createDocument: vi.fn(async (_vault, title, content) => { const document = { id: 10, title, content, bucket: { id: 2 }, status: 'active' }; documents.set(10, document); return document; @@ -33,6 +31,20 @@ function setup() { return { note, api, save, documents, engine: new SyncEngine(host, api, settings), host }; } +async function legacySetup() { + const state = setup(); + state.note.markdown = 'Hello'; + const document = { id: 10, title: 'Plan', bucket: { id: 2 }, status: 'active', + content: '
Hello
\n' + + '' }; + state.documents.set(10, document); + state.note.binding = { id: 'legacy-note-identity', account: '1', project: 2, root: 3, vault: 4, document: 10, + // Fingerprint produced by 0.1.4 for this note in the vault "Test". + sourceHash: 'c9bf04d2ecb385d3443e737fc56d7800d24c91dbe3836a0cc42ed52c41181472', + remoteHash: await remoteHash(document), pending: false }; + return state; +} + describe('selection and metadata', () => { it('selects nothing by default and respects folder boundaries', () => { expect(selection([], [])('Work/Plan.md')).toBe(false); @@ -76,7 +88,7 @@ describe('selection and metadata', () => { describe('formatted content', () => { it('uses only supported formatting and keeps table links', async () => { const rendered = await renderNote('## Heading\n\n**Bold** *italic* ~~strike~~ `code`\n\n- [x] Done\n\n```ts\n\n```\n\n| Name | Link |\n|---|---|\n| Alice | [Page](https://example.com) |', - { sourceUrl: '', resolve: async () => ({}) }); + { resolve: async () => ({}) }); expect(rendered.html).toContain('

Heading

'); expect(rendered.html).toContain('strike'); expect(rendered.html).toContain('☑ Done'); @@ -87,7 +99,6 @@ describe('formatted content', () => { }); it('escapes HTML, unsafe links and hostile attachment labels', async () => { const result = await renderNote('\n\n[[attack|]]\n\n![[pic.png|" onload="bad]]', { - sourceUrl: 'javascript:alert(1)', resolve: async target => target === 'attack' ? { url: 'javascript:alert(1)' } : { sgid: 'safe"bad' }, }); expect(result.html).not.toMatch(/ { const resolver = vi.fn(async (target: string, embed: boolean) => ({ url: 'https://example.com/' + target, sgid: embed && target.endsWith('.png') ? 'sgid' : undefined, })); - const result = await renderNote('[[Plan|Next]] and ![[image.png]] and `[[literal]]`', { sourceUrl: '', resolve: resolver }); + const result = await renderNote('[[Plan|Next]] and ![[image.png]] and `[[literal]]`', { resolve: resolver }); expect(result.html).toContain('>Next'); expect(result.html).toContain(' { - const result = await renderNote('---\nsecret: value\n---\nVisible %%private%% text', { sourceUrl: '', resolve: async () => ({}) }); + const result = await renderNote('---\nsecret: value\n---\nVisible %%private%% text', { resolve: async () => ({}) }); expect(result.html).not.toMatch(/secret|private/); expect(result.html).toContain('Visible'); }); it('omits multiline comments while preserving literal comments in code', async () => { - const result = await renderNote('Visible\n\n%%\nprivate\n\nsecret\n%%\n\n`%%literal%%`\n\n```\n%%code%%\n```', { sourceUrl: '', resolve: async () => ({}) }); + const result = await renderNote('Visible\n\n%%\nprivate\n\nsecret\n%%\n\n`%%literal%%`\n\n```\n%%code%%\n```', { resolve: async () => ({}) }); expect(result.html).not.toMatch(/private|secret/); expect(result.html).toContain('%%literal%%'); expect(result.html).toContain('%%code%%'); }); - it('retains a recovery marker in an HTTPS link if custom schemes are stripped', async () => { - const result = await renderNote('Hello', { sourceUrl: 'obsidian://open?file=Hello', - recoveryUrl: 'https://3.basecamp.com/1/buckets/2/vaults/3?basecamp-sync-id=unique-marker', resolve: async () => ({}) }); - expect(result.html.replace(/href="obsidian:[^"]*"/g, '')).toContain('basecamp-sync-id=unique-marker'); + it('publishes only note content without a footer or hidden sync marker', async () => { + const result = await renderNote('Hello', { resolve: async () => ({}) }); + expect(result.html).toBe('
Hello
\n'); }); - it('keeps the content fingerprint stable when adding the recovery marker', async () => { - const a = await renderNote('Hello', { sourceUrl: 'obsidian://open?file=x', resolve: async () => ({}) }); - const b = await renderNote('Hello', { sourceUrl: 'obsidian://open?file=x', recoveryUrl: 'https://example.com/?id=y', resolve: async () => ({}) }); - expect(a.hash).toBe(b.hash); + it('preserves source links intentionally written in the note', async () => { + const result = await renderNote('[[Plan|Open in Obsidian]]', { + resolve: async () => ({ url: 'obsidian://open?vault=Test&file=Plan.md' }), + }); + expect(result.html).toBe('\n'); }); it('renders a callout label without raw Obsidian markers', async () => { - const result = await renderNote('> [!NOTE]\n> Useful context.', { sourceUrl: '', resolve: async () => ({}) }); + const result = await renderNote('> [!NOTE]\n> Useful context.', { resolve: async () => ({}) }); expect(result.html).toContain('Note'); expect(result.html).not.toContain('[!NOTE]'); }); }); describe('sync engine', () => { + it('removes the old footer from an unchanged note on its next sync, then returns to no-op updates', async () => { + const { engine, api, note, documents } = await legacySetup(); + expect((await engine.run([note.path], [note]))[0]?.status).toBe('updated'); + expect(api.createDocument).not.toHaveBeenCalled(); + expect(api.updateDocument).toHaveBeenCalledWith(10, 'Plan', '
Hello
\n'); + expect(documents.get(10)?.content).toBe('
Hello
\n'); + expect(note.markdown).toBe('Hello'); + expect(note.binding?.document).toBe(10); + vi.mocked(api.getDocument).mockClear(); + expect((await engine.run([note.path], [note]))[0]?.status).toBe('unchanged'); + expect(api.getDocument).not.toHaveBeenCalled(); + expect(api.updateDocument).toHaveBeenCalledTimes(1); + }); + it('protects remote edits during the old-footer cleanup', async () => { + const { engine, api, note, documents } = await legacySetup(); + documents.set(10, { ...documents.get(10)!, content: 'Edited in Basecamp' }); + expect((await engine.run([note.path], [note]))[0]?.detail).toContain('changed since'); + expect(api.updateDocument).not.toHaveBeenCalled(); + expect(api.createDocument).not.toHaveBeenCalled(); + }); it('recreates only the folders below the source folder', async () => { const { api, note, host } = setup(); note.path = 'Projects/Writing/Basecamp/Standalone Content/External Highlights/KARS/Note.md'; @@ -233,22 +264,27 @@ describe('sync engine', () => { documents.set(10, { ...documents.get(10)!, status: 'archived' }); note.markdown = 'Edit'; expect((await engine.run([note.path], [note]))[0]?.detail).toContain('archived'); }); - it('recovers a create accepted by Basecamp before the response was lost', async () => { + it('stops after a lost create response and resumes only after the user links the existing document', async () => { const { engine, api, note, documents } = setup(); const create = api.createDocument; vi.mocked(api.createDocument).mockImplementationOnce(async (...args) => { const document: RemoteDocument = { id: 10, title: args[1], content: args[2], bucket: { id: 2 }, status: 'active' }; documents.set(10, document); - vi.mocked(api.listDocuments).mockResolvedValue([document]); throw new Error('Disconnected'); }); expect((await engine.run([note.path], []))[0]?.status).toBe('error'); expect(note.binding?.pending).toBe(true); + expect(documents.get(10)?.content).not.toMatch(/Open in Obsidian|Basecamp folder|basecamp-sync-id/); + expect((await engine.run([note.path], [note]))[0]?.detail).toContain('unknown outcome'); + expect(create).toHaveBeenCalledTimes(1); + expect(api.updateDocument).not.toHaveBeenCalled(); + expect(api.getDocument).not.toHaveBeenCalled(); + note.binding = { ...note.binding!, document: 10, remoteHash: await remoteHash(documents.get(10)!), pending: false }; expect((await engine.run([note.path], [note]))[0]?.status).toBe('updated'); expect(create).toHaveBeenCalledTimes(1); expect(note.binding?.document).toBe(10); }); - it('does not retry an ambiguous create when no matching document is found', async () => { + it('does not retry an ambiguous create without a confirmed document ID', async () => { const { engine, api, note } = setup(); vi.mocked(api.createDocument).mockRejectedValue(new Error('Offline')); await engine.run([note.path], []); diff --git a/versions.json b/versions.json index 6622bd8..f6b528f 100644 --- a/versions.json +++ b/versions.json @@ -1 +1 @@ -{"0.1.0":"1.11.4","0.1.1":"1.11.4","0.1.2":"1.11.4","0.1.3":"1.11.4","0.1.4":"1.11.4"} +{"0.1.0":"1.11.4","0.1.1":"1.11.4","0.1.2":"1.11.4","0.1.3":"1.11.4","0.1.4":"1.11.4","0.1.5":"1.11.4"}