Skip to content
Open
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
8 changes: 7 additions & 1 deletion src/formats/keep-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/formats/keep/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,4 +41,5 @@ export interface KeepJson {
color?: string;
labels?: KeepLabel[];
sharees?: KeepSharee[];
annotations?: KeepAnnotation[];
}
58 changes: 58 additions & 0 deletions src/formats/keep/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { KeepAnnotation } from './models';

let potentialTagsRe = /(#[^ ^#]*)/g; // Finds any non-whitespace sections starting with #
let illegalTagCharsRe = /[\\:*?<>\"|!@#$%^&()+=\`\'~;,.]/g;

Expand Down Expand Up @@ -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, '%3C').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')}`;
}
64 changes: 64 additions & 0 deletions tests/keep/annotations.test.ts
Original file line number Diff line number Diff line change
@@ -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](<https://www.icann.org/compliance/complaint>)',
'- [Cloudflare - The Web Performance & Security Company](<https://www.cloudflare.com/>)',
' 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',
'',
'- <https://example.com/path(with-parens)>',
'- 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\\]](<https://example.com/docs>)',
].join('\n')
);
});
20 changes: 20 additions & 0 deletions tests/keep/keep-note-with-annotations.json
Original file line number Diff line number Diff line change
@@ -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/"
}
]
}