diff --git a/src/formats/keep-json.ts b/src/formats/keep-json.ts index 83ba61db..401ca1b5 100644 --- a/src/formats/keep-json.ts +++ b/src/formats/keep-json.ts @@ -5,7 +5,7 @@ import { ATTACHMENT_EXTS, ImportContext } from '../main'; import { serializeFrontMatter } from '../util'; import { readZip, ZipEntryFile } from '../zip'; import { KeepJson } from './keep/models'; -import { sanitizeTag, sanitizeTags, toSentenceCase } from './keep/util'; +import { formatAnnotations, sanitizeTag, sanitizeTags, toSentenceCase } from './keep/util'; const BUNDLE_EXTS = ['zip']; @@ -201,6 +201,12 @@ export class KeepImporter extends FormatImporter { } } + const annotations = formatAnnotations(keepJson.annotations); + if (annotations) { + mdContent.push('\n\n'); + mdContent.push(annotations); + } + const file = await this.saveAsMarkdownFile(folder, filename, mdContent.join('')); // Modifying the creation and modified timestamps without changing file contents. diff --git a/src/formats/keep/models.ts b/src/formats/keep/models.ts index 8b94ff05..c00c6d82 100644 --- a/src/formats/keep/models.ts +++ b/src/formats/keep/models.ts @@ -18,6 +18,13 @@ export interface KeepLabel { name: string; } +export interface KeepAnnotation { + description?: string; + source?: string; + title?: string; + url?: string; +} + export interface KeepJson { createdTimestampUsec: number; userEditedTimestampUsec: number; @@ -34,4 +41,5 @@ export interface KeepJson { color?: string; labels?: KeepLabel[]; sharees?: KeepSharee[]; + annotations?: KeepAnnotation[]; } diff --git a/src/formats/keep/util.ts b/src/formats/keep/util.ts index 5eb08488..d37ce1bb 100644 --- a/src/formats/keep/util.ts +++ b/src/formats/keep/util.ts @@ -1,3 +1,5 @@ +import type { KeepAnnotation } from './models'; + let potentialTagsRe = /(#[^ ^#]*)/g; // Finds any non-whitespace sections starting with # let illegalTagCharsRe = /[\\:*?<>\"|!@#$%^&()+=\`\'~;,.]/g; @@ -38,3 +40,59 @@ export function sanitizeTags(str: string): string { export function toSentenceCase(str: string) { return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); } + +function normalizeAnnotationText(value: string | undefined): string { + return (value ?? '').replace(/\s+/g, ' ').trim(); +} + +function escapeMarkdownLinkText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]'); +} + +function formatAnnotationLinkUrl(url: string): string { + return `<${url.replace(//g, '%3E')}>`; +} + +function formatAnnotationPrimaryText(annotation: KeepAnnotation): string { + const title = normalizeAnnotationText(annotation.title); + const url = normalizeAnnotationText(annotation.url); + const description = normalizeAnnotationText(annotation.description); + + if (title && url) { + return `[${escapeMarkdownLinkText(title)}](${formatAnnotationLinkUrl(url)})`; + } + if (url) { + return formatAnnotationLinkUrl(url); + } + if (title) { + return title; + } + + return description; +} + +export function formatAnnotations(annotations: KeepAnnotation[] | undefined): string { + const annotationLines: string[] = []; + + for (const annotation of annotations ?? []) { + const primaryText = formatAnnotationPrimaryText(annotation); + if (!primaryText) continue; + + annotationLines.push(`- ${primaryText}`); + + const title = normalizeAnnotationText(annotation.title); + const description = normalizeAnnotationText(annotation.description); + if (description && description !== primaryText && description !== title) { + annotationLines.push(` ${description}`); + } + } + + if (annotationLines.length === 0) { + return ''; + } + + return `## Annotations\n\n${annotationLines.join('\n')}`; +} diff --git a/tests/keep/annotations.test.ts b/tests/keep/annotations.test.ts new file mode 100644 index 00000000..e5e28a01 --- /dev/null +++ b/tests/keep/annotations.test.ts @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { KeepJson } from '../../src/formats/keep/models'; +import { formatAnnotations } from '../../src/formats/keep/util'; + +test('returns empty string when a note has no annotations', () => { + assert.equal(formatAnnotations(undefined), ''); + assert.equal(formatAnnotations([]), ''); +}); + +test('formats Keep annotations as a Markdown section', () => { + const fixturePath = join( + dirname(fileURLToPath(import.meta.url)), + 'keep-note-with-annotations.json' + ); + const note = JSON.parse(readFileSync(fixturePath, 'utf-8')) as KeepJson; + + assert.equal( + formatAnnotations(note.annotations), + [ + '## Annotations', + '', + '- [Submitting a Complaint to ICANN Contractual Compliance - ICANN]()', + '- [Cloudflare - The Web Performance & Security Company]()', + ' Here at Cloudflare, we make the Internet work the way it should. Offering CDN, DNS, DDoS protection and security, find out how we can help your site.', + ].join('\n') + ); +}); + +test('skips empty annotations and falls back to available fields', () => { + assert.equal( + formatAnnotations([ + {}, + { url: 'https://example.com/path(with-parens)' }, + { description: 'Only a description' }, + ]), + [ + '## Annotations', + '', + '- ', + '- Only a description', + ].join('\n') + ); +}); + +test('escapes brackets in annotation link titles', () => { + assert.equal( + formatAnnotations([ + { + title: 'Example [Docs]', + url: 'https://example.com/docs', + }, + ]), + [ + '## Annotations', + '', + '- [Example \\[Docs\\]]()', + ].join('\n') + ); +}); diff --git a/tests/keep/keep-note-with-annotations.json b/tests/keep/keep-note-with-annotations.json new file mode 100644 index 00000000..c7caf308 --- /dev/null +++ b/tests/keep/keep-note-with-annotations.json @@ -0,0 +1,20 @@ +{ + "createdTimestampUsec": 1717378714000000, + "userEditedTimestampUsec": 1717378894000000, + "title": "Annotated links", + "textContent": "Links I saved from Keep.", + "annotations": [ + { + "description": "", + "source": "WEBLINK", + "title": "Submitting a Complaint to ICANN Contractual Compliance - ICANN", + "url": "https://www.icann.org/compliance/complaint" + }, + { + "description": "Here at Cloudflare, we make the Internet work the way it should. Offering CDN, DNS, DDoS protection and security, find out how we can help your site.", + "source": "WEBLINK", + "title": "Cloudflare - The Web Performance & Security Company", + "url": "https://www.cloudflare.com/" + } + ] +}