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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.1.7

- Preserve visible blank lines between paragraphs in Basecamp, including long notes, blockquotes and paragraphs within list items.
- Apply the spacing fix to existing linked documents on their next sync, even when their note text is unchanged. Document identity and remote-edit conflict checks are preserved.

## 0.1.6

- Prepare the first public release with account, network and hosted-service disclosures.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Copying a note also copies its identity. Remove `basecamp_sync` from the **copy*

| Obsidian content | Basecamp result |
| --- | --- |
| Paragraphs and line breaks | Blank lines between paragraphs; single newlines remain line breaks |
| Headings | Basecamp heading style; heading levels flatten because its documented HTML subset only includes `h1` |
| Bold, italic, strike, lists, quotes | Native rich text |
| Task lists | Readable checked/unchecked symbols; not Basecamp to-dos |
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.6",
"version": "0.1.7",
"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.6",
"version": "0.1.7",
"description": "Sync selected Obsidian notes to Basecamp documents.",
"private": true,
"type": "module",
Expand Down
6 changes: 4 additions & 2 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export async function renderNote(markdown: string, options: RenderOptions): Prom
return true;
});
md.renderer.rules.paragraph_open = () => '<div>';
md.renderer.rules.paragraph_close = () => '</div>\n';
// Basecamp gives divs no paragraph margins, so blank lines must be explicit.
md.renderer.rules.paragraph_close = (tokens, index) =>
tokens[index + 1]?.type === 'paragraph_open' ? '</div>\n<div><br></div>\n' : '</div>\n';
md.renderer.rules.heading_open = () => '<h1>';
md.renderer.rules.heading_close = () => '</h1>\n';
md.renderer.rules.code_inline = (tokens, index) => `<strong>${escape(tokens[index]!.content)}</strong>`;
Expand Down Expand Up @@ -128,7 +130,7 @@ export async function renderNote(markdown: string, options: RenderOptions): Prom
tokens.splice(i + 1, end - i);
}
const html = md.renderer.render(tokens, md.options, {});
// Changing the fingerprint also refreshes unchanged notes published with the old footer.
// Formatting changes also refresh already-synced notes whose Markdown is unchanged.
const hash = await sha256(JSON.stringify([html, dependencies]));
return { html, hash, warnings: [...warnings] };
}
36 changes: 35 additions & 1 deletion tests/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS, type Binding, type Note, parseBinding, stripFrontmatter } from '../src/model';
import { DEFAULT_SETTINGS, type Binding, type Note, parseBinding, sha256, stripFrontmatter } from '../src/model';
import type { Gateway, RemoteDocument } from '../src/basecamp';
import { relativeNotePath, selection } from '../src/selection';
import { renderNote } from '../src/render';
Expand Down Expand Up @@ -86,6 +86,22 @@ describe('selection and metadata', () => {
});

describe('formatted content', () => {
it('keeps blank lines between long paragraphs and single breaks within a paragraph', async () => {
const paragraph = 'A long paragraph with words that wrap across many lines. '.repeat(80).trim();
const result = await renderNote(`${paragraph}\n\nSecond **paragraph**.\nSame paragraph.\n\nLast paragraph.`,
{ resolve: async () => ({}) });
expect(result.html).toBe(`<div>${paragraph}</div>\n<div><br></div>\n` +
'<div>Second <strong>paragraph</strong>.<br>\nSame paragraph.</div>\n<div><br></div>\n' +
'<div>Last paragraph.</div>\n');
});
it('spaces paragraphs inside quotes and list items without adding gaps between list items', async () => {
const result = await renderNote('> First paragraph.\n>\n> Second paragraph.\n\n' +
'- First item.\n\n Another paragraph.\n- Second item.', { resolve: async () => ({}) });
expect(result.html.match(/<div><br><\/div>/g)).toHaveLength(2);
expect(result.html).toContain('<div>First paragraph.</div>\n<div><br></div>\n<div>Second paragraph.</div>');
expect(result.html).toContain('<div>First item.</div>\n<div><br></div>\n<div>Another paragraph.</div>');
expect(result.html).toContain('</li>\n<li>\n<div>Second item.</div>\n</li>');
});
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<x>\n```\n\n| Name | Link |\n|---|---|\n| Alice | [Page](https://example.com) |',
{ resolve: async () => ({}) });
Expand Down Expand Up @@ -144,6 +160,24 @@ describe('formatted content', () => {
});

describe('sync engine', () => {
it('repairs paragraph spacing on the next sync without a note edit or a duplicate document', async () => {
const { engine, api, note, host, documents } = setup();
note.markdown = 'First paragraph.\n\nSecond paragraph.';
const currentRender = host.render;
const oldHtml = '<div>First paragraph.</div>\n<div>Second paragraph.</div>\n';
const oldHash = await sha256(JSON.stringify([oldHtml, []]));
host.render = async () => ({ html: oldHtml, hash: oldHash, warnings: [] });
await engine.run([note.path], []);

host.render = currentRender;
expect((await engine.run([note.path], [note]))[0]?.status).toBe('updated');
expect(documents.get(10)?.content).toContain('</div>\n<div><br></div>\n<div>Second paragraph.');
expect(note.markdown).toBe('First paragraph.\n\nSecond paragraph.');
expect(note.binding?.document).toBe(10);
expect((await engine.run([note.path], [note]))[0]?.status).toBe('unchanged');
expect(api.createDocument).toHaveBeenCalledTimes(1);
expect(api.updateDocument).toHaveBeenCalledTimes(1);
});
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');
Expand Down
2 changes: 1 addition & 1 deletion versions.json
Original file line number Diff line number Diff line change
@@ -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.5":"1.11.4","0.1.6":"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","0.1.6":"1.11.4","0.1.7":"1.11.4"}
Loading