From 7348d1937163f30e0a62ab2c51b1d3ac3f2e13df Mon Sep 17 00:00:00 2001 From: Myastr0 <14167316+Myastr0@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:24:45 +0100 Subject: [PATCH 1/6] feat(wip): wip --- .../__fakes__/fakeDestination.repository.ts | 17 +- package.json | 2 +- src/domains/elements/Element.ts | 460 ------------------ .../elements/Element/CalloutElement.class.ts | 85 ++++ .../elements/Element/CodeElement.class.ts | 50 ++ .../elements/Element/DividerElement.class.ts | 8 + src/domains/elements/Element/Element.class.ts | 9 + .../elements/Element/EquationElement.class.ts | 36 ++ .../elements/Element/FileElement.class.ts | 34 ++ .../elements/Element/HtmlElement.class.ts | 11 + .../elements/Element/ImageElement.class.ts | 43 ++ .../elements/Element/LinkElement.class.ts | 23 + .../elements/Element/ListItemElement.class.ts | 23 + .../elements/Element/PageElement.class.ts | 62 +++ .../elements/Element/QuoteElement.class.ts | 11 + .../elements/Element/TableElement.class.ts | 11 + .../Element/TableOfContentElement.class.ts | 8 + .../elements/Element/TextElement.class.ts | 69 +++ .../elements/Element/TextElement.types.ts | 7 + .../elements/Element/ToggleElement.class.ts | 13 + src/domains/elements/Element/index.ts | 17 + src/domains/elements/Element/types.ts | 17 + src/domains/elements/parser.repository.ts | 4 +- .../features/synchronizeMarkdownToNotion.ts | 326 ++++++++----- src/domains/notion/NotionPage.ts | 2 +- src/domains/notion/constants.ts | 1 + src/domains/notion/types/database.types.ts | 3 + src/domains/notion/types/index.ts | 2 + src/domains/notion/{ => types}/types.ts | 30 +- .../synchronization/destination.repository.ts | 15 +- .../markdown/markdown.parser.ts | 10 + src/infrastructure/notion/index.ts | 2 +- src/infrastructure/notion/notion.converter.ts | 456 ++++++++++++++++- .../notion/notion.destination.test.ts | 23 +- .../notion/notion.destination.ts | 145 ++++-- src/infrastructure/notion/utils.ts | 2 +- yarn.lock | 8 +- 37 files changed, 1379 insertions(+), 666 deletions(-) delete mode 100644 src/domains/elements/Element.ts create mode 100644 src/domains/elements/Element/CalloutElement.class.ts create mode 100644 src/domains/elements/Element/CodeElement.class.ts create mode 100644 src/domains/elements/Element/DividerElement.class.ts create mode 100644 src/domains/elements/Element/Element.class.ts create mode 100644 src/domains/elements/Element/EquationElement.class.ts create mode 100644 src/domains/elements/Element/FileElement.class.ts create mode 100644 src/domains/elements/Element/HtmlElement.class.ts create mode 100644 src/domains/elements/Element/ImageElement.class.ts create mode 100644 src/domains/elements/Element/LinkElement.class.ts create mode 100644 src/domains/elements/Element/ListItemElement.class.ts create mode 100644 src/domains/elements/Element/PageElement.class.ts create mode 100644 src/domains/elements/Element/QuoteElement.class.ts create mode 100644 src/domains/elements/Element/TableElement.class.ts create mode 100644 src/domains/elements/Element/TableOfContentElement.class.ts create mode 100644 src/domains/elements/Element/TextElement.class.ts create mode 100644 src/domains/elements/Element/TextElement.types.ts create mode 100644 src/domains/elements/Element/ToggleElement.class.ts create mode 100644 src/domains/elements/Element/index.ts create mode 100644 src/domains/elements/Element/types.ts create mode 100644 src/domains/notion/constants.ts create mode 100644 src/domains/notion/types/database.types.ts create mode 100644 src/domains/notion/types/index.ts rename src/domains/notion/{ => types}/types.ts (96%) diff --git a/__tests__/__fakes__/fakeDestination.repository.ts b/__tests__/__fakes__/fakeDestination.repository.ts index 8afe753..e384586 100644 --- a/__tests__/__fakes__/fakeDestination.repository.ts +++ b/__tests__/__fakes__/fakeDestination.repository.ts @@ -1,6 +1,7 @@ import { type PageElement } from '@/domains/elements'; import { DestinationRepository, + ObjectType, Page, PageLockedStatus, } from '@/domains/synchronization/destination.repository'; @@ -10,18 +11,22 @@ import { FakePage } from './fakePage'; export class FakeDestinationRepository implements DestinationRepository { - getPageIdFromPageUrl({ pageUrl }: { pageUrl: string }): string { - return pageUrl.split('/').pop() ?? ''; + async getObjectType({ id }: { id: string }): Promise { + return Promise.resolve('page'); + } + + getObjectIdFromObjectUrl({ objectUrl }: { objectUrl: string }): string { + return objectUrl.split('/').pop() ?? ''; } // Simulate creating a new page // eslint-disable-next-line @typescript-eslint/require-await async createPage({ pageElement, - parentPageId, + parentObjectId, }: { pageElement: PageElement; - parentPageId: string; + parentObjectId: string; }): Promise { // Here you would implement the logic to create a new page in the fake destination const fakePage = new FakePage({ @@ -55,9 +60,9 @@ export class FakeDestinationRepository // Simulate checking if the destination is accessible // eslint-disable-next-line @typescript-eslint/require-await async destinationIsAccessible({ - parentPageId, + parentObjectId, }: { - parentPageId: string; + parentObjectId: string; }): Promise { // Here you would implement the logic to check if the destination is accessible // For demonstration purposes, let's return a boolean value diff --git a/package.json b/package.json index 9ea2477..1daa574 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "@actions/core": "1.11.1", - "@notionhq/client": "5.1.0", + "@notionhq/client": "5.4.0", "commander": "13.1.0", "dom-serializer": "2.0.0", "form-data": "4.0.4", diff --git a/src/domains/elements/Element.ts b/src/domains/elements/Element.ts deleted file mode 100644 index 79eb7a7..0000000 --- a/src/domains/elements/Element.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { SupportedEmoji } from './types'; - -export enum ElementType { - Page = 'page', - File = 'file', - Text = 'text', - Quote = 'quote', - Code = 'code', - Callout = 'callout', - Divider = 'divider', - Image = 'image', - Link = 'link', - Table = 'table', - ListItem = 'list-item', - Html = 'html', - Toggle = 'toggle', - Equation = 'equation', - TableOfContents = 'table-of-contents', -} - -export class Element { - public type: ElementType; - - constructor(type: ElementType) { - this.type = type; - } -} - -/** - * Element that represents the concept of page (in knowledge management systems) - */ -export class PageElement extends Element { - public title: string; - public icon?: SupportedEmoji; - public content: Element[]; - - constructor({ - title, - icon, - content = [], - }: { - title: string; - icon?: SupportedEmoji; - content: Element[]; - }) { - super(ElementType.Page); - this.title = title; - this.icon = icon; - this.content = content; - } - - public getIcon(): SupportedEmoji | undefined { - return this.icon; - } - - public addElementToBeginning(element: Element): void { - this.content.unshift(element); - } - - public addElementToEnd(element: Element): void { - this.content.push(element); - } -} - -/** - * Element that represents a file in the system - */ -export class FileElement extends Element { - public content: string; - public name?: string; - public creationDate?: Date; - public lastUpdatedDate?: Date; - public extension?: string; - - constructor({ - content, - name, - creationDate, - lastUpdatedDate, - extension, - }: { - content: string; - name?: string; - creationDate?: Date; - lastUpdatedDate?: Date; - extension?: string; - }) { - super(ElementType.File); - this.content = content; - this.name = name; - this.creationDate = creationDate; - this.lastUpdatedDate = lastUpdatedDate; - this.extension = extension; - } -} - -export class ListItemElement extends Element { - public listType: 'ordered' | 'unordered'; - public text: RichTextElement; - public children?: Element[]; - constructor({ - listType, - text, - children, - }: { - listType: 'ordered' | 'unordered'; - text: RichTextElement; - children?: Element[]; - }) { - super(ElementType.ListItem); - this.listType = listType; - this.text = text; - this.children = children; - } -} - -export class TableElement extends Element { - public rows: string[][]; - - constructor({ rows }: { rows: string[][] }) { - super(ElementType.Table); - this.rows = rows; - } -} - -export enum TextElementLevel { - Heading1 = 'heading_1', - Heading2 = 'heading_2', - Heading3 = 'heading_3', - Heading4 = 'heading_4', - Heading5 = 'heading_5', - Heading6 = 'heading_6', - Paragraph = 'paragraph', -} - -export enum TextElementStyle { - Italic = 'italic', - Bold = 'bold', - Strikethrough = 'strikethrough', - Underline = 'underline', -} - -export type RichTextElement = ( - | TextElement - | LinkElement - | ImageElement - | EquationElement - | ListItemElement -)[]; - -export type TextElementStyles = { - italic: boolean; - bold: boolean; - strikethrough: boolean; - underline: boolean; - code: boolean; -}; - -export class TextElement extends Element { - public text: string | RichTextElement; - public level: TextElementLevel; - public styles: TextElementStyles = { - italic: false, - bold: false, - strikethrough: false, - underline: false, - code: false, - }; - - constructor({ - text, - level = TextElementLevel.Paragraph, - styles, - }: { - text: string | RichTextElement; - level?: TextElementLevel; - styles?: { - italic?: boolean; - bold?: boolean; - strikethrough?: boolean; - underline?: boolean; - code?: boolean; - }; - }) { - super(ElementType.Text); - this.text = text; - this.level = level; - this.styles.bold = styles?.bold || false; - this.styles.italic = styles?.italic || false; - this.styles.strikethrough = styles?.strikethrough || false; - this.styles.underline = styles?.underline || false; - this.styles.code = styles?.code || false; - } -} - -export class QuoteElement extends Element { - public text: string; - - constructor({ text }: { text: string }) { - super(ElementType.Quote); - this.text = text; - } -} - -export enum ElementCodeLanguage { - JavaScript = 'javascript', - TypeScript = 'typescript', - Python = 'python', - Java = 'java', - CSharp = 'csharp', - CPlusPlus = 'c++', - Go = 'go', - Ruby = 'ruby', - Swift = 'swift', - Kotlin = 'kotlin', - Rust = 'rust', - Shell = 'shell', - Scala = 'scala', - SQL = 'sql', - HTML = 'html', - CSS = 'css', - JSON = 'json', - YAML = 'yaml', - Markdown = 'markdown', - Mermaid = 'mermaid', - PlainText = 'plaintext', -} - -export const isElementCodeLanguage = ( - value: string -): value is ElementCodeLanguage => { - return Object.values(ElementCodeLanguage).includes( - value as ElementCodeLanguage - ); -}; -export class CodeElement extends Element { - public language: ElementCodeLanguage; - public text: string; - - constructor({ - language, - text, - }: { - language: ElementCodeLanguage; - text: string; - }) { - super(ElementType.Code); - this.language = language; - this.text = text; - } -} - -const specialCalloutRegex = - // eslint-disable-next-line no-useless-escape - /^\s*\[\!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)/ims; - -export enum SpecialCalloutType { - Note = 'note', - Tip = 'tip', - Important = 'important', - Warning = 'warning', - Caution = 'caution', -} - -export class CalloutElement extends Element { - public text: string; - private readonly icon?: SupportedEmoji; - private readonly calloutType?: SpecialCalloutType; - - public static isSpecialCalloutText(text: string): boolean { - return specialCalloutRegex.test(text.trim()); - } - - constructor({ icon, text }: { icon?: SupportedEmoji; text: string }) { - super(ElementType.Callout); - this.icon = icon; - this.text = text; - - const { text: parsedText, calloutType } = - this.getSpecialCalloutTypeAndText(text); - - if (calloutType) { - this.calloutType = calloutType; - this.text = parsedText; - } - } - - private getSpecialCalloutTypeAndText(text: string): { - calloutType: SpecialCalloutType | null; - text: string; - } { - const textToSpecialCalloutType: Record = { - note: SpecialCalloutType.Note, - tip: SpecialCalloutType.Tip, - important: SpecialCalloutType.Important, - warning: SpecialCalloutType.Warning, - caution: SpecialCalloutType.Caution, - }; - - const match = specialCalloutRegex.exec(text.trim()); - - if (match) { - const typeString = match[1].toLowerCase() as SpecialCalloutType; - const text = match[2].trim(); - - const calloutType = textToSpecialCalloutType[typeString]; - - if (calloutType) { - return { calloutType, text }; - } - } - - return { - calloutType: null, - text, - }; - } - public getIcon(): SupportedEmoji | undefined { - const iconMap: Record = { - [SpecialCalloutType.Note]: 'ℹ️', - [SpecialCalloutType.Tip]: '💡', - [SpecialCalloutType.Important]: '⚠️', - [SpecialCalloutType.Warning]: '⚠️', - [SpecialCalloutType.Caution]: '⚠️', - }; - - if (this.calloutType && iconMap[this.calloutType]) { - return iconMap[this.calloutType]; - } - - return this.icon; - } -} - -export class DividerElement extends Element { - constructor() { - super(ElementType.Divider); - } -} - -export class ImageElement extends Element { - public base64?: string; - public url?: string; - public caption?: string; - public name?: string; - public creationDate?: Date; - public lastUpdatedDate?: Date; - public extension?: string; - public filepath?: string; - - constructor({ - base64, - url, - name, - creationDate, - lastUpdatedDate, - extension, - caption, - filepath, - }: { - base64?: string; - url?: string; - name?: string; - creationDate?: Date; - lastUpdatedDate?: Date; - extension?: string; - caption?: string; - filepath?: string; - }) { - super(ElementType.Image); - this.name = name; - this.creationDate = creationDate; - this.lastUpdatedDate = lastUpdatedDate; - this.extension = extension; - this.base64 = base64; - this.url = url; - this.caption = caption; - this.filepath = filepath; - } -} - -export class LinkElement extends Element { - public url: string; - public text: string; - public caption?: string; - - constructor({ - url, - text, - caption, - }: { - url: string; - text: string; - caption?: string; - }) { - super(ElementType.Link); - this.url = url; - this.text = text; - this.caption = caption; - } -} - -export class HtmlElement extends Element { - public html: string; - - constructor({ html }: { html: string }) { - super(ElementType.Html); - this.html = html; - } -} - -export class ToggleElement extends Element { - public title: string; - public children: Element[]; - - constructor({ title, children }: { title: string; children: Element[] }) { - super(ElementType.Toggle); - this.title = title; - this.children = children; - } -} - -export class TableOfContentsElement extends Element { - constructor() { - super(ElementType.TableOfContents); - } -} - -export class EquationElement extends Element { - public equation: string; - public styles: TextElementStyles = { - italic: false, - bold: false, - strikethrough: false, - underline: false, - code: false, - }; - - constructor({ - equation, - styles, - }: { - equation: string; - styles?: { - italic?: boolean; - bold?: boolean; - strikethrough?: boolean; - underline?: boolean; - code?: boolean; - }; - }) { - super(ElementType.Equation); - this.equation = equation; - this.styles.bold = styles?.bold || false; - this.styles.italic = styles?.italic || false; - this.styles.strikethrough = styles?.strikethrough || false; - this.styles.underline = styles?.underline || false; - this.styles.code = styles?.code || false; - } -} diff --git a/src/domains/elements/Element/CalloutElement.class.ts b/src/domains/elements/Element/CalloutElement.class.ts new file mode 100644 index 0000000..1e1810d --- /dev/null +++ b/src/domains/elements/Element/CalloutElement.class.ts @@ -0,0 +1,85 @@ +import { SupportedEmoji } from '../types'; +import { Element } from './Element.class'; +import { ElementType } from './types'; + +const specialCalloutRegex = + // eslint-disable-next-line no-useless-escape + /^\s*\[\!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)/ims; + +export enum SpecialCalloutType { + Note = 'note', + Tip = 'tip', + Important = 'important', + Warning = 'warning', + Caution = 'caution', +} + +export class CalloutElement extends Element { + public text: string; + private readonly icon?: SupportedEmoji; + private readonly calloutType?: SpecialCalloutType; + + public static isSpecialCalloutText(text: string): boolean { + return specialCalloutRegex.test(text.trim()); + } + + constructor({ icon, text }: { icon?: SupportedEmoji; text: string }) { + super(ElementType.Callout); + this.icon = icon; + this.text = text; + + const { text: parsedText, calloutType } = + this.getSpecialCalloutTypeAndText(text); + + if (calloutType) { + this.calloutType = calloutType; + this.text = parsedText; + } + } + + private getSpecialCalloutTypeAndText(text: string): { + calloutType: SpecialCalloutType | null; + text: string; + } { + const textToSpecialCalloutType: Record = { + note: SpecialCalloutType.Note, + tip: SpecialCalloutType.Tip, + important: SpecialCalloutType.Important, + warning: SpecialCalloutType.Warning, + caution: SpecialCalloutType.Caution, + }; + + const match = specialCalloutRegex.exec(text.trim()); + + if (match) { + const typeString = match[1].toLowerCase() as SpecialCalloutType; + const text = match[2].trim(); + + const calloutType = textToSpecialCalloutType[typeString]; + + if (calloutType) { + return { calloutType, text }; + } + } + + return { + calloutType: null, + text, + }; + } + public getIcon(): SupportedEmoji | undefined { + const iconMap: Record = { + [SpecialCalloutType.Note]: 'ℹ️', + [SpecialCalloutType.Tip]: '💡', + [SpecialCalloutType.Important]: '⚠️', + [SpecialCalloutType.Warning]: '⚠️', + [SpecialCalloutType.Caution]: '⚠️', + }; + + if (this.calloutType && iconMap[this.calloutType]) { + return iconMap[this.calloutType]; + } + + return this.icon; + } +} diff --git a/src/domains/elements/Element/CodeElement.class.ts b/src/domains/elements/Element/CodeElement.class.ts new file mode 100644 index 0000000..88a0151 --- /dev/null +++ b/src/domains/elements/Element/CodeElement.class.ts @@ -0,0 +1,50 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export enum ElementCodeLanguage { + JavaScript = 'javascript', + TypeScript = 'typescript', + Python = 'python', + Java = 'java', + CSharp = 'csharp', + CPlusPlus = 'c++', + Go = 'go', + Ruby = 'ruby', + Swift = 'swift', + Kotlin = 'kotlin', + Rust = 'rust', + Shell = 'shell', + Scala = 'scala', + SQL = 'sql', + HTML = 'html', + CSS = 'css', + JSON = 'json', + YAML = 'yaml', + Markdown = 'markdown', + Mermaid = 'mermaid', + PlainText = 'plaintext', +} + +export const isElementCodeLanguage = ( + value: string +): value is ElementCodeLanguage => { + return Object.values(ElementCodeLanguage).includes( + value as ElementCodeLanguage + ); +}; +export class CodeElement extends Element { + public language: ElementCodeLanguage; + public text: string; + + constructor({ + language, + text, + }: { + language: ElementCodeLanguage; + text: string; + }) { + super(ElementType.Code); + this.language = language; + this.text = text; + } +} diff --git a/src/domains/elements/Element/DividerElement.class.ts b/src/domains/elements/Element/DividerElement.class.ts new file mode 100644 index 0000000..95bf443 --- /dev/null +++ b/src/domains/elements/Element/DividerElement.class.ts @@ -0,0 +1,8 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class DividerElement extends Element { + constructor() { + super(ElementType.Divider); + } +} diff --git a/src/domains/elements/Element/Element.class.ts b/src/domains/elements/Element/Element.class.ts new file mode 100644 index 0000000..11e8219 --- /dev/null +++ b/src/domains/elements/Element/Element.class.ts @@ -0,0 +1,9 @@ +import { ElementType } from './types'; + +export class Element { + public type: ElementType; + + constructor(type: ElementType) { + this.type = type; + } +} diff --git a/src/domains/elements/Element/EquationElement.class.ts b/src/domains/elements/Element/EquationElement.class.ts new file mode 100644 index 0000000..42b1272 --- /dev/null +++ b/src/domains/elements/Element/EquationElement.class.ts @@ -0,0 +1,36 @@ +import { Element } from './Element.class'; +import { TextElementStyles } from './TextElement.types'; +import { ElementType } from './types'; + +export class EquationElement extends Element { + public equation: string; + public styles: TextElementStyles = { + italic: false, + bold: false, + strikethrough: false, + underline: false, + code: false, + }; + + constructor({ + equation, + styles, + }: { + equation: string; + styles?: { + italic?: boolean; + bold?: boolean; + strikethrough?: boolean; + underline?: boolean; + code?: boolean; + }; + }) { + super(ElementType.Equation); + this.equation = equation; + this.styles.bold = styles?.bold || false; + this.styles.italic = styles?.italic || false; + this.styles.strikethrough = styles?.strikethrough || false; + this.styles.underline = styles?.underline || false; + this.styles.code = styles?.code || false; + } +} diff --git a/src/domains/elements/Element/FileElement.class.ts b/src/domains/elements/Element/FileElement.class.ts new file mode 100644 index 0000000..53fbf8c --- /dev/null +++ b/src/domains/elements/Element/FileElement.class.ts @@ -0,0 +1,34 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +/** + * Element that represents a file in the system + */ +export class FileElement extends Element { + public content: string; + public name?: string; + public creationDate?: Date; + public lastUpdatedDate?: Date; + public extension?: string; + + constructor({ + content, + name, + creationDate, + lastUpdatedDate, + extension, + }: { + content: string; + name?: string; + creationDate?: Date; + lastUpdatedDate?: Date; + extension?: string; + }) { + super(ElementType.File); + this.content = content; + this.name = name; + this.creationDate = creationDate; + this.lastUpdatedDate = lastUpdatedDate; + this.extension = extension; + } +} diff --git a/src/domains/elements/Element/HtmlElement.class.ts b/src/domains/elements/Element/HtmlElement.class.ts new file mode 100644 index 0000000..9590a25 --- /dev/null +++ b/src/domains/elements/Element/HtmlElement.class.ts @@ -0,0 +1,11 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class HtmlElement extends Element { + public html: string; + + constructor({ html }: { html: string }) { + super(ElementType.Html); + this.html = html; + } +} diff --git a/src/domains/elements/Element/ImageElement.class.ts b/src/domains/elements/Element/ImageElement.class.ts new file mode 100644 index 0000000..5ce8eb1 --- /dev/null +++ b/src/domains/elements/Element/ImageElement.class.ts @@ -0,0 +1,43 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class ImageElement extends Element { + public base64?: string; + public url?: string; + public caption?: string; + public name?: string; + public creationDate?: Date; + public lastUpdatedDate?: Date; + public extension?: string; + public filepath?: string; + + constructor({ + base64, + url, + name, + creationDate, + lastUpdatedDate, + extension, + caption, + filepath, + }: { + base64?: string; + url?: string; + name?: string; + creationDate?: Date; + lastUpdatedDate?: Date; + extension?: string; + caption?: string; + filepath?: string; + }) { + super(ElementType.Image); + this.name = name; + this.creationDate = creationDate; + this.lastUpdatedDate = lastUpdatedDate; + this.extension = extension; + this.base64 = base64; + this.url = url; + this.caption = caption; + this.filepath = filepath; + } +} diff --git a/src/domains/elements/Element/LinkElement.class.ts b/src/domains/elements/Element/LinkElement.class.ts new file mode 100644 index 0000000..7f0567e --- /dev/null +++ b/src/domains/elements/Element/LinkElement.class.ts @@ -0,0 +1,23 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class LinkElement extends Element { + public url: string; + public text: string; + public caption?: string; + + constructor({ + url, + text, + caption, + }: { + url: string; + text: string; + caption?: string; + }) { + super(ElementType.Link); + this.url = url; + this.text = text; + this.caption = caption; + } +} diff --git a/src/domains/elements/Element/ListItemElement.class.ts b/src/domains/elements/Element/ListItemElement.class.ts new file mode 100644 index 0000000..1e64f07 --- /dev/null +++ b/src/domains/elements/Element/ListItemElement.class.ts @@ -0,0 +1,23 @@ +import { RichTextElement } from '.'; +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class ListItemElement extends Element { + public listType: 'ordered' | 'unordered'; + public text: RichTextElement; + public children?: Element[]; + constructor({ + listType, + text, + children, + }: { + listType: 'ordered' | 'unordered'; + text: RichTextElement; + children?: Element[]; + }) { + super(ElementType.ListItem); + this.listType = listType; + this.text = text; + this.children = children; + } +} diff --git a/src/domains/elements/Element/PageElement.class.ts b/src/domains/elements/Element/PageElement.class.ts new file mode 100644 index 0000000..ff965da --- /dev/null +++ b/src/domains/elements/Element/PageElement.class.ts @@ -0,0 +1,62 @@ +import { SupportedEmoji } from '../types'; +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export type PageElementPropertyValue = + | string + | string[] + | number + | number[] + | boolean + | boolean[] + | null + | undefined; + +export type PageElementProperties = { + name: string; + value: PageElementPropertyValue; +}; + +/** + * Element that represents the concept of page (in knowledge management systems) + */ +export class PageElement extends Element { + public mkNotesInternalId?: string; + public title: string; + public icon?: SupportedEmoji; + public content: Element[]; + public properties?: PageElementProperties[]; + + constructor({ + mkNotesInternalId, + title, + icon, + content = [], + properties, + }: { + mkNotesInternalId?: string; + title: string; + icon?: SupportedEmoji; + content: Element[]; + properties?: PageElementProperties[]; + }) { + super(ElementType.Page); + this.mkNotesInternalId = mkNotesInternalId; + this.title = title; + this.icon = icon; + this.content = content; + this.properties = properties; + } + + public getIcon(): SupportedEmoji | undefined { + return this.icon; + } + + public addElementToBeginning(element: Element): void { + this.content.unshift(element); + } + + public addElementToEnd(element: Element): void { + this.content.push(element); + } +} diff --git a/src/domains/elements/Element/QuoteElement.class.ts b/src/domains/elements/Element/QuoteElement.class.ts new file mode 100644 index 0000000..2b5dd81 --- /dev/null +++ b/src/domains/elements/Element/QuoteElement.class.ts @@ -0,0 +1,11 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class QuoteElement extends Element { + public text: string; + + constructor({ text }: { text: string }) { + super(ElementType.Quote); + this.text = text; + } +} diff --git a/src/domains/elements/Element/TableElement.class.ts b/src/domains/elements/Element/TableElement.class.ts new file mode 100644 index 0000000..679d868 --- /dev/null +++ b/src/domains/elements/Element/TableElement.class.ts @@ -0,0 +1,11 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class TableElement extends Element { + public rows: string[][]; + + constructor({ rows }: { rows: string[][] }) { + super(ElementType.Table); + this.rows = rows; + } +} diff --git a/src/domains/elements/Element/TableOfContentElement.class.ts b/src/domains/elements/Element/TableOfContentElement.class.ts new file mode 100644 index 0000000..fa4568a --- /dev/null +++ b/src/domains/elements/Element/TableOfContentElement.class.ts @@ -0,0 +1,8 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class TableOfContentsElement extends Element { + constructor() { + super(ElementType.TableOfContents); + } +} diff --git a/src/domains/elements/Element/TextElement.class.ts b/src/domains/elements/Element/TextElement.class.ts new file mode 100644 index 0000000..af1c332 --- /dev/null +++ b/src/domains/elements/Element/TextElement.class.ts @@ -0,0 +1,69 @@ +import { Element } from './Element.class'; +import { EquationElement } from './EquationElement.class'; +import { ImageElement } from './ImageElement.class'; +import { LinkElement } from './LinkElement.class'; +import { ListItemElement } from './ListItemElement.class'; +import { TextElementStyles } from './TextElement.types'; +import { ElementType } from './types'; + +export type RichTextElement = ( + | TextElement + | LinkElement + | ImageElement + | EquationElement + | ListItemElement +)[]; + +export enum TextElementLevel { + Heading1 = 'heading_1', + Heading2 = 'heading_2', + Heading3 = 'heading_3', + Heading4 = 'heading_4', + Heading5 = 'heading_5', + Heading6 = 'heading_6', + Paragraph = 'paragraph', +} + +export enum TextElementStyle { + Italic = 'italic', + Bold = 'bold', + Strikethrough = 'strikethrough', + Underline = 'underline', +} + +export class TextElement extends Element { + public text: string | RichTextElement; + public level: TextElementLevel; + public styles: TextElementStyles = { + italic: false, + bold: false, + strikethrough: false, + underline: false, + code: false, + }; + + constructor({ + text, + level = TextElementLevel.Paragraph, + styles, + }: { + text: string | RichTextElement; + level?: TextElementLevel; + styles?: { + italic?: boolean; + bold?: boolean; + strikethrough?: boolean; + underline?: boolean; + code?: boolean; + }; + }) { + super(ElementType.Text); + this.text = text; + this.level = level; + this.styles.bold = styles?.bold || false; + this.styles.italic = styles?.italic || false; + this.styles.strikethrough = styles?.strikethrough || false; + this.styles.underline = styles?.underline || false; + this.styles.code = styles?.code || false; + } +} diff --git a/src/domains/elements/Element/TextElement.types.ts b/src/domains/elements/Element/TextElement.types.ts new file mode 100644 index 0000000..f124767 --- /dev/null +++ b/src/domains/elements/Element/TextElement.types.ts @@ -0,0 +1,7 @@ +export type TextElementStyles = { + italic: boolean; + bold: boolean; + strikethrough: boolean; + underline: boolean; + code: boolean; +}; diff --git a/src/domains/elements/Element/ToggleElement.class.ts b/src/domains/elements/Element/ToggleElement.class.ts new file mode 100644 index 0000000..e0cea20 --- /dev/null +++ b/src/domains/elements/Element/ToggleElement.class.ts @@ -0,0 +1,13 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class ToggleElement extends Element { + public title: string; + public children: Element[]; + + constructor({ title, children }: { title: string; children: Element[] }) { + super(ElementType.Toggle); + this.title = title; + this.children = children; + } +} diff --git a/src/domains/elements/Element/index.ts b/src/domains/elements/Element/index.ts new file mode 100644 index 0000000..f25afb7 --- /dev/null +++ b/src/domains/elements/Element/index.ts @@ -0,0 +1,17 @@ +export * from './CalloutElement.class'; +export * from './CodeElement.class'; +export * from './DividerElement.class'; +export * from './Element.class'; +export * from './EquationElement.class'; +export * from './FileElement.class'; +export * from './HtmlElement.class'; +export * from './ImageElement.class'; +export * from './LinkElement.class'; +export * from './ListItemElement.class'; +export * from './PageElement.class'; +export * from './QuoteElement.class'; +export * from './TableElement.class'; +export * from './TableOfContentElement.class'; +export * from './TextElement.class'; +export * from './ToggleElement.class'; +export * from './types'; diff --git a/src/domains/elements/Element/types.ts b/src/domains/elements/Element/types.ts new file mode 100644 index 0000000..ee5de2a --- /dev/null +++ b/src/domains/elements/Element/types.ts @@ -0,0 +1,17 @@ +export enum ElementType { + Page = 'page', + File = 'file', + Text = 'text', + Quote = 'quote', + Code = 'code', + Callout = 'callout', + Divider = 'divider', + Image = 'image', + Link = 'link', + Table = 'table', + ListItem = 'list-item', + Html = 'html', + Toggle = 'toggle', + Equation = 'equation', + TableOfContents = 'table-of-contents', +} diff --git a/src/domains/elements/parser.repository.ts b/src/domains/elements/parser.repository.ts index 35f1d18..9d42edf 100644 --- a/src/domains/elements/parser.repository.ts +++ b/src/domains/elements/parser.repository.ts @@ -1,10 +1,12 @@ import { Logger } from 'winston'; -import { Element } from './Element'; +import { Element, PageElementProperties } from './Element'; import { SupportedEmoji } from './types'; export interface ParseResult { + mkNotesInternalId?: string; title?: string; + properties?: PageElementProperties[]; content: Element[]; icon?: SupportedEmoji; } diff --git a/src/domains/features/synchronizeMarkdownToNotion.ts b/src/domains/features/synchronizeMarkdownToNotion.ts index b472cb6..70f092d 100644 --- a/src/domains/features/synchronizeMarkdownToNotion.ts +++ b/src/domains/features/synchronizeMarkdownToNotion.ts @@ -11,6 +11,7 @@ import { SiteMap, type TreeNode } from '@/domains/sitemap'; import { type DestinationRepository, type File, + ObjectType, type Page, type SourceRepository, } from '@/domains/synchronization'; @@ -50,16 +51,43 @@ export class SynchronizeMarkdownToNotion { ): Promise { const { notionParentPageUrl, cleanSync, lockPage, ...others } = args; - const notionPageId = this.destinationRepository.getPageIdFromPageUrl({ - pageUrl: notionParentPageUrl, + const notionObjectId = this.destinationRepository.getObjectIdFromObjectUrl({ + objectUrl: notionParentPageUrl, }); + // Check if the Notion page is accessible + const destinationIsAccessible = + await this.destinationRepository.destinationIsAccessible({ + parentObjectId: notionObjectId, + }); + + if (!destinationIsAccessible) { + throw new Error('Destination is not accessible'); + } + + // Check if the source is accessible + try { + await this.sourceRepository.sourceIsAccessible(others as T); + } catch (err) { + throw new Error(`Source is not accessible:`, { + cause: err, + }); + } + + const parentObjectType = await this.destinationRepository.getObjectType({ + id: notionObjectId, + }); + + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } + // If clean sync is enabled, delete all existing content first if (cleanSync) { this.logger.info('Clean sync enabled - removing existing content'); try { await this.destinationRepository.deleteChildBlocks({ - parentPageId: notionPageId, + parentPageId: notionObjectId, }); this.logger.info('Successfully removed existing content'); } catch (error) { @@ -71,25 +99,6 @@ export class SynchronizeMarkdownToNotion { } try { - // Check if the Notion page is accessible - const destinationIsAccessible = - await this.destinationRepository.destinationIsAccessible({ - parentPageId: notionPageId, - }); - - if (!destinationIsAccessible) { - throw new Error('Destination is not accessible'); - } - - // Check if the GitHub repository is accessible - try { - await this.sourceRepository.sourceIsAccessible(others as T); - } catch (err) { - throw new Error(`Source is not accessible:`, { - cause: err, - }); - } - this.logger.info('Starting synchronization process'); const filePaths = await this.sourceRepository.getFilePathList( @@ -101,7 +110,8 @@ export class SynchronizeMarkdownToNotion { // Traverse the SiteMap and synchronize files await this.synchronizeTreeNode({ node: siteMap.root, - parentPageId: notionPageId, + parentObjectId: notionObjectId, + parentObjectType, lockPage, }); @@ -116,136 +126,208 @@ export class SynchronizeMarkdownToNotion { } } - private async synchronizeTreeNode({ + /** + * Fetches a file and converts it to a PageElement + */ + private async fetchAndConvertToPageElement( + filePath: string + ): Promise { + const file = await this.sourceRepository.getFile({ path: filePath } as T); + + if (this.elementConverter.setCurrentFilePath) { + this.elementConverter.setCurrentFilePath(filePath); + } + + const element = this.elementConverter.convertToElement(file); + + if (!(element instanceof PageElement)) { + throw new Error('Element is not a PageElement'); + } + + return element; + } + + /** + * Locks a page if locking is enabled + */ + private async lockPageIfNeeded( + pageId: string, + shouldLock: boolean + ): Promise { + if (shouldLock) { + await this.destinationRepository.setPageLockedStatus({ + pageId, + lockStatus: 'locked', + }); + this.logger.info(`Locked page ${pageId}`); + } + } + + /** + * Synchronizes the root node to the parent object (page or database) + * Returns the page ID to use as parent for child nodes + */ + private async synchronizeRootNode({ node, - parentPageId, + parentObjectId, + parentObjectType, lockPage, }: { node: TreeNode; - parentPageId: string; + parentObjectId: string; + parentObjectType: ObjectType; lockPage: boolean; - }): Promise { - // If the current node has content AND is the root node, add it to the parent page - if (node.filepath && node.parent === null) { - try { - this.logger.info(`Adding content from ${node.filepath} to parent page`); - - // Retrieve the file content - const file = await this.sourceRepository.getFile({ - path: node.filepath, - } as T); + }): Promise { + this.logger.info( + `Adding content from ${node.filepath} to parent ${parentObjectType}` + ); - // Set the current file path for image resolution - if (this.elementConverter.setCurrentFilePath) { - this.elementConverter.setCurrentFilePath(node.filepath); - } + const pageElement = await this.fetchAndConvertToPageElement(node.filepath); - // Convert the file content to elements - const pageElement = this.elementConverter.convertToElement(file); + if (parentObjectType === 'page') { + await this.destinationRepository.appendToPage({ + pageId: parentObjectId, + pageElement, + }); + this.logger.info(`Added content from ${node.filepath} to parent page`); - if (!(pageElement instanceof PageElement)) { - throw new Error('Element is not a PageElement'); - } + await this.lockPageIfNeeded(parentObjectId, lockPage); - // Add the content to the existing parent page by appending it - await this.destinationRepository.appendToPage({ - pageId: parentPageId, - pageElement, - }); + return parentObjectId; + } - this.logger.info(`Added content from ${node.filepath} to parent page`); + // parentObjectType === 'database' + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + filePath: node.filepath, + }); - if (lockPage) { - await this.destinationRepository.setPageLockedStatus({ - pageId: parentPageId, - lockStatus: 'locked', - }); - this.logger.info(`Locked parent page ${parentPageId}`); - } - } catch (error) { - if (error instanceof Error) { - this.logger.error( - `Failed to add content from ${node.filepath} to parent page`, - { - error, - } - ); - } - throw error; - } + if (!newPage.pageId) { + throw new Error('New page ID is undefined'); } - for (const childNode of node.children) { - const filePath = childNode.filepath; - this.logger.info(`Processing file: ${filePath}`); + return newPage.pageId; + } - try { - // Retrieve the file from the source repository - const file = await this.sourceRepository.getFile({ - path: filePath, - } as T); - - // Set the current file path for image resolution - if (this.elementConverter.setCurrentFilePath) { - this.elementConverter.setCurrentFilePath(filePath); - } + /** + * Synchronizes a child node and its descendants recursively + */ + private async synchronizeChildNode({ + childNode, + parentPageId, + lockPage, + }: { + childNode: TreeNode; + parentPageId: string; + lockPage: boolean; + }): Promise { + const filePath = childNode.filepath; + this.logger.info(`Processing file: ${filePath}`); - // Convert the file content to a Notion page element - const pageElement = this.elementConverter.convertToElement(file); + const pageElement = await this.fetchAndConvertToPageElement(filePath); - if (!(pageElement instanceof PageElement)) { - throw new Error('Element is not a PageElement'); - } + // Add standard elements at the beginning (in reverse order) + pageElement.addElementToBeginning(new TableOfContentsElement()); + pageElement.addElementToBeginning(new DividerElement()); - [new DividerElement(), new TableOfContentsElement()].forEach( - (element) => { - pageElement.addElementToBeginning(element); - } - ); + if (childNode.children.length > 0) { + pageElement.addElementToEnd(new DividerElement()); + } - if (childNode.children.length > 0) { - // Add divider to the end of the page - pageElement.addElementToEnd(new DividerElement()); - } + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId: parentPageId, + parentObjectType: 'page', + filePath, + }); - // Create the Notion page and get the new page ID - const newPage = await this.destinationRepository.createPage({ - pageElement, - parentPageId, - filePath, - }); + this.logger.info(`Created Notion page for file: ${filePath}`); + + if (!newPage.pageId) { + throw new Error('Page ID is undefined'); + } - this.logger.info(`Created Notion page for file: ${filePath}`); + // Recursively process children + for (const grandChild of childNode.children) { + await this.synchronizeChildNode({ + childNode: grandChild, + parentPageId: newPage.pageId, + lockPage, + }); + } - // Recursively process the children of the current node - if (childNode.children.length > 0) { - if (newPage.pageId === undefined) { - throw new Error('Page ID is undefined'); - } + await this.lockPageIfNeeded(newPage.pageId, lockPage); + } - await this.synchronizeTreeNode({ - node: childNode, - parentPageId: newPage.pageId, + /** + * Main orchestrator for synchronizing a tree node and its children + */ + private async synchronizeTreeNode({ + node, + parentObjectId, + parentObjectType, + lockPage, + }: { + node: TreeNode; + parentObjectId: string; + parentObjectType: ObjectType; + lockPage: boolean; + }): Promise { + let parentPageId: string = parentObjectId; + + switch (parentObjectType) { + case 'unknown': + throw new Error('Parent object type is unknown'); + case 'database': + if (this.getIsRootNode(node)) { + parentPageId = await this.synchronizeRootNode({ + node, + parentObjectId, + parentObjectType, lockPage, }); - } - - if (lockPage && newPage.pageId) { - await this.destinationRepository.setPageLockedStatus({ - pageId: newPage.pageId, - lockStatus: 'locked', + } else { + parentPageId = await this.synchronizeRootNode({ + node: node.children[0], + parentObjectId, + parentObjectType, + lockPage, }); - this.logger.info(`Locked page ${newPage.pageId}`); } - } catch (error) { - if (error instanceof Error) { - this.logger.error(`Failed to synchronize file: ${filePath}`, { - error, + break; + case 'page': + if (this.getIsRootNode(node)) { + parentPageId = await this.synchronizeRootNode({ + node, + parentObjectId, + parentObjectType, + lockPage, }); } + break; + default: + throw new Error('Invalid parent object type'); + } + for (const childNode of node.children) { + try { + await this.synchronizeChildNode({ + childNode, + parentPageId, + lockPage, + }); + } catch (error) { + this.logger.error(`Failed to synchronize file: ${childNode.filepath}`, { + error, + }); throw error; } } } + + private getIsRootNode(node: TreeNode): boolean { + return node.parent === null && !['', undefined].includes(node.filepath); + } } diff --git a/src/domains/notion/NotionPage.ts b/src/domains/notion/NotionPage.ts index 00f4c6a..6eb42aa 100644 --- a/src/domains/notion/NotionPage.ts +++ b/src/domains/notion/NotionPage.ts @@ -9,7 +9,7 @@ import { Icon, PageProperties, PartialCreatePageBodyParameters, -} from '@/domains/notion/types'; +} from '@/domains/notion/types/types'; export class NotionPage implements Page { public readonly pageId?: string; diff --git a/src/domains/notion/constants.ts b/src/domains/notion/constants.ts new file mode 100644 index 0000000..122e2ae --- /dev/null +++ b/src/domains/notion/constants.ts @@ -0,0 +1 @@ +export const MK_NOTES_INTERNAL_ID_PROPERTY_NAME = 'mk-notes-id'; diff --git a/src/domains/notion/types/database.types.ts b/src/domains/notion/types/database.types.ts new file mode 100644 index 0000000..f163506 --- /dev/null +++ b/src/domains/notion/types/database.types.ts @@ -0,0 +1,3 @@ +import { GetDatabaseResponse as _GetDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'; + +export type GetDatabaseResponse = _GetDatabaseResponse; diff --git a/src/domains/notion/types/index.ts b/src/domains/notion/types/index.ts new file mode 100644 index 0000000..1453541 --- /dev/null +++ b/src/domains/notion/types/index.ts @@ -0,0 +1,2 @@ +export * from './database.types'; +export * from './types'; diff --git a/src/domains/notion/types.ts b/src/domains/notion/types/types.ts similarity index 96% rename from src/domains/notion/types.ts rename to src/domains/notion/types/types.ts index 8f6aa0a..9e7f713 100644 --- a/src/domains/notion/types.ts +++ b/src/domains/notion/types/types.ts @@ -1,5 +1,7 @@ import { BlockObjectRequestWithoutChildren as _BlockObjectRequestWithoutChildren, + CreatePageParameters, + DataSourceObjectResponse, PageObjectResponse, PartialUserObjectResponse, } from '@notionhq/client/build/src/api-endpoints'; @@ -72,23 +74,12 @@ type EquationItemRequest = { export type RichTextItemRequest = TextItemRequest | EquationItemRequest; -// Parent Interfaces -export interface ParentPage { - page_id: IdRequest; - type?: 'page_id'; -} - -export interface ParentDatabase { - database_id: IdRequest; - type?: 'database_id'; -} - -export type Parent = ParentPage | ParentDatabase; +export type Parent = CreatePageParameters['parent']; // Property Interfaces export interface TitleProperty { title: Array; - id: string; + id: 'title'; type?: 'title'; } @@ -791,3 +782,16 @@ function isCreatedBy(created_by: unknown): boolean { function isLastEditedBy(last_edited_by: unknown): boolean { return typeof last_edited_by === 'object'; // Assuming PartialUserObjectResponse is an object, refine this if needed } + +// Database Property Definition Types +// These represent the schema/definition of properties in a Notion database + +export type DatabasePropertyDefinition = + DataSourceObjectResponse['properties'][string]; +export type DatabasePropertyType = DatabasePropertyDefinition['type']; + +export interface DatabaseProperty { + name: string; + definition: DatabasePropertyDefinition; + type: DatabasePropertyType; +} diff --git a/src/domains/synchronization/destination.repository.ts b/src/domains/synchronization/destination.repository.ts index 45cfd35..c022fbb 100644 --- a/src/domains/synchronization/destination.repository.ts +++ b/src/domains/synchronization/destination.repository.ts @@ -8,14 +8,18 @@ export interface Page { } export type PageLockedStatus = 'locked' | 'unlocked'; +export type ObjectType = 'page' | 'database' | 'unknown'; + export interface DestinationRepository { createPage: ({ pageElement, - parentPageId, + parentObjectId, + parentObjectType, filePath, }: { pageElement: PageElement; - parentPageId: string; + parentObjectId: string; + parentObjectType: ObjectType; filePath?: string; }) => Promise; updatePage: ({ @@ -28,11 +32,11 @@ export interface DestinationRepository { filePath?: string; }) => Promise; destinationIsAccessible: ({ - parentPageId, + parentObjectId, }: { - parentPageId: string; + parentObjectId: string; }) => Promise; - getPageIdFromPageUrl: ({ pageUrl }: { pageUrl: string }) => string; + getObjectIdFromObjectUrl: ({ objectUrl }: { objectUrl: string }) => string; deleteChildBlocks: ({ parentPageId, }: { @@ -64,4 +68,5 @@ export interface DestinationRepository { }: { pageId: string; }) => Promise; + getObjectType: ({ id }: { id: string }) => Promise; } diff --git a/src/infrastructure/markdown/markdown.parser.ts b/src/infrastructure/markdown/markdown.parser.ts index ba3e118..dbc5e92 100644 --- a/src/infrastructure/markdown/markdown.parser.ts +++ b/src/infrastructure/markdown/markdown.parser.ts @@ -28,8 +28,10 @@ import { HtmlParser } from '@/infrastructure/html'; import { EquationToken, ExtendedToken } from './types'; export interface MarkdownMetadata { + id?: string; title?: string; icon?: string; + properties?: Record; } export class MarkdownParser extends ParserRepository { @@ -423,6 +425,10 @@ export class MarkdownParser extends ParserRepository { const fileMetadata = this.getMetadata(content); + if (fileMetadata.id) { + result.mkNotesInternalId = fileMetadata.id; + } + if (fileMetadata.title) { result.title = fileMetadata.title; } @@ -431,6 +437,10 @@ export class MarkdownParser extends ParserRepository { result.icon = fileMetadata.icon as SupportedEmoji; } + if (fileMetadata.properties && Array.isArray(fileMetadata.properties)) { + result.properties = fileMetadata.properties; + } + return result; } } diff --git a/src/infrastructure/notion/index.ts b/src/infrastructure/notion/index.ts index 078019a..b60c3a6 100644 --- a/src/infrastructure/notion/index.ts +++ b/src/infrastructure/notion/index.ts @@ -1,5 +1,5 @@ export * from '../../domains/notion/NotionPage'; -export * from '../../domains/notion/types'; +export * from '../../domains/notion/types/types'; export * from './file-upload.service'; export * from './notion.converter'; export * from './notion.destination'; diff --git a/src/infrastructure/notion/notion.converter.ts b/src/infrastructure/notion/notion.converter.ts index 7fdbc12..bae8dab 100644 --- a/src/infrastructure/notion/notion.converter.ts +++ b/src/infrastructure/notion/notion.converter.ts @@ -14,6 +14,8 @@ import { LinkElement, ListItemElement, PageElement, + PageElementProperties, + PageElementPropertyValue, QuoteElement, RichTextElement, TableElement, @@ -21,6 +23,7 @@ import { TextElementLevel, ToggleElement, } from '@/domains/elements'; +import { MK_NOTES_INTERNAL_ID_PROPERTY_NAME } from '@/domains/notion/constants'; import { NotionPage } from '@/domains/notion/NotionPage'; import { @@ -28,22 +31,35 @@ import { BlockObjectRequestWithoutChildren, BulletedListItemBlock, CalloutBlock, + CheckboxProperty, CreatePageBodyParameters, + DatabaseProperty, + DatabasePropertyDefinition, + DateProperty, + EmailProperty, EquationBlock, Heading1Block, Heading2Block, Heading3Block, LanguageRequest, + MultiSelectProperty, NumberedListItemBlock, + NumberProperty, + PageProperties, ParagraphBlock, + PhoneNumberProperty, QuoteBlock, RichTextItemRequest, + RichTextProperty, + SelectProperty, + StatusProperty, TableBlock, TableOfContentsBlock, TableRowBlock, TitleProperty, ToggleBlock, -} from '../../domains/notion/types'; + UrlProperty, +} from '../../domains/notion/types/types'; import { NotionFileUploadService } from './file-upload.service'; type PartialCreatePageBodyParameters = Pick< @@ -109,8 +125,418 @@ export class NotionConverterRepository return true; } + // ============================================ + // Property Conversion Functions + // ============================================ + + /** + * Converts a string value to a Notion TitleProperty + */ + private convertToTitleProperty( + value: PageElementPropertyValue + ): TitleProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + return { + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], + }; + } + + /** + * Converts a string value to a Notion RichTextProperty + */ + private convertToRichTextProperty( + value: PageElementPropertyValue + ): RichTextProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + return { + type: 'rich_text', + rich_text: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], + }; + } + + /** + * Converts a string value to a Notion NumberProperty + * Returns null if the value cannot be parsed as a number + */ + private convertToNumberProperty( + value: PageElementPropertyValue + ): NumberProperty | null { + if (typeof value !== 'number') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + const parsed = value; + + if (isNaN(parsed)) { + this.logger.warn( + `Cannot convert "${value}" to number property, skipping` + ); + return null; + } + return { + type: 'number', + number: parsed, + }; + } + + /** + * Converts a string value to a Notion UrlProperty + */ + private convertToUrlProperty(value: PageElementPropertyValue): UrlProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + return { + type: 'url', + url: value || null, + }; + } + + /** + * Converts a string value to a Notion SelectProperty + * The value should match one of the available options in the database property definition + */ + private convertToSelectProperty( + value: PageElementPropertyValue, + propertyDefinition: DatabasePropertyDefinition + ): SelectProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + // Type-narrow to access select options + if (propertyDefinition.type === 'select') { + const { options } = propertyDefinition.select; + const matchingOption = options.find( + (opt) => opt.name.toLowerCase() === value.toLowerCase() + ); + + if (matchingOption) { + return { + type: 'select', + select: { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }, + }; + } + } + + // If no matching option found, create a new one with the provided value + // Notion will create the option if it doesn't exist + return { + type: 'select', + select: { + id: '', + name: value, + }, + }; + } + + /** + * Converts a string value to a Notion MultiSelectProperty + * The value should be a comma-separated list of option names + */ + private convertToMultiSelectProperty( + value: PageElementPropertyValue, + propertyDefinition: DatabasePropertyDefinition + ): MultiSelectProperty { + if (typeof value !== 'string' && !Array.isArray(value)) { + throw new Error(`Invalid value type: ${typeof value}`); + } + + const values = value instanceof Array ? value : [value]; + + // Type-narrow to access multi_select options + const options = + propertyDefinition.type === 'multi_select' + ? propertyDefinition.multi_select.options + : []; + + const multiSelectOptions = values.map((val) => { + const matchingOption = options.find( + (opt) => opt.name.toLowerCase() === String(val).toLowerCase() + ); + + if (matchingOption) { + return { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }; + } + + // Create new option if not found + return { + id: '', + name: String(val), + }; + }); + + return { + type: 'multi_select', + multi_select: multiSelectOptions, + }; + } + + /** + * Converts a string value to a Notion DateProperty + * Supports ISO 8601 date strings (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss) + */ + private convertToDateProperty( + value: PageElementPropertyValue + ): DateProperty | null { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + // Try to parse the date + const date = new Date(value); + if (isNaN(date.getTime())) { + this.logger.warn(`Cannot convert "${value}" to date property, skipping`); + return null; + } + + // Format as ISO string (Notion expects ISO 8601 format) + return { + type: 'date', + date: value, // Pass the original value if it's already in ISO format + }; + } + + /** + * Converts a string value to a Notion CheckboxProperty + * Accepts: "true", "false", "yes", "no", "1", "0" + */ + private convertToCheckboxProperty( + value: PageElementPropertyValue + ): CheckboxProperty { + if (typeof value !== 'string' && typeof value !== 'boolean') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + if (typeof value === 'boolean') { + return { + type: 'checkbox', + checkbox: value, + }; + } + + const normalizedValue = value.toLowerCase().trim(); + const trueValues = ['true', 'yes', '1', 'on', 'checked']; + const isChecked = trueValues.includes(normalizedValue); + + return { + type: 'checkbox', + checkbox: isChecked, + }; + } + + /** + * Converts a string value to a Notion EmailProperty + */ + private convertToEmailProperty( + value: PageElementPropertyValue + ): EmailProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + return { + type: 'email', + email: value || null, + }; + } + + /** + * Converts a string value to a Notion PhoneNumberProperty + */ + private convertToPhoneNumberProperty( + value: PageElementPropertyValue + ): PhoneNumberProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + return { + type: 'phone_number', + phone_number: value || null, + }; + } + + /** + * Converts a string value to a Notion StatusProperty + * The value should match one of the available status options in the database property definition + */ + private convertToStatusProperty( + value: PageElementPropertyValue, + propertyDefinition: DatabasePropertyDefinition + ): StatusProperty { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + + // Type-narrow to access status options + if (propertyDefinition.type === 'status') { + const { options } = propertyDefinition.status; + const matchingOption = options.find( + (opt) => opt.name.toLowerCase() === value.toLowerCase() + ); + + if (matchingOption) { + return { + type: 'status', + status: { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }, + }; + } + } + + // If no matching option found, use the value as-is + // Note: Notion may reject this if the status doesn't exist + return { + type: 'status', + status: { + id: '', + name: value, + }, + }; + } + + /** + * Converts a PageElementProperty to the appropriate Notion property based on the database property definition + */ + private convertPropertyValue( + value: PageElementPropertyValue, + propertyDefinition: DatabasePropertyDefinition + ): PageProperties[string] | null { + if (value instanceof Array) { + if (propertyDefinition.type === 'multi_select') { + return this.convertToMultiSelectProperty(value, propertyDefinition); + } + this.logger.warn( + `Unsupported array value for property type "${propertyDefinition.type}"` + ); + + return null; + } + + switch (propertyDefinition.type) { + case 'title': + return this.convertToTitleProperty(value); + case 'rich_text': + return this.convertToRichTextProperty(value); + case 'number': + return this.convertToNumberProperty(value); + case 'url': + return this.convertToUrlProperty(value); + case 'select': + return this.convertToSelectProperty(value, propertyDefinition); + case 'multi_select': + return this.convertToMultiSelectProperty(value, propertyDefinition); + case 'date': + return this.convertToDateProperty(value); + case 'checkbox': + return this.convertToCheckboxProperty(value); + case 'email': + return this.convertToEmailProperty(value); + case 'phone_number': + return this.convertToPhoneNumberProperty(value); + case 'status': + return this.convertToStatusProperty(value, propertyDefinition); + case 'people': + case 'files': + case 'relation': + case 'formula': + case 'rollup': + case 'created_time': + case 'created_by': + case 'last_edited_time': + case 'last_edited_by': + case 'unique_id': + this.logger.warn( + `Property type "${propertyDefinition.type}" cannot be converted from string, skipping property "${propertyDefinition.name}"` + ); + return null; + } + } + + /** + * Converts page element properties to Notion PageProperties based on database property definitions + * + * @param properties - Array of PageElementProperties from the markdown frontmatter + * @param notionPropertyDefinitions - Array of database property definitions from Notion + * @returns PageProperties object ready to be used in Notion API calls + */ + private convertPageElementProperties( + properties?: PageElementProperties[], + notionProperties: DatabaseProperty[] = [] + ): PageProperties { + const result: PageProperties = {}; + + // Create a map of property definitions by name for quick lookup + const definitionMap = new Map(); + for (const property of notionProperties) { + definitionMap.set(property.name, property.definition); + } + + // Convert each page element property + for (const property of properties ?? []) { + const definition = definitionMap.get(property.name); + + if (!definition) { + this.logger.warn( + `No matching Notion property definition found for "${property.name}", skipping` + ); + continue; + } + + const convertedValue = this.convertPropertyValue( + property.value, + definition + ); + + if (convertedValue !== null) { + // Use the original definition name to preserve casing + result[definition.name] = convertedValue; + } + } + + return result; + } + private async convertPageElement( - element: PageElement + element: PageElement, + notionPropertyDefinitions: DatabaseProperty[] = [] ): Promise { const title: TitleProperty = { id: 'title', @@ -126,10 +552,26 @@ export class NotionConverterRepository ], }; + const elementProperties = element.properties ?? []; + + if (element.mkNotesInternalId) { + elementProperties.push({ + name: MK_NOTES_INTERNAL_ID_PROPERTY_NAME, + value: element.mkNotesInternalId, + }); + } + + // Convert page element properties to Notion properties + const convertedProperties = this.convertPageElementProperties( + elementProperties, + notionPropertyDefinitions + ); + const result: PartialCreatePageBodyParameters = { children: [], properties: { title, + ...convertedProperties, }, }; @@ -187,8 +629,14 @@ export class NotionConverterRepository return null; } } - async convertFromElement(element: PageElement): Promise { - const notionPageInput = await this.convertPageElement(element); + async convertFromElement( + element: PageElement, + availableProperties: DatabaseProperty[] = [] + ): Promise { + const notionPageInput = await this.convertPageElement( + element, + availableProperties + ); return NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); } diff --git a/src/infrastructure/notion/notion.destination.test.ts b/src/infrastructure/notion/notion.destination.test.ts index f55aa8a..f591d94 100644 --- a/src/infrastructure/notion/notion.destination.test.ts +++ b/src/infrastructure/notion/notion.destination.test.ts @@ -54,7 +54,7 @@ describe('NotionDestinationRepository', () => { jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue({ id: 'page-id', object: 'page' }); const result = await repository.destinationIsAccessible({ - parentPageId: 'page-id', + parentObjectId: 'page-id', }); expect(result).toBe(true); @@ -67,7 +67,7 @@ describe('NotionDestinationRepository', () => { jest.spyOn(mockClient.pages,'retrieve').mockRejectedValue(new Error('Not found')); const result = await repository.destinationIsAccessible({ - parentPageId: 'invalid-id', + parentObjectId: 'invalid-id', }); expect(result).toBe(false); @@ -135,7 +135,8 @@ describe('NotionDestinationRepository', () => { jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue({ results: [] } as unknown as ListBlockChildrenResponse); const result = await repository.createPage({ - parentPageId: 'parent-id', + parentObjectId: 'parent-id', + parentObjectType: 'page', pageElement, }); @@ -260,45 +261,45 @@ describe('NotionDestinationRepository', () => { describe('getPageIdFromPageUrl', () => { it('should extract the page ID from a standard Notion URL', () => { const pageUrl = 'https://www.notion.so/workspace/Test-Page-12345678901234567890123456789012'; - const result = repository.getPageIdFromPageUrl({ pageUrl }); + const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); expect(result).toBe('12345678901234567890123456789012'); }); it('should extract the page ID from a URL with multiple hyphens', () => { const pageUrl = 'https://www.notion.so/workspace/My-Test-Page-With-Many-Hyphens-12345678901234567890123456789012'; - const result = repository.getPageIdFromPageUrl({ pageUrl }); + const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); expect(result).toBe('12345678901234567890123456789012'); }); it('should extract the page ID from a URL without a workspace name', () => { const pageUrl = 'https://www.notion.so/Test-Page-12345678901234567890123456789012'; - const result = repository.getPageIdFromPageUrl({ pageUrl }); + const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); expect(result).toBe('12345678901234567890123456789012'); }); it('should throw an error for a URL without a page ID', () => { const pageUrl = 'https://www.notion.so/workspace/Test-Page'; - expect(() => repository.getPageIdFromPageUrl({ pageUrl })).toThrow('Invalid Notion URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL'); }); it('should throw an error for a URL with an invalid page ID length', () => { const pageUrl = 'https://www.notion.so/workspace/Test-Page-123456'; - expect(() => repository.getPageIdFromPageUrl({ pageUrl })).toThrow('Invalid Notion URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL'); }); it('should throw an error for a non-Notion URL', () => { const pageUrl = 'https://example.com/some-page'; - expect(() => repository.getPageIdFromPageUrl({ pageUrl })).toThrow('Invalid Notion URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL'); }); it('should throw an error when the URL is a Notion Database', () => { const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?v=7d43042815524daa9c5c3a7a4f8e1fe4&pvs=4'; - expect(() => repository.getPageIdFromPageUrl({ pageUrl })).toThrow('Notion Databases are not supported yet. Please use a Notion Page URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Notion Databases are not supported yet. Please use a Notion Page URL'); }); it('should extract the page ID from a URL with direct ID and query parameters', () => { const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?pvs=4'; - const result = repository.getPageIdFromPageUrl({ pageUrl }); + const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); expect(result).toBe('16d4754ea1e980d1a2fdc2ab5fa4dfaf'); }); }); diff --git a/src/infrastructure/notion/notion.destination.ts b/src/infrastructure/notion/notion.destination.ts index 3b65e31..4245ae1 100644 --- a/src/infrastructure/notion/notion.destination.ts +++ b/src/infrastructure/notion/notion.destination.ts @@ -2,6 +2,8 @@ import { Client, isFullPage, LogLevel } from '@notionhq/client'; import { BlockObjectResponse, CreatePageParameters, + GetDatabaseResponse, + GetDataSourceResponse, PageObjectResponse, PartialBlockObjectResponse, UpdatePageParameters, @@ -16,13 +18,16 @@ import { import { NotionPage } from '@/domains/notion/NotionPage'; import { DestinationRepository, + ObjectType, PageLockedStatus, } from '@/domains/synchronization/destination.repository'; import { BlockObjectRequest, BlockObjectRequestWithoutChildren, + DatabaseProperty, Icon, + Parent, TitleProperty, } from '../../domains/notion/types'; import { NotionConverterRepository } from './notion.converter'; @@ -83,50 +88,39 @@ export class NotionDestinationRepository } } - getPageIdFromPageUrl({ pageUrl }: { pageUrl: string }): string { - const urlObj = new URL(pageUrl); + getObjectIdFromObjectUrl({ objectUrl }: { objectUrl: string }): string { + const urlObj = new URL(objectUrl); - const pathSegments = urlObj.pathname.split('-'); - let lastSegment = pathSegments[pathSegments.length - 1]; + // Notion IDs are 32-character hexadecimal strings (UUID without dashes) + // They can be embedded in path segments like "MK-Notes-4dd0bd3dc73648a9a55dcf05dd03080f" + const notionIdRegex = /[a-f0-9]{32}/gi; + const matches = urlObj.pathname.match(notionIdRegex); - /** - * If the URL has a query parameter `v`, it's becase it's a Notion Database - * Unfortunatly, for now, mk-notes doesn't support Notion Databases - **/ - if (urlObj.searchParams.has('v')) { - throw new Error( - 'Notion Databases are not supported yet. Please use a Notion Page URL' - ); - } - - if (lastSegment.startsWith('/')) { - lastSegment = lastSegment.slice(1); - } - - const [lastSegmentWithoutQueryParams] = lastSegment.split('?'); - - if (!lastSegmentWithoutQueryParams) { - throw new Error('Invalid Notion URL'); + if (!matches || matches.length === 0) { + throw new Error('Invalid Notion URL: No valid Notion ID found'); } - if (lastSegmentWithoutQueryParams.length !== 32) { - throw new Error('Invalid Notion URL'); - } - - return lastSegmentWithoutQueryParams; + // Return the last match (closest to the end of the URL path) + return matches[matches.length - 1]; } async destinationIsAccessible({ - parentPageId, + parentObjectId, }: { - parentPageId: string; + parentObjectId: string; }): Promise { try { - await this.getPage({ pageId: parentPageId }); + await this.getPage({ pageId: parentObjectId }); return true; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { - return false; + try { + await this.getDatabaseById({ databaseId: parentObjectId }); + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_err) { + return false; + } } } @@ -153,12 +147,15 @@ export class NotionDestinationRepository isLocked: pageObjectResponse.is_locked ?? false, }); } + async createPage({ - parentPageId, + parentObjectId, + parentObjectType, pageElement, filePath, }: { - parentPageId: string; + parentObjectId: string; + parentObjectType: ObjectType; pageElement: PageElement; filePath?: string; }): Promise { @@ -167,13 +164,51 @@ export class NotionDestinationRepository this.notionConverter.setCurrentFilePath(filePath); } - const notionPage = - await this.notionConverter.convertFromElement(pageElement); + if (parentObjectType === 'unknown') { + throw new Error('Unknown parent object type'); + } + + let parent: Parent | undefined; + const availableProperties: DatabaseProperty[] = []; + + if (parentObjectType === 'page') { + parent = { type: 'page_id', page_id: parentObjectId }; + } + + if (parentObjectType === 'database') { + const database = await this.getDatabaseById({ + databaseId: parentObjectId, + }); + + if (!('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + + const datasource = await this.getDatasourceByDatasourceId({ + datasourceId: database.data_sources[0].id, + }); + + parent = { type: 'data_source_id', data_source_id: datasource.id }; + + availableProperties.push( + ...Object.entries(datasource.properties).map(([name, property]) => ({ + name, + definition: property, + type: property.type, + })) + ); + } + + const notionPage = await this.notionConverter.convertFromElement( + pageElement, + availableProperties + ); + const NOTION_BLOCK_LIMIT = 100; // First create the page without children const { id: notionPageId } = await this.client.pages.create({ - parent: { type: 'page_id', page_id: parentPageId }, + parent, properties: notionPage.properties as CreatePageParameters['properties'], icon: notionPage.icon, children: [], // Create page without children initially @@ -419,4 +454,42 @@ export class NotionDestinationRepository return isLocked ? 'locked' : 'unlocked'; } + + async getDatabaseById({ + databaseId, + }: { + databaseId: string; + }): Promise { + return this.client.databases.retrieve({ + database_id: databaseId, + }); + } + + async getObjectType({ + id, + }: { + id: string; + }): Promise<'page' | 'database' | 'unknown'> { + try { + await this.client.pages.retrieve({ page_id: id }); + return 'page'; + } catch { + try { + await this.client.databases.retrieve({ database_id: id }); + return 'database'; + } catch { + return 'unknown'; + } + } + } + + async getDatasourceByDatasourceId({ + datasourceId, + }: { + datasourceId: string; + }): Promise { + return this.client.dataSources.retrieve({ + data_source_id: datasourceId, + }); + } } diff --git a/src/infrastructure/notion/utils.ts b/src/infrastructure/notion/utils.ts index cc5177f..7c41a3c 100644 --- a/src/infrastructure/notion/utils.ts +++ b/src/infrastructure/notion/utils.ts @@ -3,7 +3,7 @@ import { BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints'; import { BlockObjectRequest, BlockObjectRequestWithoutChildren, -} from '../../domains/notion/types'; +} from '../../domains/notion/types/types'; export const normalizeBlock = ( block: diff --git a/yarn.lock b/yarn.lock index 64e7225..c8dc940 100644 --- a/yarn.lock +++ b/yarn.lock @@ -711,10 +711,10 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@notionhq/client@5.1.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-5.1.0.tgz#b841b185e9a0cc1159d1a3f6ae12ed5a29ee96de" - integrity sha512-YYVjXYk1XwKQ4XIh+iGjaaXOGHxaDgB3UaGnDMyrZ3X9UiYQsZpzPIvTuhvp97os8a5W5kTQFsyq77+I+COOVQ== +"@notionhq/client@5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-5.4.0.tgz#5f10af391730c1755dd7b821298287117aff73f3" + integrity sha512-SJsprS26S0Wi9CoTQp4vC8/nPpAIo1gMB4H7aJ2E/k0fWnNGIEAg984KwtzK6h9ZGaPcEaryVRSz1VVClJcVUw== "@open-draft/deferred-promise@^2.2.0": version "2.2.0" From 556acbff246d9e88864f46937f5b516f8e3d72a4 Mon Sep 17 00:00:00 2001 From: Myastr0 <14167316+Myastr0@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:25:01 +0100 Subject: [PATCH 2/6] feat(wip): wip --- preview/index.js | 1545 ++++++++++++++++++++++++++++++++++------------ sync/index.js | 1545 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 2334 insertions(+), 756 deletions(-) diff --git a/preview/index.js b/preview/index.js index 1a9c764..9937109 100644 --- a/preview/index.js +++ b/preview/index.js @@ -4563,6 +4563,18 @@ class Client { auth: args === null || args === void 0 ? void 0 : args.auth, }); }, + /** + * List page templates that are available for a data source + */ + listTemplates: (args) => { + return this.request({ + path: api_endpoints_1.listDataSourceTemplates.path(args), + method: api_endpoints_1.listDataSourceTemplates.method, + query: (0, utils_1.pick)(args, api_endpoints_1.listDataSourceTemplates.queryParams), + body: (0, utils_1.pick)(args, api_endpoints_1.listDataSourceTemplates.bodyParams), + auth: args === null || args === void 0 ? void 0 : args.auth, + }); + }, }; this.pages = { /** @@ -4983,7 +4995,7 @@ exports["default"] = Client; // cspell:disable-file // Note: This is a generated file. DO NOT EDIT! Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.oauthIntrospect = exports.oauthRevoke = exports.oauthToken = exports.getFileUpload = exports.completeFileUpload = exports.sendFileUpload = exports.listFileUploads = exports.createFileUpload = exports.getComment = exports.listComments = exports.createComment = exports.search = exports.createDatabase = exports.updateDatabase = exports.getDatabase = exports.createDataSource = exports.queryDataSource = exports.updateDataSource = exports.getDataSource = exports.appendBlockChildren = exports.listBlockChildren = exports.deleteBlock = exports.updateBlock = exports.getBlock = exports.getPageProperty = exports.updatePage = exports.getPage = exports.createPage = exports.listUsers = exports.getUser = exports.getSelf = void 0; +exports.movePage = exports.oauthIntrospect = exports.oauthRevoke = exports.oauthToken = exports.getFileUpload = exports.completeFileUpload = exports.sendFileUpload = exports.listFileUploads = exports.createFileUpload = exports.getComment = exports.listComments = exports.createComment = exports.search = exports.createDatabase = exports.updateDatabase = exports.getDatabase = exports.listDataSourceTemplates = exports.createDataSource = exports.queryDataSource = exports.updateDataSource = exports.getDataSource = exports.appendBlockChildren = exports.listBlockChildren = exports.deleteBlock = exports.updateBlock = exports.getBlock = exports.getPageProperty = exports.updatePage = exports.getPage = exports.createPage = exports.listUsers = exports.getUser = exports.getSelf = void 0; /** * Retrieve your token's bot user */ @@ -5021,7 +5033,15 @@ exports.createPage = { method: "post", pathParams: [], queryParams: [], - bodyParams: ["parent", "properties", "icon", "cover", "content", "children"], + bodyParams: [ + "parent", + "properties", + "icon", + "cover", + "content", + "children", + "template", + ], path: () => `pages`, }; /** @@ -5046,6 +5066,8 @@ exports.updatePage = { "icon", "cover", "is_locked", + "template", + "erase_content", "archived", "in_trash", ], @@ -5177,6 +5199,7 @@ exports.queryDataSource = { "page_size", "archived", "in_trash", + "result_type", ], path: (p) => `data_sources/${p.data_source_id}/query`, }; @@ -5190,6 +5213,16 @@ exports.createDataSource = { bodyParams: ["parent", "properties", "title", "icon"], path: () => `data_sources`, }; +/** + * List templates in a data source + */ +exports.listDataSourceTemplates = { + method: "get", + pathParams: ["data_source_id"], + queryParams: ["name", "start_cursor", "page_size"], + bodyParams: [], + path: (p) => `data_sources/${p.data_source_id}/templates`, +}; /** * Retrieve a database */ @@ -5376,6 +5409,16 @@ exports.oauthIntrospect = { bodyParams: ["token"], path: () => `oauth/introspect`, }; +/** + * Move a page + */ +exports.movePage = { + method: "post", + pathParams: ["page_id"], + queryParams: [], + bodyParams: ["parent"], + path: (p) => `pages/${p.page_id}/move`, +}; //# sourceMappingURL=api-endpoints.js.map /***/ }), @@ -5608,6 +5651,8 @@ function isAPIErrorCode(code) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.iteratePaginatedAPI = iteratePaginatedAPI; exports.collectPaginatedAPI = collectPaginatedAPI; +exports.iterateDataSourceTemplates = iterateDataSourceTemplates; +exports.collectDataSourceTemplates = collectDataSourceTemplates; exports.isFullBlock = isFullBlock; exports.isFullPage = isFullPage; exports.isFullDataSource = isFullDataSource; @@ -5677,6 +5722,55 @@ async function collectPaginatedAPI(listFn, firstPageArgs) { } return results; } +/** + * Returns an async iterator over data source templates. + * + * Example (given a notion Client called `notion`): + * + * ``` + * for await (const template of iterateDataSourceTemplates(notion, { + * data_source_id: dataSourceId, + * })) { + * console.log(template.name, template.is_default) + * } + * ``` + * + * @param client A Notion client instance. + * @param args Arguments including the data_source_id and optional start_cursor. + */ +async function* iterateDataSourceTemplates(client, args) { + let nextCursor = args.start_cursor; + do { + const response = await client.dataSources.listTemplates({ + ...args, + start_cursor: nextCursor, + }); + yield* response.templates; + nextCursor = response.next_cursor; + } while (nextCursor); +} +/** + * Collect all data source templates into an in-memory array. + * + * Example (given a notion Client called `notion`): + * + * ``` + * const templates = await collectDataSourceTemplates(notion, { + * data_source_id: dataSourceId, + * }) + * // Do something with templates. + * ``` + * + * @param client A Notion client instance. + * @param args Arguments including the data_source_id and optional start_cursor. + */ +async function collectDataSourceTemplates(client, args) { + const results = []; + for await (const template of iterateDataSourceTemplates(client, args)) { + results.push(template); + } + return results; +} /** * @returns `true` if `response` is a full `BlockObjectResponse`. */ @@ -5854,7 +5948,7 @@ function extractBlockId(urlWithBlock) { * @packageDocumentation */ Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.extractBlockId = exports.extractPageId = exports.extractDatabaseId = exports.extractNotionId = exports.isFullPageOrDataSource = exports.isFullComment = exports.isFullUser = exports.isFullPage = exports.isFullDatabase = exports.isFullDataSource = exports.isFullBlock = exports.iteratePaginatedAPI = exports.collectPaginatedAPI = exports.isNotionClientError = exports.RequestTimeoutError = exports.UnknownHTTPResponseError = exports.APIResponseError = exports.ClientErrorCode = exports.APIErrorCode = exports.LogLevel = exports.Client = void 0; +exports.extractBlockId = exports.extractPageId = exports.extractDatabaseId = exports.extractNotionId = exports.isFullPageOrDataSource = exports.isFullComment = exports.isFullUser = exports.isFullPage = exports.isFullDatabase = exports.isFullDataSource = exports.isFullBlock = exports.iterateDataSourceTemplates = exports.collectDataSourceTemplates = exports.iteratePaginatedAPI = exports.collectPaginatedAPI = exports.isNotionClientError = exports.RequestTimeoutError = exports.UnknownHTTPResponseError = exports.APIResponseError = exports.ClientErrorCode = exports.APIErrorCode = exports.LogLevel = exports.Client = void 0; var Client_1 = __nccwpck_require__(9711); Object.defineProperty(exports, "Client", ({ enumerable: true, get: function () { return Client_1.default; } })); var logging_1 = __nccwpck_require__(2743); @@ -5870,6 +5964,8 @@ Object.defineProperty(exports, "isNotionClientError", ({ enumerable: true, get: var helpers_1 = __nccwpck_require__(5847); Object.defineProperty(exports, "collectPaginatedAPI", ({ enumerable: true, get: function () { return helpers_1.collectPaginatedAPI; } })); Object.defineProperty(exports, "iteratePaginatedAPI", ({ enumerable: true, get: function () { return helpers_1.iteratePaginatedAPI; } })); +Object.defineProperty(exports, "collectDataSourceTemplates", ({ enumerable: true, get: function () { return helpers_1.collectDataSourceTemplates; } })); +Object.defineProperty(exports, "iterateDataSourceTemplates", ({ enumerable: true, get: function () { return helpers_1.iterateDataSourceTemplates; } })); Object.defineProperty(exports, "isFullBlock", ({ enumerable: true, get: function () { return helpers_1.isFullBlock; } })); Object.defineProperty(exports, "isFullDataSource", ({ enumerable: true, get: function () { return helpers_1.isFullDataSource; } })); Object.defineProperty(exports, "isFullDatabase", ({ enumerable: true, get: function () { return helpers_1.isFullDatabase; } })); @@ -75012,186 +75108,15 @@ if (require.main === require.cache[eval('__filename')]) { /***/ }), -/***/ 3739: -/***/ ((__unused_webpack_module, exports) => { +/***/ 7512: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.EquationElement = exports.TableOfContentsElement = exports.ToggleElement = exports.HtmlElement = exports.LinkElement = exports.ImageElement = exports.DividerElement = exports.CalloutElement = exports.SpecialCalloutType = exports.CodeElement = exports.isElementCodeLanguage = exports.ElementCodeLanguage = exports.QuoteElement = exports.TextElement = exports.TextElementStyle = exports.TextElementLevel = exports.TableElement = exports.ListItemElement = exports.FileElement = exports.PageElement = exports.Element = exports.ElementType = void 0; -var ElementType; -(function (ElementType) { - ElementType["Page"] = "page"; - ElementType["File"] = "file"; - ElementType["Text"] = "text"; - ElementType["Quote"] = "quote"; - ElementType["Code"] = "code"; - ElementType["Callout"] = "callout"; - ElementType["Divider"] = "divider"; - ElementType["Image"] = "image"; - ElementType["Link"] = "link"; - ElementType["Table"] = "table"; - ElementType["ListItem"] = "list-item"; - ElementType["Html"] = "html"; - ElementType["Toggle"] = "toggle"; - ElementType["Equation"] = "equation"; - ElementType["TableOfContents"] = "table-of-contents"; -})(ElementType || (exports.ElementType = ElementType = {})); -class Element { - type; - constructor(type) { - this.type = type; - } -} -exports.Element = Element; -/** - * Element that represents the concept of page (in knowledge management systems) - */ -class PageElement extends Element { - title; - icon; - content; - constructor({ title, icon, content = [], }) { - super(ElementType.Page); - this.title = title; - this.icon = icon; - this.content = content; - } - getIcon() { - return this.icon; - } - addElementToBeginning(element) { - this.content.unshift(element); - } - addElementToEnd(element) { - this.content.push(element); - } -} -exports.PageElement = PageElement; -/** - * Element that represents a file in the system - */ -class FileElement extends Element { - content; - name; - creationDate; - lastUpdatedDate; - extension; - constructor({ content, name, creationDate, lastUpdatedDate, extension, }) { - super(ElementType.File); - this.content = content; - this.name = name; - this.creationDate = creationDate; - this.lastUpdatedDate = lastUpdatedDate; - this.extension = extension; - } -} -exports.FileElement = FileElement; -class ListItemElement extends Element { - listType; - text; - children; - constructor({ listType, text, children, }) { - super(ElementType.ListItem); - this.listType = listType; - this.text = text; - this.children = children; - } -} -exports.ListItemElement = ListItemElement; -class TableElement extends Element { - rows; - constructor({ rows }) { - super(ElementType.Table); - this.rows = rows; - } -} -exports.TableElement = TableElement; -var TextElementLevel; -(function (TextElementLevel) { - TextElementLevel["Heading1"] = "heading_1"; - TextElementLevel["Heading2"] = "heading_2"; - TextElementLevel["Heading3"] = "heading_3"; - TextElementLevel["Heading4"] = "heading_4"; - TextElementLevel["Heading5"] = "heading_5"; - TextElementLevel["Heading6"] = "heading_6"; - TextElementLevel["Paragraph"] = "paragraph"; -})(TextElementLevel || (exports.TextElementLevel = TextElementLevel = {})); -var TextElementStyle; -(function (TextElementStyle) { - TextElementStyle["Italic"] = "italic"; - TextElementStyle["Bold"] = "bold"; - TextElementStyle["Strikethrough"] = "strikethrough"; - TextElementStyle["Underline"] = "underline"; -})(TextElementStyle || (exports.TextElementStyle = TextElementStyle = {})); -class TextElement extends Element { - text; - level; - styles = { - italic: false, - bold: false, - strikethrough: false, - underline: false, - code: false, - }; - constructor({ text, level = TextElementLevel.Paragraph, styles, }) { - super(ElementType.Text); - this.text = text; - this.level = level; - this.styles.bold = styles?.bold || false; - this.styles.italic = styles?.italic || false; - this.styles.strikethrough = styles?.strikethrough || false; - this.styles.underline = styles?.underline || false; - this.styles.code = styles?.code || false; - } -} -exports.TextElement = TextElement; -class QuoteElement extends Element { - text; - constructor({ text }) { - super(ElementType.Quote); - this.text = text; - } -} -exports.QuoteElement = QuoteElement; -var ElementCodeLanguage; -(function (ElementCodeLanguage) { - ElementCodeLanguage["JavaScript"] = "javascript"; - ElementCodeLanguage["TypeScript"] = "typescript"; - ElementCodeLanguage["Python"] = "python"; - ElementCodeLanguage["Java"] = "java"; - ElementCodeLanguage["CSharp"] = "csharp"; - ElementCodeLanguage["CPlusPlus"] = "c++"; - ElementCodeLanguage["Go"] = "go"; - ElementCodeLanguage["Ruby"] = "ruby"; - ElementCodeLanguage["Swift"] = "swift"; - ElementCodeLanguage["Kotlin"] = "kotlin"; - ElementCodeLanguage["Rust"] = "rust"; - ElementCodeLanguage["Shell"] = "shell"; - ElementCodeLanguage["Scala"] = "scala"; - ElementCodeLanguage["SQL"] = "sql"; - ElementCodeLanguage["HTML"] = "html"; - ElementCodeLanguage["CSS"] = "css"; - ElementCodeLanguage["JSON"] = "json"; - ElementCodeLanguage["YAML"] = "yaml"; - ElementCodeLanguage["Markdown"] = "markdown"; - ElementCodeLanguage["Mermaid"] = "mermaid"; - ElementCodeLanguage["PlainText"] = "plaintext"; -})(ElementCodeLanguage || (exports.ElementCodeLanguage = ElementCodeLanguage = {})); -const isElementCodeLanguage = (value) => { - return Object.values(ElementCodeLanguage).includes(value); -}; -exports.isElementCodeLanguage = isElementCodeLanguage; -class CodeElement extends Element { - language; - text; - constructor({ language, text, }) { - super(ElementType.Code); - this.language = language; - this.text = text; - } -} -exports.CodeElement = CodeElement; +exports.CalloutElement = exports.SpecialCalloutType = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); const specialCalloutRegex = // eslint-disable-next-line no-useless-escape /^\s*\[\!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)/ims; @@ -75203,7 +75128,7 @@ var SpecialCalloutType; SpecialCalloutType["Warning"] = "warning"; SpecialCalloutType["Caution"] = "caution"; })(SpecialCalloutType || (exports.SpecialCalloutType = SpecialCalloutType = {})); -class CalloutElement extends Element { +class CalloutElement extends Element_class_1.Element { text; icon; calloutType; @@ -75211,7 +75136,7 @@ class CalloutElement extends Element { return specialCalloutRegex.test(text.trim()); } constructor({ icon, text }) { - super(ElementType.Callout); + super(types_1.ElementType.Callout); this.icon = icon; this.text = text; const { text: parsedText, calloutType } = this.getSpecialCalloutTypeAndText(text); @@ -75257,71 +75182,108 @@ class CalloutElement extends Element { } } exports.CalloutElement = CalloutElement; -class DividerElement extends Element { - constructor() { - super(ElementType.Divider); - } -} -exports.DividerElement = DividerElement; -class ImageElement extends Element { - base64; - url; - caption; - name; - creationDate; - lastUpdatedDate; - extension; - filepath; - constructor({ base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { - super(ElementType.Image); - this.name = name; - this.creationDate = creationDate; - this.lastUpdatedDate = lastUpdatedDate; - this.extension = extension; - this.base64 = base64; - this.url = url; - this.caption = caption; - this.filepath = filepath; - } -} -exports.ImageElement = ImageElement; -class LinkElement extends Element { - url; + + +/***/ }), + +/***/ 9769: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.CodeElement = exports.isElementCodeLanguage = exports.ElementCodeLanguage = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +var ElementCodeLanguage; +(function (ElementCodeLanguage) { + ElementCodeLanguage["JavaScript"] = "javascript"; + ElementCodeLanguage["TypeScript"] = "typescript"; + ElementCodeLanguage["Python"] = "python"; + ElementCodeLanguage["Java"] = "java"; + ElementCodeLanguage["CSharp"] = "csharp"; + ElementCodeLanguage["CPlusPlus"] = "c++"; + ElementCodeLanguage["Go"] = "go"; + ElementCodeLanguage["Ruby"] = "ruby"; + ElementCodeLanguage["Swift"] = "swift"; + ElementCodeLanguage["Kotlin"] = "kotlin"; + ElementCodeLanguage["Rust"] = "rust"; + ElementCodeLanguage["Shell"] = "shell"; + ElementCodeLanguage["Scala"] = "scala"; + ElementCodeLanguage["SQL"] = "sql"; + ElementCodeLanguage["HTML"] = "html"; + ElementCodeLanguage["CSS"] = "css"; + ElementCodeLanguage["JSON"] = "json"; + ElementCodeLanguage["YAML"] = "yaml"; + ElementCodeLanguage["Markdown"] = "markdown"; + ElementCodeLanguage["Mermaid"] = "mermaid"; + ElementCodeLanguage["PlainText"] = "plaintext"; +})(ElementCodeLanguage || (exports.ElementCodeLanguage = ElementCodeLanguage = {})); +const isElementCodeLanguage = (value) => { + return Object.values(ElementCodeLanguage).includes(value); +}; +exports.isElementCodeLanguage = isElementCodeLanguage; +class CodeElement extends Element_class_1.Element { + language; text; - caption; - constructor({ url, text, caption, }) { - super(ElementType.Link); - this.url = url; + constructor({ language, text, }) { + super(types_1.ElementType.Code); + this.language = language; this.text = text; - this.caption = caption; - } -} -exports.LinkElement = LinkElement; -class HtmlElement extends Element { - html; - constructor({ html }) { - super(ElementType.Html); - this.html = html; } } -exports.HtmlElement = HtmlElement; -class ToggleElement extends Element { - title; - children; - constructor({ title, children }) { - super(ElementType.Toggle); - this.title = title; - this.children = children; +exports.CodeElement = CodeElement; + + +/***/ }), + +/***/ 1983: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.DividerElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class DividerElement extends Element_class_1.Element { + constructor() { + super(types_1.ElementType.Divider); } } -exports.ToggleElement = ToggleElement; -class TableOfContentsElement extends Element { - constructor() { - super(ElementType.TableOfContents); +exports.DividerElement = DividerElement; + + +/***/ }), + +/***/ 5238: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.Element = void 0; +class Element { + type; + constructor(type) { + this.type = type; } } -exports.TableOfContentsElement = TableOfContentsElement; -class EquationElement extends Element { +exports.Element = Element; + + +/***/ }), + +/***/ 1020: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.EquationElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class EquationElement extends Element_class_1.Element { equation; styles = { italic: false, @@ -75331,7 +75293,7 @@ class EquationElement extends Element { code: false, }; constructor({ equation, styles, }) { - super(ElementType.Equation); + super(types_1.ElementType.Equation); this.equation = equation; this.styles.bold = styles?.bold || false; this.styles.italic = styles?.italic || false; @@ -75343,6 +75305,391 @@ class EquationElement extends Element { exports.EquationElement = EquationElement; +/***/ }), + +/***/ 5320: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.FileElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +/** + * Element that represents a file in the system + */ +class FileElement extends Element_class_1.Element { + content; + name; + creationDate; + lastUpdatedDate; + extension; + constructor({ content, name, creationDate, lastUpdatedDate, extension, }) { + super(types_1.ElementType.File); + this.content = content; + this.name = name; + this.creationDate = creationDate; + this.lastUpdatedDate = lastUpdatedDate; + this.extension = extension; + } +} +exports.FileElement = FileElement; + + +/***/ }), + +/***/ 877: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.HtmlElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class HtmlElement extends Element_class_1.Element { + html; + constructor({ html }) { + super(types_1.ElementType.Html); + this.html = html; + } +} +exports.HtmlElement = HtmlElement; + + +/***/ }), + +/***/ 7047: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ImageElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class ImageElement extends Element_class_1.Element { + base64; + url; + caption; + name; + creationDate; + lastUpdatedDate; + extension; + filepath; + constructor({ base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { + super(types_1.ElementType.Image); + this.name = name; + this.creationDate = creationDate; + this.lastUpdatedDate = lastUpdatedDate; + this.extension = extension; + this.base64 = base64; + this.url = url; + this.caption = caption; + this.filepath = filepath; + } +} +exports.ImageElement = ImageElement; + + +/***/ }), + +/***/ 538: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.LinkElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class LinkElement extends Element_class_1.Element { + url; + text; + caption; + constructor({ url, text, caption, }) { + super(types_1.ElementType.Link); + this.url = url; + this.text = text; + this.caption = caption; + } +} +exports.LinkElement = LinkElement; + + +/***/ }), + +/***/ 1539: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ListItemElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class ListItemElement extends Element_class_1.Element { + listType; + text; + children; + constructor({ listType, text, children, }) { + super(types_1.ElementType.ListItem); + this.listType = listType; + this.text = text; + this.children = children; + } +} +exports.ListItemElement = ListItemElement; + + +/***/ }), + +/***/ 2407: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PageElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +/** + * Element that represents the concept of page (in knowledge management systems) + */ +class PageElement extends Element_class_1.Element { + mkNotesInternalId; + title; + icon; + content; + properties; + constructor({ mkNotesInternalId, title, icon, content = [], properties, }) { + super(types_1.ElementType.Page); + this.mkNotesInternalId = mkNotesInternalId; + this.title = title; + this.icon = icon; + this.content = content; + this.properties = properties; + } + getIcon() { + return this.icon; + } + addElementToBeginning(element) { + this.content.unshift(element); + } + addElementToEnd(element) { + this.content.push(element); + } +} +exports.PageElement = PageElement; + + +/***/ }), + +/***/ 9248: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.QuoteElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class QuoteElement extends Element_class_1.Element { + text; + constructor({ text }) { + super(types_1.ElementType.Quote); + this.text = text; + } +} +exports.QuoteElement = QuoteElement; + + +/***/ }), + +/***/ 9704: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TableElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class TableElement extends Element_class_1.Element { + rows; + constructor({ rows }) { + super(types_1.ElementType.Table); + this.rows = rows; + } +} +exports.TableElement = TableElement; + + +/***/ }), + +/***/ 4728: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TableOfContentsElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class TableOfContentsElement extends Element_class_1.Element { + constructor() { + super(types_1.ElementType.TableOfContents); + } +} +exports.TableOfContentsElement = TableOfContentsElement; + + +/***/ }), + +/***/ 8731: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TextElement = exports.TextElementStyle = exports.TextElementLevel = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +var TextElementLevel; +(function (TextElementLevel) { + TextElementLevel["Heading1"] = "heading_1"; + TextElementLevel["Heading2"] = "heading_2"; + TextElementLevel["Heading3"] = "heading_3"; + TextElementLevel["Heading4"] = "heading_4"; + TextElementLevel["Heading5"] = "heading_5"; + TextElementLevel["Heading6"] = "heading_6"; + TextElementLevel["Paragraph"] = "paragraph"; +})(TextElementLevel || (exports.TextElementLevel = TextElementLevel = {})); +var TextElementStyle; +(function (TextElementStyle) { + TextElementStyle["Italic"] = "italic"; + TextElementStyle["Bold"] = "bold"; + TextElementStyle["Strikethrough"] = "strikethrough"; + TextElementStyle["Underline"] = "underline"; +})(TextElementStyle || (exports.TextElementStyle = TextElementStyle = {})); +class TextElement extends Element_class_1.Element { + text; + level; + styles = { + italic: false, + bold: false, + strikethrough: false, + underline: false, + code: false, + }; + constructor({ text, level = TextElementLevel.Paragraph, styles, }) { + super(types_1.ElementType.Text); + this.text = text; + this.level = level; + this.styles.bold = styles?.bold || false; + this.styles.italic = styles?.italic || false; + this.styles.strikethrough = styles?.strikethrough || false; + this.styles.underline = styles?.underline || false; + this.styles.code = styles?.code || false; + } +} +exports.TextElement = TextElement; + + +/***/ }), + +/***/ 1690: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ToggleElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class ToggleElement extends Element_class_1.Element { + title; + children; + constructor({ title, children }) { + super(types_1.ElementType.Toggle); + this.title = title; + this.children = children; + } +} +exports.ToggleElement = ToggleElement; + + +/***/ }), + +/***/ 2052: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +__exportStar(__nccwpck_require__(7512), exports); +__exportStar(__nccwpck_require__(9769), exports); +__exportStar(__nccwpck_require__(1983), exports); +__exportStar(__nccwpck_require__(5238), exports); +__exportStar(__nccwpck_require__(1020), exports); +__exportStar(__nccwpck_require__(5320), exports); +__exportStar(__nccwpck_require__(877), exports); +__exportStar(__nccwpck_require__(7047), exports); +__exportStar(__nccwpck_require__(538), exports); +__exportStar(__nccwpck_require__(1539), exports); +__exportStar(__nccwpck_require__(2407), exports); +__exportStar(__nccwpck_require__(9248), exports); +__exportStar(__nccwpck_require__(9704), exports); +__exportStar(__nccwpck_require__(4728), exports); +__exportStar(__nccwpck_require__(8731), exports); +__exportStar(__nccwpck_require__(1690), exports); +__exportStar(__nccwpck_require__(9461), exports); + + +/***/ }), + +/***/ 9461: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ElementType = void 0; +var ElementType; +(function (ElementType) { + ElementType["Page"] = "page"; + ElementType["File"] = "file"; + ElementType["Text"] = "text"; + ElementType["Quote"] = "quote"; + ElementType["Code"] = "code"; + ElementType["Callout"] = "callout"; + ElementType["Divider"] = "divider"; + ElementType["Image"] = "image"; + ElementType["Link"] = "link"; + ElementType["Table"] = "table"; + ElementType["ListItem"] = "list-item"; + ElementType["Html"] = "html"; + ElementType["Toggle"] = "toggle"; + ElementType["Equation"] = "equation"; + ElementType["TableOfContents"] = "table-of-contents"; +})(ElementType || (exports.ElementType = ElementType = {})); + + /***/ }), /***/ 2057: @@ -75376,7 +75723,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); __exportStar(__nccwpck_require__(2057), exports); -__exportStar(__nccwpck_require__(3739), exports); +__exportStar(__nccwpck_require__(2052), exports); __exportStar(__nccwpck_require__(464), exports); __exportStar(__nccwpck_require__(5666), exports); @@ -75523,15 +75870,37 @@ class SynchronizeMarkdownToNotion { } async execute(args) { const { notionParentPageUrl, cleanSync, lockPage, ...others } = args; - const notionPageId = this.destinationRepository.getPageIdFromPageUrl({ - pageUrl: notionParentPageUrl, + const notionObjectId = this.destinationRepository.getObjectIdFromObjectUrl({ + objectUrl: notionParentPageUrl, + }); + // Check if the Notion page is accessible + const destinationIsAccessible = await this.destinationRepository.destinationIsAccessible({ + parentObjectId: notionObjectId, }); + if (!destinationIsAccessible) { + throw new Error('Destination is not accessible'); + } + // Check if the source is accessible + try { + await this.sourceRepository.sourceIsAccessible(others); + } + catch (err) { + throw new Error(`Source is not accessible:`, { + cause: err, + }); + } + const parentObjectType = await this.destinationRepository.getObjectType({ + id: notionObjectId, + }); + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } // If clean sync is enabled, delete all existing content first if (cleanSync) { this.logger.info('Clean sync enabled - removing existing content'); try { await this.destinationRepository.deleteChildBlocks({ - parentPageId: notionPageId, + parentPageId: notionObjectId, }); this.logger.info('Successfully removed existing content'); } @@ -75540,29 +75909,14 @@ class SynchronizeMarkdownToNotion { } } try { - // Check if the Notion page is accessible - const destinationIsAccessible = await this.destinationRepository.destinationIsAccessible({ - parentPageId: notionPageId, - }); - if (!destinationIsAccessible) { - throw new Error('Destination is not accessible'); - } - // Check if the GitHub repository is accessible - try { - await this.sourceRepository.sourceIsAccessible(others); - } - catch (err) { - throw new Error(`Source is not accessible:`, { - cause: err, - }); - } this.logger.info('Starting synchronization process'); const filePaths = await this.sourceRepository.getFilePathList(others); const siteMap = sitemap_1.SiteMap.buildFromFilePaths(filePaths); // Traverse the SiteMap and synchronize files await this.synchronizeTreeNode({ node: siteMap.root, - parentPageId: notionPageId, + parentObjectId: notionObjectId, + parentObjectType, lockPage, }); this.logger.info('Synchronization process completed successfully'); @@ -75576,107 +75930,151 @@ class SynchronizeMarkdownToNotion { throw error; } } - async synchronizeTreeNode({ node, parentPageId, lockPage, }) { - // If the current node has content AND is the root node, add it to the parent page - if (node.filepath && node.parent === null) { - try { - this.logger.info(`Adding content from ${node.filepath} to parent page`); - // Retrieve the file content - const file = await this.sourceRepository.getFile({ - path: node.filepath, - }); - // Set the current file path for image resolution - if (this.elementConverter.setCurrentFilePath) { - this.elementConverter.setCurrentFilePath(node.filepath); - } - // Convert the file content to elements - const pageElement = this.elementConverter.convertToElement(file); - if (!(pageElement instanceof elements_1.PageElement)) { - throw new Error('Element is not a PageElement'); + /** + * Fetches a file and converts it to a PageElement + */ + async fetchAndConvertToPageElement(filePath) { + const file = await this.sourceRepository.getFile({ path: filePath }); + if (this.elementConverter.setCurrentFilePath) { + this.elementConverter.setCurrentFilePath(filePath); + } + const element = this.elementConverter.convertToElement(file); + if (!(element instanceof elements_1.PageElement)) { + throw new Error('Element is not a PageElement'); + } + return element; + } + /** + * Locks a page if locking is enabled + */ + async lockPageIfNeeded(pageId, shouldLock) { + if (shouldLock) { + await this.destinationRepository.setPageLockedStatus({ + pageId, + lockStatus: 'locked', + }); + this.logger.info(`Locked page ${pageId}`); + } + } + /** + * Synchronizes the root node to the parent object (page or database) + * Returns the page ID to use as parent for child nodes + */ + async synchronizeRootNode({ node, parentObjectId, parentObjectType, lockPage, }) { + this.logger.info(`Adding content from ${node.filepath} to parent ${parentObjectType}`); + const pageElement = await this.fetchAndConvertToPageElement(node.filepath); + if (parentObjectType === 'page') { + await this.destinationRepository.appendToPage({ + pageId: parentObjectId, + pageElement, + }); + this.logger.info(`Added content from ${node.filepath} to parent page`); + await this.lockPageIfNeeded(parentObjectId, lockPage); + return parentObjectId; + } + // parentObjectType === 'database' + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + filePath: node.filepath, + }); + if (!newPage.pageId) { + throw new Error('New page ID is undefined'); + } + return newPage.pageId; + } + /** + * Synchronizes a child node and its descendants recursively + */ + async synchronizeChildNode({ childNode, parentPageId, lockPage, }) { + const filePath = childNode.filepath; + this.logger.info(`Processing file: ${filePath}`); + const pageElement = await this.fetchAndConvertToPageElement(filePath); + // Add standard elements at the beginning (in reverse order) + pageElement.addElementToBeginning(new elements_1.TableOfContentsElement()); + pageElement.addElementToBeginning(new elements_1.DividerElement()); + if (childNode.children.length > 0) { + pageElement.addElementToEnd(new elements_1.DividerElement()); + } + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId: parentPageId, + parentObjectType: 'page', + filePath, + }); + this.logger.info(`Created Notion page for file: ${filePath}`); + if (!newPage.pageId) { + throw new Error('Page ID is undefined'); + } + // Recursively process children + for (const grandChild of childNode.children) { + await this.synchronizeChildNode({ + childNode: grandChild, + parentPageId: newPage.pageId, + lockPage, + }); + } + await this.lockPageIfNeeded(newPage.pageId, lockPage); + } + /** + * Main orchestrator for synchronizing a tree node and its children + */ + async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, }) { + let parentPageId = parentObjectId; + switch (parentObjectType) { + case 'unknown': + throw new Error('Parent object type is unknown'); + case 'database': + if (this.getIsRootNode(node)) { + parentPageId = await this.synchronizeRootNode({ + node, + parentObjectId, + parentObjectType, + lockPage, + }); } - // Add the content to the existing parent page by appending it - await this.destinationRepository.appendToPage({ - pageId: parentPageId, - pageElement, - }); - this.logger.info(`Added content from ${node.filepath} to parent page`); - if (lockPage) { - await this.destinationRepository.setPageLockedStatus({ - pageId: parentPageId, - lockStatus: 'locked', + else { + parentPageId = await this.synchronizeRootNode({ + node: node.children[0], + parentObjectId, + parentObjectType, + lockPage, }); - this.logger.info(`Locked parent page ${parentPageId}`); } - } - catch (error) { - if (error instanceof Error) { - this.logger.error(`Failed to add content from ${node.filepath} to parent page`, { - error, + break; + case 'page': + if (this.getIsRootNode(node)) { + parentPageId = await this.synchronizeRootNode({ + node, + parentObjectId, + parentObjectType, + lockPage, }); } - throw error; - } + break; + default: + throw new Error('Invalid parent object type'); } for (const childNode of node.children) { - const filePath = childNode.filepath; - this.logger.info(`Processing file: ${filePath}`); try { - // Retrieve the file from the source repository - const file = await this.sourceRepository.getFile({ - path: filePath, - }); - // Set the current file path for image resolution - if (this.elementConverter.setCurrentFilePath) { - this.elementConverter.setCurrentFilePath(filePath); - } - // Convert the file content to a Notion page element - const pageElement = this.elementConverter.convertToElement(file); - if (!(pageElement instanceof elements_1.PageElement)) { - throw new Error('Element is not a PageElement'); - } - [new elements_1.DividerElement(), new elements_1.TableOfContentsElement()].forEach((element) => { - pageElement.addElementToBeginning(element); - }); - if (childNode.children.length > 0) { - // Add divider to the end of the page - pageElement.addElementToEnd(new elements_1.DividerElement()); - } - // Create the Notion page and get the new page ID - const newPage = await this.destinationRepository.createPage({ - pageElement, + await this.synchronizeChildNode({ + childNode, parentPageId, - filePath, + lockPage, }); - this.logger.info(`Created Notion page for file: ${filePath}`); - // Recursively process the children of the current node - if (childNode.children.length > 0) { - if (newPage.pageId === undefined) { - throw new Error('Page ID is undefined'); - } - await this.synchronizeTreeNode({ - node: childNode, - parentPageId: newPage.pageId, - lockPage, - }); - } - if (lockPage && newPage.pageId) { - await this.destinationRepository.setPageLockedStatus({ - pageId: newPage.pageId, - lockStatus: 'locked', - }); - this.logger.info(`Locked page ${newPage.pageId}`); - } } catch (error) { - if (error instanceof Error) { - this.logger.error(`Failed to synchronize file: ${filePath}`, { - error, - }); - } + this.logger.error(`Failed to synchronize file: ${childNode.filepath}`, { + error, + }); throw error; } } } + getIsRootNode(node) { + return node.parent === null && !['', undefined].includes(node.filepath); + } } exports.SynchronizeMarkdownToNotion = SynchronizeMarkdownToNotion; @@ -75756,6 +76154,18 @@ class NotionPage { exports.NotionPage = NotionPage; +/***/ }), + +/***/ 8642: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.MK_NOTES_INTERNAL_ID_PROPERTY_NAME = void 0; +exports.MK_NOTES_INTERNAL_ID_PROPERTY_NAME = 'mk-notes-id'; + + /***/ }), /***/ 8109: @@ -75789,7 +76199,7 @@ exports.isNotionNestingValidationError = isNotionNestingValidationError; /***/ }), -/***/ 8434: +/***/ 8188: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -77093,12 +77503,18 @@ class MarkdownParser extends elements_1.ParserRepository { content: elements, }; const fileMetadata = this.getMetadata(content); + if (fileMetadata.id) { + result.mkNotesInternalId = fileMetadata.id; + } if (fileMetadata.title) { result.title = fileMetadata.title; } if (fileMetadata.icon) { result.icon = fileMetadata.icon; } + if (fileMetadata.properties && Array.isArray(fileMetadata.properties)) { + result.properties = fileMetadata.properties; + } return result; } } @@ -77484,7 +77900,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); __exportStar(__nccwpck_require__(3913), exports); -__exportStar(__nccwpck_require__(8434), exports); +__exportStar(__nccwpck_require__(8188), exports); __exportStar(__nccwpck_require__(2792), exports); __exportStar(__nccwpck_require__(6918), exports); __exportStar(__nccwpck_require__(3942), exports); @@ -77535,6 +77951,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.NotionConverterRepository = void 0; const path = __importStar(__nccwpck_require__(6928)); const elements_1 = __nccwpck_require__(7591); +const constants_1 = __nccwpck_require__(8642); const NotionPage_1 = __nccwpck_require__(3913); const SUPPORTED_IMAGE_URL_EXTENSIONS = [ '.bmp', @@ -77579,7 +77996,323 @@ class NotionConverterRepository { // Relative paths or absolute local paths return true; } - async convertPageElement(element) { + // ============================================ + // Property Conversion Functions + // ============================================ + /** + * Converts a string value to a Notion TitleProperty + */ + convertToTitleProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], + }; + } + /** + * Converts a string value to a Notion RichTextProperty + */ + convertToRichTextProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'rich_text', + rich_text: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], + }; + } + /** + * Converts a string value to a Notion NumberProperty + * Returns null if the value cannot be parsed as a number + */ + convertToNumberProperty(value) { + if (typeof value !== 'number') { + throw new Error(`Invalid value type: ${typeof value}`); + } + const parsed = value; + if (isNaN(parsed)) { + this.logger.warn(`Cannot convert "${value}" to number property, skipping`); + return null; + } + return { + type: 'number', + number: parsed, + }; + } + /** + * Converts a string value to a Notion UrlProperty + */ + convertToUrlProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'url', + url: value || null, + }; + } + /** + * Converts a string value to a Notion SelectProperty + * The value should match one of the available options in the database property definition + */ + convertToSelectProperty(value, propertyDefinition) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + // Type-narrow to access select options + if (propertyDefinition.type === 'select') { + const { options } = propertyDefinition.select; + const matchingOption = options.find((opt) => opt.name.toLowerCase() === value.toLowerCase()); + if (matchingOption) { + return { + type: 'select', + select: { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }, + }; + } + } + // If no matching option found, create a new one with the provided value + // Notion will create the option if it doesn't exist + return { + type: 'select', + select: { + id: '', + name: value, + }, + }; + } + /** + * Converts a string value to a Notion MultiSelectProperty + * The value should be a comma-separated list of option names + */ + convertToMultiSelectProperty(value, propertyDefinition) { + if (typeof value !== 'string' && !Array.isArray(value)) { + throw new Error(`Invalid value type: ${typeof value}`); + } + const values = value instanceof Array ? value : [value]; + // Type-narrow to access multi_select options + const options = propertyDefinition.type === 'multi_select' + ? propertyDefinition.multi_select.options + : []; + const multiSelectOptions = values.map((val) => { + const matchingOption = options.find((opt) => opt.name.toLowerCase() === String(val).toLowerCase()); + if (matchingOption) { + return { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }; + } + // Create new option if not found + return { + id: '', + name: String(val), + }; + }); + return { + type: 'multi_select', + multi_select: multiSelectOptions, + }; + } + /** + * Converts a string value to a Notion DateProperty + * Supports ISO 8601 date strings (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss) + */ + convertToDateProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + // Try to parse the date + const date = new Date(value); + if (isNaN(date.getTime())) { + this.logger.warn(`Cannot convert "${value}" to date property, skipping`); + return null; + } + // Format as ISO string (Notion expects ISO 8601 format) + return { + type: 'date', + date: value, // Pass the original value if it's already in ISO format + }; + } + /** + * Converts a string value to a Notion CheckboxProperty + * Accepts: "true", "false", "yes", "no", "1", "0" + */ + convertToCheckboxProperty(value) { + if (typeof value !== 'string' && typeof value !== 'boolean') { + throw new Error(`Invalid value type: ${typeof value}`); + } + if (typeof value === 'boolean') { + return { + type: 'checkbox', + checkbox: value, + }; + } + const normalizedValue = value.toLowerCase().trim(); + const trueValues = ['true', 'yes', '1', 'on', 'checked']; + const isChecked = trueValues.includes(normalizedValue); + return { + type: 'checkbox', + checkbox: isChecked, + }; + } + /** + * Converts a string value to a Notion EmailProperty + */ + convertToEmailProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'email', + email: value || null, + }; + } + /** + * Converts a string value to a Notion PhoneNumberProperty + */ + convertToPhoneNumberProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'phone_number', + phone_number: value || null, + }; + } + /** + * Converts a string value to a Notion StatusProperty + * The value should match one of the available status options in the database property definition + */ + convertToStatusProperty(value, propertyDefinition) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + // Type-narrow to access status options + if (propertyDefinition.type === 'status') { + const { options } = propertyDefinition.status; + const matchingOption = options.find((opt) => opt.name.toLowerCase() === value.toLowerCase()); + if (matchingOption) { + return { + type: 'status', + status: { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }, + }; + } + } + // If no matching option found, use the value as-is + // Note: Notion may reject this if the status doesn't exist + return { + type: 'status', + status: { + id: '', + name: value, + }, + }; + } + /** + * Converts a PageElementProperty to the appropriate Notion property based on the database property definition + */ + convertPropertyValue(value, propertyDefinition) { + if (value instanceof Array) { + if (propertyDefinition.type === 'multi_select') { + return this.convertToMultiSelectProperty(value, propertyDefinition); + } + this.logger.warn(`Unsupported array value for property type "${propertyDefinition.type}"`); + return null; + } + switch (propertyDefinition.type) { + case 'title': + return this.convertToTitleProperty(value); + case 'rich_text': + return this.convertToRichTextProperty(value); + case 'number': + return this.convertToNumberProperty(value); + case 'url': + return this.convertToUrlProperty(value); + case 'select': + return this.convertToSelectProperty(value, propertyDefinition); + case 'multi_select': + return this.convertToMultiSelectProperty(value, propertyDefinition); + case 'date': + return this.convertToDateProperty(value); + case 'checkbox': + return this.convertToCheckboxProperty(value); + case 'email': + return this.convertToEmailProperty(value); + case 'phone_number': + return this.convertToPhoneNumberProperty(value); + case 'status': + return this.convertToStatusProperty(value, propertyDefinition); + case 'people': + case 'files': + case 'relation': + case 'formula': + case 'rollup': + case 'created_time': + case 'created_by': + case 'last_edited_time': + case 'last_edited_by': + case 'unique_id': + this.logger.warn(`Property type "${propertyDefinition.type}" cannot be converted from string, skipping property "${propertyDefinition.name}"`); + return null; + } + } + /** + * Converts page element properties to Notion PageProperties based on database property definitions + * + * @param properties - Array of PageElementProperties from the markdown frontmatter + * @param notionPropertyDefinitions - Array of database property definitions from Notion + * @returns PageProperties object ready to be used in Notion API calls + */ + convertPageElementProperties(properties, notionProperties = []) { + const result = {}; + // Create a map of property definitions by name for quick lookup + const definitionMap = new Map(); + for (const property of notionProperties) { + definitionMap.set(property.name, property.definition); + } + // Convert each page element property + for (const property of properties ?? []) { + const definition = definitionMap.get(property.name); + if (!definition) { + this.logger.warn(`No matching Notion property definition found for "${property.name}", skipping`); + continue; + } + const convertedValue = this.convertPropertyValue(property.value, definition); + if (convertedValue !== null) { + // Use the original definition name to preserve casing + result[definition.name] = convertedValue; + } + } + return result; + } + async convertPageElement(element, notionPropertyDefinitions = []) { const title = { id: 'title', type: 'title', @@ -77593,10 +78326,20 @@ class NotionConverterRepository { }, ], }; + const elementProperties = element.properties ?? []; + if (element.mkNotesInternalId) { + elementProperties.push({ + name: constants_1.MK_NOTES_INTERNAL_ID_PROPERTY_NAME, + value: element.mkNotesInternalId, + }); + } + // Convert page element properties to Notion properties + const convertedProperties = this.convertPageElementProperties(elementProperties, notionPropertyDefinitions); const result = { children: [], properties: { title, + ...convertedProperties, }, }; for (const contentElement of element.content) { @@ -77646,8 +78389,8 @@ class NotionConverterRepository { return null; } } - async convertFromElement(element) { - const notionPageInput = await this.convertPageElement(element); + async convertFromElement(element, availableProperties = []) { + const notionPageInput = await this.convertPageElement(element, availableProperties); return NotionPage_1.NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); } convertText(element) { @@ -78119,37 +78862,33 @@ class NotionDestinationRepository { throw error instanceof Error ? error : new Error(String(error)); } } - getPageIdFromPageUrl({ pageUrl }) { - const urlObj = new URL(pageUrl); - const pathSegments = urlObj.pathname.split('-'); - let lastSegment = pathSegments[pathSegments.length - 1]; - /** - * If the URL has a query parameter `v`, it's becase it's a Notion Database - * Unfortunatly, for now, mk-notes doesn't support Notion Databases - **/ - if (urlObj.searchParams.has('v')) { - throw new Error('Notion Databases are not supported yet. Please use a Notion Page URL'); - } - if (lastSegment.startsWith('/')) { - lastSegment = lastSegment.slice(1); - } - const [lastSegmentWithoutQueryParams] = lastSegment.split('?'); - if (!lastSegmentWithoutQueryParams) { - throw new Error('Invalid Notion URL'); - } - if (lastSegmentWithoutQueryParams.length !== 32) { - throw new Error('Invalid Notion URL'); + getObjectIdFromObjectUrl({ objectUrl }) { + const urlObj = new URL(objectUrl); + // Notion IDs are 32-character hexadecimal strings (UUID without dashes) + // They can be embedded in path segments like "MK-Notes-4dd0bd3dc73648a9a55dcf05dd03080f" + const notionIdRegex = /[a-f0-9]{32}/gi; + const matches = urlObj.pathname.match(notionIdRegex); + if (!matches || matches.length === 0) { + throw new Error('Invalid Notion URL: No valid Notion ID found'); } - return lastSegmentWithoutQueryParams; + // Return the last match (closest to the end of the URL path) + return matches[matches.length - 1]; } - async destinationIsAccessible({ parentPageId, }) { + async destinationIsAccessible({ parentObjectId, }) { try { - await this.getPage({ pageId: parentPageId }); + await this.getPage({ pageId: parentObjectId }); return true; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { - return false; + try { + await this.getDatabaseById({ databaseId: parentObjectId }); + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (_err) { + return false; + } } } async getPageById({ notionPageId, }) { @@ -78168,16 +78907,41 @@ class NotionDestinationRepository { isLocked: pageObjectResponse.is_locked ?? false, }); } - async createPage({ parentPageId, pageElement, filePath, }) { + async createPage({ parentObjectId, parentObjectType, pageElement, filePath, }) { // Set the current file path for image resolution if (filePath) { this.notionConverter.setCurrentFilePath(filePath); } - const notionPage = await this.notionConverter.convertFromElement(pageElement); + if (parentObjectType === 'unknown') { + throw new Error('Unknown parent object type'); + } + let parent; + const availableProperties = []; + if (parentObjectType === 'page') { + parent = { type: 'page_id', page_id: parentObjectId }; + } + if (parentObjectType === 'database') { + const database = await this.getDatabaseById({ + databaseId: parentObjectId, + }); + if (!('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + const datasource = await this.getDatasourceByDatasourceId({ + datasourceId: database.data_sources[0].id, + }); + parent = { type: 'data_source_id', data_source_id: datasource.id }; + availableProperties.push(...Object.entries(datasource.properties).map(([name, property]) => ({ + name, + definition: property, + type: property.type, + }))); + } + const notionPage = await this.notionConverter.convertFromElement(pageElement, availableProperties); const NOTION_BLOCK_LIMIT = 100; // First create the page without children const { id: notionPageId } = await this.client.pages.create({ - parent: { type: 'page_id', page_id: parentPageId }, + parent, properties: notionPage.properties, icon: notionPage.icon, children: [], // Create page without children initially @@ -78328,6 +79092,31 @@ class NotionDestinationRepository { } return isLocked ? 'locked' : 'unlocked'; } + async getDatabaseById({ databaseId, }) { + return this.client.databases.retrieve({ + database_id: databaseId, + }); + } + async getObjectType({ id, }) { + try { + await this.client.pages.retrieve({ page_id: id }); + return 'page'; + } + catch { + try { + await this.client.databases.retrieve({ database_id: id }); + return 'database'; + } + catch { + return 'unknown'; + } + } + } + async getDatasourceByDatasourceId({ datasourceId, }) { + return this.client.dataSources.retrieve({ + data_source_id: datasourceId, + }); + } } exports.NotionDestinationRepository = NotionDestinationRepository; @@ -84722,7 +85511,7 @@ var lexer = _Lexer.lex; /***/ ((module) => { "use strict"; -module.exports = /*#__PURE__*/JSON.parse('{"name":"@notionhq/client","version":"5.1.0","description":"A simple and easy to use client for the Notion API","engines":{"node":">=18"},"homepage":"https://developers.notion.com/docs/getting-started","bugs":{"url":"https://github.com/makenotion/notion-sdk-js/issues"},"repository":{"type":"git","url":"https://github.com/makenotion/notion-sdk-js/"},"keywords":["notion","notionapi","rest","notion-api"],"main":"./build/src","types":"./build/src/index.d.ts","scripts":{"prepare":"npm run build","prepublishOnly":"npm run checkLoggedIn && npm run lint && npm run test","build":"tsc","prettier":"prettier --write .","lint":"prettier --check . && eslint . --ext .ts && cspell \'**/*\' ","test":"jest ./test","check-links":"git ls-files | grep md$ | xargs -n 1 markdown-link-check","prebuild":"npm run clean","clean":"rm -rf ./build","checkLoggedIn":"./scripts/verifyLoggedIn.sh","install:examples":"for dir in examples/*/; do echo \\"Installing dependencies in $dir...\\"; (cd \\"$dir\\" && npm install); done","examples:install":"npm run install:examples","examples:typecheck":"for dir in examples/*/; do echo \\"Typechecking $dir...\\"; (cd \\"$dir\\" && npx tsc --noEmit) || exit 1; done"},"author":"","license":"MIT","files":["build/package.json","build/src/**"],"devDependencies":{"@types/jest":"28.1.4","@typescript-eslint/eslint-plugin":"5.39.0","@typescript-eslint/parser":"5.39.0","cspell":"5.4.1","eslint":"7.24.0","jest":"28.1.2","markdown-link-check":"3.13.7","prettier":"2.8.8","ts-jest":"28.0.5","typescript":"5.9.2"}}'); +module.exports = /*#__PURE__*/JSON.parse('{"name":"@notionhq/client","version":"5.4.0","description":"A simple and easy to use client for the Notion API","engines":{"node":">=18"},"homepage":"https://developers.notion.com/docs/getting-started","bugs":{"url":"https://github.com/makenotion/notion-sdk-js/issues"},"repository":{"type":"git","url":"https://github.com/makenotion/notion-sdk-js/"},"keywords":["notion","notionapi","rest","notion-api"],"main":"./build/src","types":"./build/src/index.d.ts","scripts":{"prepare":"npm run build","prepublishOnly":"npm run checkLoggedIn && npm run lint && npm run test","build":"tsc","prettier":"prettier --write .","lint":"prettier --check . && eslint . --ext .ts && cspell \'**/*\' ","test":"jest ./test","check-links":"git ls-files | grep md$ | xargs -n 1 markdown-link-check","prebuild":"npm run clean","clean":"rm -rf ./build","checkLoggedIn":"./scripts/verifyLoggedIn.sh","install:examples":"for dir in examples/*/; do echo \\"Installing dependencies in $dir...\\"; (cd \\"$dir\\" && npm install); done","examples:install":"npm run install:examples","examples:typecheck":"for dir in examples/*/; do echo \\"Typechecking $dir...\\"; (cd \\"$dir\\" && npx tsc --noEmit) || exit 1; done"},"author":"","license":"MIT","files":["build/package.json","build/src/**"],"devDependencies":{"@types/jest":"28.1.4","@typescript-eslint/eslint-plugin":"5.39.0","@typescript-eslint/parser":"5.39.0","cspell":"5.4.1","eslint":"7.24.0","jest":"28.1.2","markdown-link-check":"3.13.7","prettier":"2.8.8","ts-jest":"28.0.5","typescript":"5.9.2"}}'); /***/ }), diff --git a/sync/index.js b/sync/index.js index 4d99152..cb078e5 100644 --- a/sync/index.js +++ b/sync/index.js @@ -4563,6 +4563,18 @@ class Client { auth: args === null || args === void 0 ? void 0 : args.auth, }); }, + /** + * List page templates that are available for a data source + */ + listTemplates: (args) => { + return this.request({ + path: api_endpoints_1.listDataSourceTemplates.path(args), + method: api_endpoints_1.listDataSourceTemplates.method, + query: (0, utils_1.pick)(args, api_endpoints_1.listDataSourceTemplates.queryParams), + body: (0, utils_1.pick)(args, api_endpoints_1.listDataSourceTemplates.bodyParams), + auth: args === null || args === void 0 ? void 0 : args.auth, + }); + }, }; this.pages = { /** @@ -4983,7 +4995,7 @@ exports["default"] = Client; // cspell:disable-file // Note: This is a generated file. DO NOT EDIT! Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.oauthIntrospect = exports.oauthRevoke = exports.oauthToken = exports.getFileUpload = exports.completeFileUpload = exports.sendFileUpload = exports.listFileUploads = exports.createFileUpload = exports.getComment = exports.listComments = exports.createComment = exports.search = exports.createDatabase = exports.updateDatabase = exports.getDatabase = exports.createDataSource = exports.queryDataSource = exports.updateDataSource = exports.getDataSource = exports.appendBlockChildren = exports.listBlockChildren = exports.deleteBlock = exports.updateBlock = exports.getBlock = exports.getPageProperty = exports.updatePage = exports.getPage = exports.createPage = exports.listUsers = exports.getUser = exports.getSelf = void 0; +exports.movePage = exports.oauthIntrospect = exports.oauthRevoke = exports.oauthToken = exports.getFileUpload = exports.completeFileUpload = exports.sendFileUpload = exports.listFileUploads = exports.createFileUpload = exports.getComment = exports.listComments = exports.createComment = exports.search = exports.createDatabase = exports.updateDatabase = exports.getDatabase = exports.listDataSourceTemplates = exports.createDataSource = exports.queryDataSource = exports.updateDataSource = exports.getDataSource = exports.appendBlockChildren = exports.listBlockChildren = exports.deleteBlock = exports.updateBlock = exports.getBlock = exports.getPageProperty = exports.updatePage = exports.getPage = exports.createPage = exports.listUsers = exports.getUser = exports.getSelf = void 0; /** * Retrieve your token's bot user */ @@ -5021,7 +5033,15 @@ exports.createPage = { method: "post", pathParams: [], queryParams: [], - bodyParams: ["parent", "properties", "icon", "cover", "content", "children"], + bodyParams: [ + "parent", + "properties", + "icon", + "cover", + "content", + "children", + "template", + ], path: () => `pages`, }; /** @@ -5046,6 +5066,8 @@ exports.updatePage = { "icon", "cover", "is_locked", + "template", + "erase_content", "archived", "in_trash", ], @@ -5177,6 +5199,7 @@ exports.queryDataSource = { "page_size", "archived", "in_trash", + "result_type", ], path: (p) => `data_sources/${p.data_source_id}/query`, }; @@ -5190,6 +5213,16 @@ exports.createDataSource = { bodyParams: ["parent", "properties", "title", "icon"], path: () => `data_sources`, }; +/** + * List templates in a data source + */ +exports.listDataSourceTemplates = { + method: "get", + pathParams: ["data_source_id"], + queryParams: ["name", "start_cursor", "page_size"], + bodyParams: [], + path: (p) => `data_sources/${p.data_source_id}/templates`, +}; /** * Retrieve a database */ @@ -5376,6 +5409,16 @@ exports.oauthIntrospect = { bodyParams: ["token"], path: () => `oauth/introspect`, }; +/** + * Move a page + */ +exports.movePage = { + method: "post", + pathParams: ["page_id"], + queryParams: [], + bodyParams: ["parent"], + path: (p) => `pages/${p.page_id}/move`, +}; //# sourceMappingURL=api-endpoints.js.map /***/ }), @@ -5608,6 +5651,8 @@ function isAPIErrorCode(code) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.iteratePaginatedAPI = iteratePaginatedAPI; exports.collectPaginatedAPI = collectPaginatedAPI; +exports.iterateDataSourceTemplates = iterateDataSourceTemplates; +exports.collectDataSourceTemplates = collectDataSourceTemplates; exports.isFullBlock = isFullBlock; exports.isFullPage = isFullPage; exports.isFullDataSource = isFullDataSource; @@ -5677,6 +5722,55 @@ async function collectPaginatedAPI(listFn, firstPageArgs) { } return results; } +/** + * Returns an async iterator over data source templates. + * + * Example (given a notion Client called `notion`): + * + * ``` + * for await (const template of iterateDataSourceTemplates(notion, { + * data_source_id: dataSourceId, + * })) { + * console.log(template.name, template.is_default) + * } + * ``` + * + * @param client A Notion client instance. + * @param args Arguments including the data_source_id and optional start_cursor. + */ +async function* iterateDataSourceTemplates(client, args) { + let nextCursor = args.start_cursor; + do { + const response = await client.dataSources.listTemplates({ + ...args, + start_cursor: nextCursor, + }); + yield* response.templates; + nextCursor = response.next_cursor; + } while (nextCursor); +} +/** + * Collect all data source templates into an in-memory array. + * + * Example (given a notion Client called `notion`): + * + * ``` + * const templates = await collectDataSourceTemplates(notion, { + * data_source_id: dataSourceId, + * }) + * // Do something with templates. + * ``` + * + * @param client A Notion client instance. + * @param args Arguments including the data_source_id and optional start_cursor. + */ +async function collectDataSourceTemplates(client, args) { + const results = []; + for await (const template of iterateDataSourceTemplates(client, args)) { + results.push(template); + } + return results; +} /** * @returns `true` if `response` is a full `BlockObjectResponse`. */ @@ -5854,7 +5948,7 @@ function extractBlockId(urlWithBlock) { * @packageDocumentation */ Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.extractBlockId = exports.extractPageId = exports.extractDatabaseId = exports.extractNotionId = exports.isFullPageOrDataSource = exports.isFullComment = exports.isFullUser = exports.isFullPage = exports.isFullDatabase = exports.isFullDataSource = exports.isFullBlock = exports.iteratePaginatedAPI = exports.collectPaginatedAPI = exports.isNotionClientError = exports.RequestTimeoutError = exports.UnknownHTTPResponseError = exports.APIResponseError = exports.ClientErrorCode = exports.APIErrorCode = exports.LogLevel = exports.Client = void 0; +exports.extractBlockId = exports.extractPageId = exports.extractDatabaseId = exports.extractNotionId = exports.isFullPageOrDataSource = exports.isFullComment = exports.isFullUser = exports.isFullPage = exports.isFullDatabase = exports.isFullDataSource = exports.isFullBlock = exports.iterateDataSourceTemplates = exports.collectDataSourceTemplates = exports.iteratePaginatedAPI = exports.collectPaginatedAPI = exports.isNotionClientError = exports.RequestTimeoutError = exports.UnknownHTTPResponseError = exports.APIResponseError = exports.ClientErrorCode = exports.APIErrorCode = exports.LogLevel = exports.Client = void 0; var Client_1 = __nccwpck_require__(9711); Object.defineProperty(exports, "Client", ({ enumerable: true, get: function () { return Client_1.default; } })); var logging_1 = __nccwpck_require__(2743); @@ -5870,6 +5964,8 @@ Object.defineProperty(exports, "isNotionClientError", ({ enumerable: true, get: var helpers_1 = __nccwpck_require__(5847); Object.defineProperty(exports, "collectPaginatedAPI", ({ enumerable: true, get: function () { return helpers_1.collectPaginatedAPI; } })); Object.defineProperty(exports, "iteratePaginatedAPI", ({ enumerable: true, get: function () { return helpers_1.iteratePaginatedAPI; } })); +Object.defineProperty(exports, "collectDataSourceTemplates", ({ enumerable: true, get: function () { return helpers_1.collectDataSourceTemplates; } })); +Object.defineProperty(exports, "iterateDataSourceTemplates", ({ enumerable: true, get: function () { return helpers_1.iterateDataSourceTemplates; } })); Object.defineProperty(exports, "isFullBlock", ({ enumerable: true, get: function () { return helpers_1.isFullBlock; } })); Object.defineProperty(exports, "isFullDataSource", ({ enumerable: true, get: function () { return helpers_1.isFullDataSource; } })); Object.defineProperty(exports, "isFullDatabase", ({ enumerable: true, get: function () { return helpers_1.isFullDatabase; } })); @@ -75052,186 +75148,15 @@ function getInputAsBool(name, options) { /***/ }), -/***/ 3739: -/***/ ((__unused_webpack_module, exports) => { +/***/ 7512: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.EquationElement = exports.TableOfContentsElement = exports.ToggleElement = exports.HtmlElement = exports.LinkElement = exports.ImageElement = exports.DividerElement = exports.CalloutElement = exports.SpecialCalloutType = exports.CodeElement = exports.isElementCodeLanguage = exports.ElementCodeLanguage = exports.QuoteElement = exports.TextElement = exports.TextElementStyle = exports.TextElementLevel = exports.TableElement = exports.ListItemElement = exports.FileElement = exports.PageElement = exports.Element = exports.ElementType = void 0; -var ElementType; -(function (ElementType) { - ElementType["Page"] = "page"; - ElementType["File"] = "file"; - ElementType["Text"] = "text"; - ElementType["Quote"] = "quote"; - ElementType["Code"] = "code"; - ElementType["Callout"] = "callout"; - ElementType["Divider"] = "divider"; - ElementType["Image"] = "image"; - ElementType["Link"] = "link"; - ElementType["Table"] = "table"; - ElementType["ListItem"] = "list-item"; - ElementType["Html"] = "html"; - ElementType["Toggle"] = "toggle"; - ElementType["Equation"] = "equation"; - ElementType["TableOfContents"] = "table-of-contents"; -})(ElementType || (exports.ElementType = ElementType = {})); -class Element { - type; - constructor(type) { - this.type = type; - } -} -exports.Element = Element; -/** - * Element that represents the concept of page (in knowledge management systems) - */ -class PageElement extends Element { - title; - icon; - content; - constructor({ title, icon, content = [], }) { - super(ElementType.Page); - this.title = title; - this.icon = icon; - this.content = content; - } - getIcon() { - return this.icon; - } - addElementToBeginning(element) { - this.content.unshift(element); - } - addElementToEnd(element) { - this.content.push(element); - } -} -exports.PageElement = PageElement; -/** - * Element that represents a file in the system - */ -class FileElement extends Element { - content; - name; - creationDate; - lastUpdatedDate; - extension; - constructor({ content, name, creationDate, lastUpdatedDate, extension, }) { - super(ElementType.File); - this.content = content; - this.name = name; - this.creationDate = creationDate; - this.lastUpdatedDate = lastUpdatedDate; - this.extension = extension; - } -} -exports.FileElement = FileElement; -class ListItemElement extends Element { - listType; - text; - children; - constructor({ listType, text, children, }) { - super(ElementType.ListItem); - this.listType = listType; - this.text = text; - this.children = children; - } -} -exports.ListItemElement = ListItemElement; -class TableElement extends Element { - rows; - constructor({ rows }) { - super(ElementType.Table); - this.rows = rows; - } -} -exports.TableElement = TableElement; -var TextElementLevel; -(function (TextElementLevel) { - TextElementLevel["Heading1"] = "heading_1"; - TextElementLevel["Heading2"] = "heading_2"; - TextElementLevel["Heading3"] = "heading_3"; - TextElementLevel["Heading4"] = "heading_4"; - TextElementLevel["Heading5"] = "heading_5"; - TextElementLevel["Heading6"] = "heading_6"; - TextElementLevel["Paragraph"] = "paragraph"; -})(TextElementLevel || (exports.TextElementLevel = TextElementLevel = {})); -var TextElementStyle; -(function (TextElementStyle) { - TextElementStyle["Italic"] = "italic"; - TextElementStyle["Bold"] = "bold"; - TextElementStyle["Strikethrough"] = "strikethrough"; - TextElementStyle["Underline"] = "underline"; -})(TextElementStyle || (exports.TextElementStyle = TextElementStyle = {})); -class TextElement extends Element { - text; - level; - styles = { - italic: false, - bold: false, - strikethrough: false, - underline: false, - code: false, - }; - constructor({ text, level = TextElementLevel.Paragraph, styles, }) { - super(ElementType.Text); - this.text = text; - this.level = level; - this.styles.bold = styles?.bold || false; - this.styles.italic = styles?.italic || false; - this.styles.strikethrough = styles?.strikethrough || false; - this.styles.underline = styles?.underline || false; - this.styles.code = styles?.code || false; - } -} -exports.TextElement = TextElement; -class QuoteElement extends Element { - text; - constructor({ text }) { - super(ElementType.Quote); - this.text = text; - } -} -exports.QuoteElement = QuoteElement; -var ElementCodeLanguage; -(function (ElementCodeLanguage) { - ElementCodeLanguage["JavaScript"] = "javascript"; - ElementCodeLanguage["TypeScript"] = "typescript"; - ElementCodeLanguage["Python"] = "python"; - ElementCodeLanguage["Java"] = "java"; - ElementCodeLanguage["CSharp"] = "csharp"; - ElementCodeLanguage["CPlusPlus"] = "c++"; - ElementCodeLanguage["Go"] = "go"; - ElementCodeLanguage["Ruby"] = "ruby"; - ElementCodeLanguage["Swift"] = "swift"; - ElementCodeLanguage["Kotlin"] = "kotlin"; - ElementCodeLanguage["Rust"] = "rust"; - ElementCodeLanguage["Shell"] = "shell"; - ElementCodeLanguage["Scala"] = "scala"; - ElementCodeLanguage["SQL"] = "sql"; - ElementCodeLanguage["HTML"] = "html"; - ElementCodeLanguage["CSS"] = "css"; - ElementCodeLanguage["JSON"] = "json"; - ElementCodeLanguage["YAML"] = "yaml"; - ElementCodeLanguage["Markdown"] = "markdown"; - ElementCodeLanguage["Mermaid"] = "mermaid"; - ElementCodeLanguage["PlainText"] = "plaintext"; -})(ElementCodeLanguage || (exports.ElementCodeLanguage = ElementCodeLanguage = {})); -const isElementCodeLanguage = (value) => { - return Object.values(ElementCodeLanguage).includes(value); -}; -exports.isElementCodeLanguage = isElementCodeLanguage; -class CodeElement extends Element { - language; - text; - constructor({ language, text, }) { - super(ElementType.Code); - this.language = language; - this.text = text; - } -} -exports.CodeElement = CodeElement; +exports.CalloutElement = exports.SpecialCalloutType = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); const specialCalloutRegex = // eslint-disable-next-line no-useless-escape /^\s*\[\!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)/ims; @@ -75243,7 +75168,7 @@ var SpecialCalloutType; SpecialCalloutType["Warning"] = "warning"; SpecialCalloutType["Caution"] = "caution"; })(SpecialCalloutType || (exports.SpecialCalloutType = SpecialCalloutType = {})); -class CalloutElement extends Element { +class CalloutElement extends Element_class_1.Element { text; icon; calloutType; @@ -75251,7 +75176,7 @@ class CalloutElement extends Element { return specialCalloutRegex.test(text.trim()); } constructor({ icon, text }) { - super(ElementType.Callout); + super(types_1.ElementType.Callout); this.icon = icon; this.text = text; const { text: parsedText, calloutType } = this.getSpecialCalloutTypeAndText(text); @@ -75297,71 +75222,108 @@ class CalloutElement extends Element { } } exports.CalloutElement = CalloutElement; -class DividerElement extends Element { - constructor() { - super(ElementType.Divider); - } -} -exports.DividerElement = DividerElement; -class ImageElement extends Element { - base64; - url; - caption; - name; - creationDate; - lastUpdatedDate; - extension; - filepath; - constructor({ base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { - super(ElementType.Image); - this.name = name; - this.creationDate = creationDate; - this.lastUpdatedDate = lastUpdatedDate; - this.extension = extension; - this.base64 = base64; - this.url = url; - this.caption = caption; - this.filepath = filepath; - } -} -exports.ImageElement = ImageElement; -class LinkElement extends Element { - url; + + +/***/ }), + +/***/ 9769: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.CodeElement = exports.isElementCodeLanguage = exports.ElementCodeLanguage = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +var ElementCodeLanguage; +(function (ElementCodeLanguage) { + ElementCodeLanguage["JavaScript"] = "javascript"; + ElementCodeLanguage["TypeScript"] = "typescript"; + ElementCodeLanguage["Python"] = "python"; + ElementCodeLanguage["Java"] = "java"; + ElementCodeLanguage["CSharp"] = "csharp"; + ElementCodeLanguage["CPlusPlus"] = "c++"; + ElementCodeLanguage["Go"] = "go"; + ElementCodeLanguage["Ruby"] = "ruby"; + ElementCodeLanguage["Swift"] = "swift"; + ElementCodeLanguage["Kotlin"] = "kotlin"; + ElementCodeLanguage["Rust"] = "rust"; + ElementCodeLanguage["Shell"] = "shell"; + ElementCodeLanguage["Scala"] = "scala"; + ElementCodeLanguage["SQL"] = "sql"; + ElementCodeLanguage["HTML"] = "html"; + ElementCodeLanguage["CSS"] = "css"; + ElementCodeLanguage["JSON"] = "json"; + ElementCodeLanguage["YAML"] = "yaml"; + ElementCodeLanguage["Markdown"] = "markdown"; + ElementCodeLanguage["Mermaid"] = "mermaid"; + ElementCodeLanguage["PlainText"] = "plaintext"; +})(ElementCodeLanguage || (exports.ElementCodeLanguage = ElementCodeLanguage = {})); +const isElementCodeLanguage = (value) => { + return Object.values(ElementCodeLanguage).includes(value); +}; +exports.isElementCodeLanguage = isElementCodeLanguage; +class CodeElement extends Element_class_1.Element { + language; text; - caption; - constructor({ url, text, caption, }) { - super(ElementType.Link); - this.url = url; + constructor({ language, text, }) { + super(types_1.ElementType.Code); + this.language = language; this.text = text; - this.caption = caption; - } -} -exports.LinkElement = LinkElement; -class HtmlElement extends Element { - html; - constructor({ html }) { - super(ElementType.Html); - this.html = html; } } -exports.HtmlElement = HtmlElement; -class ToggleElement extends Element { - title; - children; - constructor({ title, children }) { - super(ElementType.Toggle); - this.title = title; - this.children = children; +exports.CodeElement = CodeElement; + + +/***/ }), + +/***/ 1983: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.DividerElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class DividerElement extends Element_class_1.Element { + constructor() { + super(types_1.ElementType.Divider); } } -exports.ToggleElement = ToggleElement; -class TableOfContentsElement extends Element { - constructor() { - super(ElementType.TableOfContents); +exports.DividerElement = DividerElement; + + +/***/ }), + +/***/ 5238: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.Element = void 0; +class Element { + type; + constructor(type) { + this.type = type; } } -exports.TableOfContentsElement = TableOfContentsElement; -class EquationElement extends Element { +exports.Element = Element; + + +/***/ }), + +/***/ 1020: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.EquationElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class EquationElement extends Element_class_1.Element { equation; styles = { italic: false, @@ -75371,7 +75333,7 @@ class EquationElement extends Element { code: false, }; constructor({ equation, styles, }) { - super(ElementType.Equation); + super(types_1.ElementType.Equation); this.equation = equation; this.styles.bold = styles?.bold || false; this.styles.italic = styles?.italic || false; @@ -75383,6 +75345,391 @@ class EquationElement extends Element { exports.EquationElement = EquationElement; +/***/ }), + +/***/ 5320: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.FileElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +/** + * Element that represents a file in the system + */ +class FileElement extends Element_class_1.Element { + content; + name; + creationDate; + lastUpdatedDate; + extension; + constructor({ content, name, creationDate, lastUpdatedDate, extension, }) { + super(types_1.ElementType.File); + this.content = content; + this.name = name; + this.creationDate = creationDate; + this.lastUpdatedDate = lastUpdatedDate; + this.extension = extension; + } +} +exports.FileElement = FileElement; + + +/***/ }), + +/***/ 877: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.HtmlElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class HtmlElement extends Element_class_1.Element { + html; + constructor({ html }) { + super(types_1.ElementType.Html); + this.html = html; + } +} +exports.HtmlElement = HtmlElement; + + +/***/ }), + +/***/ 7047: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ImageElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class ImageElement extends Element_class_1.Element { + base64; + url; + caption; + name; + creationDate; + lastUpdatedDate; + extension; + filepath; + constructor({ base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { + super(types_1.ElementType.Image); + this.name = name; + this.creationDate = creationDate; + this.lastUpdatedDate = lastUpdatedDate; + this.extension = extension; + this.base64 = base64; + this.url = url; + this.caption = caption; + this.filepath = filepath; + } +} +exports.ImageElement = ImageElement; + + +/***/ }), + +/***/ 538: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.LinkElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class LinkElement extends Element_class_1.Element { + url; + text; + caption; + constructor({ url, text, caption, }) { + super(types_1.ElementType.Link); + this.url = url; + this.text = text; + this.caption = caption; + } +} +exports.LinkElement = LinkElement; + + +/***/ }), + +/***/ 1539: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ListItemElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class ListItemElement extends Element_class_1.Element { + listType; + text; + children; + constructor({ listType, text, children, }) { + super(types_1.ElementType.ListItem); + this.listType = listType; + this.text = text; + this.children = children; + } +} +exports.ListItemElement = ListItemElement; + + +/***/ }), + +/***/ 2407: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PageElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +/** + * Element that represents the concept of page (in knowledge management systems) + */ +class PageElement extends Element_class_1.Element { + mkNotesInternalId; + title; + icon; + content; + properties; + constructor({ mkNotesInternalId, title, icon, content = [], properties, }) { + super(types_1.ElementType.Page); + this.mkNotesInternalId = mkNotesInternalId; + this.title = title; + this.icon = icon; + this.content = content; + this.properties = properties; + } + getIcon() { + return this.icon; + } + addElementToBeginning(element) { + this.content.unshift(element); + } + addElementToEnd(element) { + this.content.push(element); + } +} +exports.PageElement = PageElement; + + +/***/ }), + +/***/ 9248: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.QuoteElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class QuoteElement extends Element_class_1.Element { + text; + constructor({ text }) { + super(types_1.ElementType.Quote); + this.text = text; + } +} +exports.QuoteElement = QuoteElement; + + +/***/ }), + +/***/ 9704: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TableElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class TableElement extends Element_class_1.Element { + rows; + constructor({ rows }) { + super(types_1.ElementType.Table); + this.rows = rows; + } +} +exports.TableElement = TableElement; + + +/***/ }), + +/***/ 4728: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TableOfContentsElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class TableOfContentsElement extends Element_class_1.Element { + constructor() { + super(types_1.ElementType.TableOfContents); + } +} +exports.TableOfContentsElement = TableOfContentsElement; + + +/***/ }), + +/***/ 8731: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.TextElement = exports.TextElementStyle = exports.TextElementLevel = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +var TextElementLevel; +(function (TextElementLevel) { + TextElementLevel["Heading1"] = "heading_1"; + TextElementLevel["Heading2"] = "heading_2"; + TextElementLevel["Heading3"] = "heading_3"; + TextElementLevel["Heading4"] = "heading_4"; + TextElementLevel["Heading5"] = "heading_5"; + TextElementLevel["Heading6"] = "heading_6"; + TextElementLevel["Paragraph"] = "paragraph"; +})(TextElementLevel || (exports.TextElementLevel = TextElementLevel = {})); +var TextElementStyle; +(function (TextElementStyle) { + TextElementStyle["Italic"] = "italic"; + TextElementStyle["Bold"] = "bold"; + TextElementStyle["Strikethrough"] = "strikethrough"; + TextElementStyle["Underline"] = "underline"; +})(TextElementStyle || (exports.TextElementStyle = TextElementStyle = {})); +class TextElement extends Element_class_1.Element { + text; + level; + styles = { + italic: false, + bold: false, + strikethrough: false, + underline: false, + code: false, + }; + constructor({ text, level = TextElementLevel.Paragraph, styles, }) { + super(types_1.ElementType.Text); + this.text = text; + this.level = level; + this.styles.bold = styles?.bold || false; + this.styles.italic = styles?.italic || false; + this.styles.strikethrough = styles?.strikethrough || false; + this.styles.underline = styles?.underline || false; + this.styles.code = styles?.code || false; + } +} +exports.TextElement = TextElement; + + +/***/ }), + +/***/ 1690: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ToggleElement = void 0; +const Element_class_1 = __nccwpck_require__(5238); +const types_1 = __nccwpck_require__(9461); +class ToggleElement extends Element_class_1.Element { + title; + children; + constructor({ title, children }) { + super(types_1.ElementType.Toggle); + this.title = title; + this.children = children; + } +} +exports.ToggleElement = ToggleElement; + + +/***/ }), + +/***/ 2052: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +__exportStar(__nccwpck_require__(7512), exports); +__exportStar(__nccwpck_require__(9769), exports); +__exportStar(__nccwpck_require__(1983), exports); +__exportStar(__nccwpck_require__(5238), exports); +__exportStar(__nccwpck_require__(1020), exports); +__exportStar(__nccwpck_require__(5320), exports); +__exportStar(__nccwpck_require__(877), exports); +__exportStar(__nccwpck_require__(7047), exports); +__exportStar(__nccwpck_require__(538), exports); +__exportStar(__nccwpck_require__(1539), exports); +__exportStar(__nccwpck_require__(2407), exports); +__exportStar(__nccwpck_require__(9248), exports); +__exportStar(__nccwpck_require__(9704), exports); +__exportStar(__nccwpck_require__(4728), exports); +__exportStar(__nccwpck_require__(8731), exports); +__exportStar(__nccwpck_require__(1690), exports); +__exportStar(__nccwpck_require__(9461), exports); + + +/***/ }), + +/***/ 9461: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.ElementType = void 0; +var ElementType; +(function (ElementType) { + ElementType["Page"] = "page"; + ElementType["File"] = "file"; + ElementType["Text"] = "text"; + ElementType["Quote"] = "quote"; + ElementType["Code"] = "code"; + ElementType["Callout"] = "callout"; + ElementType["Divider"] = "divider"; + ElementType["Image"] = "image"; + ElementType["Link"] = "link"; + ElementType["Table"] = "table"; + ElementType["ListItem"] = "list-item"; + ElementType["Html"] = "html"; + ElementType["Toggle"] = "toggle"; + ElementType["Equation"] = "equation"; + ElementType["TableOfContents"] = "table-of-contents"; +})(ElementType || (exports.ElementType = ElementType = {})); + + /***/ }), /***/ 2057: @@ -75416,7 +75763,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); __exportStar(__nccwpck_require__(2057), exports); -__exportStar(__nccwpck_require__(3739), exports); +__exportStar(__nccwpck_require__(2052), exports); __exportStar(__nccwpck_require__(464), exports); __exportStar(__nccwpck_require__(5666), exports); @@ -75563,15 +75910,37 @@ class SynchronizeMarkdownToNotion { } async execute(args) { const { notionParentPageUrl, cleanSync, lockPage, ...others } = args; - const notionPageId = this.destinationRepository.getPageIdFromPageUrl({ - pageUrl: notionParentPageUrl, + const notionObjectId = this.destinationRepository.getObjectIdFromObjectUrl({ + objectUrl: notionParentPageUrl, + }); + // Check if the Notion page is accessible + const destinationIsAccessible = await this.destinationRepository.destinationIsAccessible({ + parentObjectId: notionObjectId, }); + if (!destinationIsAccessible) { + throw new Error('Destination is not accessible'); + } + // Check if the source is accessible + try { + await this.sourceRepository.sourceIsAccessible(others); + } + catch (err) { + throw new Error(`Source is not accessible:`, { + cause: err, + }); + } + const parentObjectType = await this.destinationRepository.getObjectType({ + id: notionObjectId, + }); + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } // If clean sync is enabled, delete all existing content first if (cleanSync) { this.logger.info('Clean sync enabled - removing existing content'); try { await this.destinationRepository.deleteChildBlocks({ - parentPageId: notionPageId, + parentPageId: notionObjectId, }); this.logger.info('Successfully removed existing content'); } @@ -75580,29 +75949,14 @@ class SynchronizeMarkdownToNotion { } } try { - // Check if the Notion page is accessible - const destinationIsAccessible = await this.destinationRepository.destinationIsAccessible({ - parentPageId: notionPageId, - }); - if (!destinationIsAccessible) { - throw new Error('Destination is not accessible'); - } - // Check if the GitHub repository is accessible - try { - await this.sourceRepository.sourceIsAccessible(others); - } - catch (err) { - throw new Error(`Source is not accessible:`, { - cause: err, - }); - } this.logger.info('Starting synchronization process'); const filePaths = await this.sourceRepository.getFilePathList(others); const siteMap = sitemap_1.SiteMap.buildFromFilePaths(filePaths); // Traverse the SiteMap and synchronize files await this.synchronizeTreeNode({ node: siteMap.root, - parentPageId: notionPageId, + parentObjectId: notionObjectId, + parentObjectType, lockPage, }); this.logger.info('Synchronization process completed successfully'); @@ -75616,107 +75970,151 @@ class SynchronizeMarkdownToNotion { throw error; } } - async synchronizeTreeNode({ node, parentPageId, lockPage, }) { - // If the current node has content AND is the root node, add it to the parent page - if (node.filepath && node.parent === null) { - try { - this.logger.info(`Adding content from ${node.filepath} to parent page`); - // Retrieve the file content - const file = await this.sourceRepository.getFile({ - path: node.filepath, - }); - // Set the current file path for image resolution - if (this.elementConverter.setCurrentFilePath) { - this.elementConverter.setCurrentFilePath(node.filepath); - } - // Convert the file content to elements - const pageElement = this.elementConverter.convertToElement(file); - if (!(pageElement instanceof elements_1.PageElement)) { - throw new Error('Element is not a PageElement'); + /** + * Fetches a file and converts it to a PageElement + */ + async fetchAndConvertToPageElement(filePath) { + const file = await this.sourceRepository.getFile({ path: filePath }); + if (this.elementConverter.setCurrentFilePath) { + this.elementConverter.setCurrentFilePath(filePath); + } + const element = this.elementConverter.convertToElement(file); + if (!(element instanceof elements_1.PageElement)) { + throw new Error('Element is not a PageElement'); + } + return element; + } + /** + * Locks a page if locking is enabled + */ + async lockPageIfNeeded(pageId, shouldLock) { + if (shouldLock) { + await this.destinationRepository.setPageLockedStatus({ + pageId, + lockStatus: 'locked', + }); + this.logger.info(`Locked page ${pageId}`); + } + } + /** + * Synchronizes the root node to the parent object (page or database) + * Returns the page ID to use as parent for child nodes + */ + async synchronizeRootNode({ node, parentObjectId, parentObjectType, lockPage, }) { + this.logger.info(`Adding content from ${node.filepath} to parent ${parentObjectType}`); + const pageElement = await this.fetchAndConvertToPageElement(node.filepath); + if (parentObjectType === 'page') { + await this.destinationRepository.appendToPage({ + pageId: parentObjectId, + pageElement, + }); + this.logger.info(`Added content from ${node.filepath} to parent page`); + await this.lockPageIfNeeded(parentObjectId, lockPage); + return parentObjectId; + } + // parentObjectType === 'database' + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + filePath: node.filepath, + }); + if (!newPage.pageId) { + throw new Error('New page ID is undefined'); + } + return newPage.pageId; + } + /** + * Synchronizes a child node and its descendants recursively + */ + async synchronizeChildNode({ childNode, parentPageId, lockPage, }) { + const filePath = childNode.filepath; + this.logger.info(`Processing file: ${filePath}`); + const pageElement = await this.fetchAndConvertToPageElement(filePath); + // Add standard elements at the beginning (in reverse order) + pageElement.addElementToBeginning(new elements_1.TableOfContentsElement()); + pageElement.addElementToBeginning(new elements_1.DividerElement()); + if (childNode.children.length > 0) { + pageElement.addElementToEnd(new elements_1.DividerElement()); + } + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId: parentPageId, + parentObjectType: 'page', + filePath, + }); + this.logger.info(`Created Notion page for file: ${filePath}`); + if (!newPage.pageId) { + throw new Error('Page ID is undefined'); + } + // Recursively process children + for (const grandChild of childNode.children) { + await this.synchronizeChildNode({ + childNode: grandChild, + parentPageId: newPage.pageId, + lockPage, + }); + } + await this.lockPageIfNeeded(newPage.pageId, lockPage); + } + /** + * Main orchestrator for synchronizing a tree node and its children + */ + async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, }) { + let parentPageId = parentObjectId; + switch (parentObjectType) { + case 'unknown': + throw new Error('Parent object type is unknown'); + case 'database': + if (this.getIsRootNode(node)) { + parentPageId = await this.synchronizeRootNode({ + node, + parentObjectId, + parentObjectType, + lockPage, + }); } - // Add the content to the existing parent page by appending it - await this.destinationRepository.appendToPage({ - pageId: parentPageId, - pageElement, - }); - this.logger.info(`Added content from ${node.filepath} to parent page`); - if (lockPage) { - await this.destinationRepository.setPageLockedStatus({ - pageId: parentPageId, - lockStatus: 'locked', + else { + parentPageId = await this.synchronizeRootNode({ + node: node.children[0], + parentObjectId, + parentObjectType, + lockPage, }); - this.logger.info(`Locked parent page ${parentPageId}`); } - } - catch (error) { - if (error instanceof Error) { - this.logger.error(`Failed to add content from ${node.filepath} to parent page`, { - error, + break; + case 'page': + if (this.getIsRootNode(node)) { + parentPageId = await this.synchronizeRootNode({ + node, + parentObjectId, + parentObjectType, + lockPage, }); } - throw error; - } + break; + default: + throw new Error('Invalid parent object type'); } for (const childNode of node.children) { - const filePath = childNode.filepath; - this.logger.info(`Processing file: ${filePath}`); try { - // Retrieve the file from the source repository - const file = await this.sourceRepository.getFile({ - path: filePath, - }); - // Set the current file path for image resolution - if (this.elementConverter.setCurrentFilePath) { - this.elementConverter.setCurrentFilePath(filePath); - } - // Convert the file content to a Notion page element - const pageElement = this.elementConverter.convertToElement(file); - if (!(pageElement instanceof elements_1.PageElement)) { - throw new Error('Element is not a PageElement'); - } - [new elements_1.DividerElement(), new elements_1.TableOfContentsElement()].forEach((element) => { - pageElement.addElementToBeginning(element); - }); - if (childNode.children.length > 0) { - // Add divider to the end of the page - pageElement.addElementToEnd(new elements_1.DividerElement()); - } - // Create the Notion page and get the new page ID - const newPage = await this.destinationRepository.createPage({ - pageElement, + await this.synchronizeChildNode({ + childNode, parentPageId, - filePath, + lockPage, }); - this.logger.info(`Created Notion page for file: ${filePath}`); - // Recursively process the children of the current node - if (childNode.children.length > 0) { - if (newPage.pageId === undefined) { - throw new Error('Page ID is undefined'); - } - await this.synchronizeTreeNode({ - node: childNode, - parentPageId: newPage.pageId, - lockPage, - }); - } - if (lockPage && newPage.pageId) { - await this.destinationRepository.setPageLockedStatus({ - pageId: newPage.pageId, - lockStatus: 'locked', - }); - this.logger.info(`Locked page ${newPage.pageId}`); - } } catch (error) { - if (error instanceof Error) { - this.logger.error(`Failed to synchronize file: ${filePath}`, { - error, - }); - } + this.logger.error(`Failed to synchronize file: ${childNode.filepath}`, { + error, + }); throw error; } } } + getIsRootNode(node) { + return node.parent === null && !['', undefined].includes(node.filepath); + } } exports.SynchronizeMarkdownToNotion = SynchronizeMarkdownToNotion; @@ -75796,6 +76194,18 @@ class NotionPage { exports.NotionPage = NotionPage; +/***/ }), + +/***/ 8642: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.MK_NOTES_INTERNAL_ID_PROPERTY_NAME = void 0; +exports.MK_NOTES_INTERNAL_ID_PROPERTY_NAME = 'mk-notes-id'; + + /***/ }), /***/ 8109: @@ -75829,7 +76239,7 @@ exports.isNotionNestingValidationError = isNotionNestingValidationError; /***/ }), -/***/ 8434: +/***/ 8188: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -77133,12 +77543,18 @@ class MarkdownParser extends elements_1.ParserRepository { content: elements, }; const fileMetadata = this.getMetadata(content); + if (fileMetadata.id) { + result.mkNotesInternalId = fileMetadata.id; + } if (fileMetadata.title) { result.title = fileMetadata.title; } if (fileMetadata.icon) { result.icon = fileMetadata.icon; } + if (fileMetadata.properties && Array.isArray(fileMetadata.properties)) { + result.properties = fileMetadata.properties; + } return result; } } @@ -77524,7 +77940,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); __exportStar(__nccwpck_require__(3913), exports); -__exportStar(__nccwpck_require__(8434), exports); +__exportStar(__nccwpck_require__(8188), exports); __exportStar(__nccwpck_require__(2792), exports); __exportStar(__nccwpck_require__(6918), exports); __exportStar(__nccwpck_require__(3942), exports); @@ -77575,6 +77991,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.NotionConverterRepository = void 0; const path = __importStar(__nccwpck_require__(6928)); const elements_1 = __nccwpck_require__(7591); +const constants_1 = __nccwpck_require__(8642); const NotionPage_1 = __nccwpck_require__(3913); const SUPPORTED_IMAGE_URL_EXTENSIONS = [ '.bmp', @@ -77619,7 +78036,323 @@ class NotionConverterRepository { // Relative paths or absolute local paths return true; } - async convertPageElement(element) { + // ============================================ + // Property Conversion Functions + // ============================================ + /** + * Converts a string value to a Notion TitleProperty + */ + convertToTitleProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], + }; + } + /** + * Converts a string value to a Notion RichTextProperty + */ + convertToRichTextProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'rich_text', + rich_text: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], + }; + } + /** + * Converts a string value to a Notion NumberProperty + * Returns null if the value cannot be parsed as a number + */ + convertToNumberProperty(value) { + if (typeof value !== 'number') { + throw new Error(`Invalid value type: ${typeof value}`); + } + const parsed = value; + if (isNaN(parsed)) { + this.logger.warn(`Cannot convert "${value}" to number property, skipping`); + return null; + } + return { + type: 'number', + number: parsed, + }; + } + /** + * Converts a string value to a Notion UrlProperty + */ + convertToUrlProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'url', + url: value || null, + }; + } + /** + * Converts a string value to a Notion SelectProperty + * The value should match one of the available options in the database property definition + */ + convertToSelectProperty(value, propertyDefinition) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + // Type-narrow to access select options + if (propertyDefinition.type === 'select') { + const { options } = propertyDefinition.select; + const matchingOption = options.find((opt) => opt.name.toLowerCase() === value.toLowerCase()); + if (matchingOption) { + return { + type: 'select', + select: { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }, + }; + } + } + // If no matching option found, create a new one with the provided value + // Notion will create the option if it doesn't exist + return { + type: 'select', + select: { + id: '', + name: value, + }, + }; + } + /** + * Converts a string value to a Notion MultiSelectProperty + * The value should be a comma-separated list of option names + */ + convertToMultiSelectProperty(value, propertyDefinition) { + if (typeof value !== 'string' && !Array.isArray(value)) { + throw new Error(`Invalid value type: ${typeof value}`); + } + const values = value instanceof Array ? value : [value]; + // Type-narrow to access multi_select options + const options = propertyDefinition.type === 'multi_select' + ? propertyDefinition.multi_select.options + : []; + const multiSelectOptions = values.map((val) => { + const matchingOption = options.find((opt) => opt.name.toLowerCase() === String(val).toLowerCase()); + if (matchingOption) { + return { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }; + } + // Create new option if not found + return { + id: '', + name: String(val), + }; + }); + return { + type: 'multi_select', + multi_select: multiSelectOptions, + }; + } + /** + * Converts a string value to a Notion DateProperty + * Supports ISO 8601 date strings (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss) + */ + convertToDateProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + // Try to parse the date + const date = new Date(value); + if (isNaN(date.getTime())) { + this.logger.warn(`Cannot convert "${value}" to date property, skipping`); + return null; + } + // Format as ISO string (Notion expects ISO 8601 format) + return { + type: 'date', + date: value, // Pass the original value if it's already in ISO format + }; + } + /** + * Converts a string value to a Notion CheckboxProperty + * Accepts: "true", "false", "yes", "no", "1", "0" + */ + convertToCheckboxProperty(value) { + if (typeof value !== 'string' && typeof value !== 'boolean') { + throw new Error(`Invalid value type: ${typeof value}`); + } + if (typeof value === 'boolean') { + return { + type: 'checkbox', + checkbox: value, + }; + } + const normalizedValue = value.toLowerCase().trim(); + const trueValues = ['true', 'yes', '1', 'on', 'checked']; + const isChecked = trueValues.includes(normalizedValue); + return { + type: 'checkbox', + checkbox: isChecked, + }; + } + /** + * Converts a string value to a Notion EmailProperty + */ + convertToEmailProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'email', + email: value || null, + }; + } + /** + * Converts a string value to a Notion PhoneNumberProperty + */ + convertToPhoneNumberProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'phone_number', + phone_number: value || null, + }; + } + /** + * Converts a string value to a Notion StatusProperty + * The value should match one of the available status options in the database property definition + */ + convertToStatusProperty(value, propertyDefinition) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + // Type-narrow to access status options + if (propertyDefinition.type === 'status') { + const { options } = propertyDefinition.status; + const matchingOption = options.find((opt) => opt.name.toLowerCase() === value.toLowerCase()); + if (matchingOption) { + return { + type: 'status', + status: { + id: matchingOption.id, + name: matchingOption.name, + color: matchingOption.color, + }, + }; + } + } + // If no matching option found, use the value as-is + // Note: Notion may reject this if the status doesn't exist + return { + type: 'status', + status: { + id: '', + name: value, + }, + }; + } + /** + * Converts a PageElementProperty to the appropriate Notion property based on the database property definition + */ + convertPropertyValue(value, propertyDefinition) { + if (value instanceof Array) { + if (propertyDefinition.type === 'multi_select') { + return this.convertToMultiSelectProperty(value, propertyDefinition); + } + this.logger.warn(`Unsupported array value for property type "${propertyDefinition.type}"`); + return null; + } + switch (propertyDefinition.type) { + case 'title': + return this.convertToTitleProperty(value); + case 'rich_text': + return this.convertToRichTextProperty(value); + case 'number': + return this.convertToNumberProperty(value); + case 'url': + return this.convertToUrlProperty(value); + case 'select': + return this.convertToSelectProperty(value, propertyDefinition); + case 'multi_select': + return this.convertToMultiSelectProperty(value, propertyDefinition); + case 'date': + return this.convertToDateProperty(value); + case 'checkbox': + return this.convertToCheckboxProperty(value); + case 'email': + return this.convertToEmailProperty(value); + case 'phone_number': + return this.convertToPhoneNumberProperty(value); + case 'status': + return this.convertToStatusProperty(value, propertyDefinition); + case 'people': + case 'files': + case 'relation': + case 'formula': + case 'rollup': + case 'created_time': + case 'created_by': + case 'last_edited_time': + case 'last_edited_by': + case 'unique_id': + this.logger.warn(`Property type "${propertyDefinition.type}" cannot be converted from string, skipping property "${propertyDefinition.name}"`); + return null; + } + } + /** + * Converts page element properties to Notion PageProperties based on database property definitions + * + * @param properties - Array of PageElementProperties from the markdown frontmatter + * @param notionPropertyDefinitions - Array of database property definitions from Notion + * @returns PageProperties object ready to be used in Notion API calls + */ + convertPageElementProperties(properties, notionProperties = []) { + const result = {}; + // Create a map of property definitions by name for quick lookup + const definitionMap = new Map(); + for (const property of notionProperties) { + definitionMap.set(property.name, property.definition); + } + // Convert each page element property + for (const property of properties ?? []) { + const definition = definitionMap.get(property.name); + if (!definition) { + this.logger.warn(`No matching Notion property definition found for "${property.name}", skipping`); + continue; + } + const convertedValue = this.convertPropertyValue(property.value, definition); + if (convertedValue !== null) { + // Use the original definition name to preserve casing + result[definition.name] = convertedValue; + } + } + return result; + } + async convertPageElement(element, notionPropertyDefinitions = []) { const title = { id: 'title', type: 'title', @@ -77633,10 +78366,20 @@ class NotionConverterRepository { }, ], }; + const elementProperties = element.properties ?? []; + if (element.mkNotesInternalId) { + elementProperties.push({ + name: constants_1.MK_NOTES_INTERNAL_ID_PROPERTY_NAME, + value: element.mkNotesInternalId, + }); + } + // Convert page element properties to Notion properties + const convertedProperties = this.convertPageElementProperties(elementProperties, notionPropertyDefinitions); const result = { children: [], properties: { title, + ...convertedProperties, }, }; for (const contentElement of element.content) { @@ -77686,8 +78429,8 @@ class NotionConverterRepository { return null; } } - async convertFromElement(element) { - const notionPageInput = await this.convertPageElement(element); + async convertFromElement(element, availableProperties = []) { + const notionPageInput = await this.convertPageElement(element, availableProperties); return NotionPage_1.NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); } convertText(element) { @@ -78159,37 +78902,33 @@ class NotionDestinationRepository { throw error instanceof Error ? error : new Error(String(error)); } } - getPageIdFromPageUrl({ pageUrl }) { - const urlObj = new URL(pageUrl); - const pathSegments = urlObj.pathname.split('-'); - let lastSegment = pathSegments[pathSegments.length - 1]; - /** - * If the URL has a query parameter `v`, it's becase it's a Notion Database - * Unfortunatly, for now, mk-notes doesn't support Notion Databases - **/ - if (urlObj.searchParams.has('v')) { - throw new Error('Notion Databases are not supported yet. Please use a Notion Page URL'); - } - if (lastSegment.startsWith('/')) { - lastSegment = lastSegment.slice(1); - } - const [lastSegmentWithoutQueryParams] = lastSegment.split('?'); - if (!lastSegmentWithoutQueryParams) { - throw new Error('Invalid Notion URL'); - } - if (lastSegmentWithoutQueryParams.length !== 32) { - throw new Error('Invalid Notion URL'); + getObjectIdFromObjectUrl({ objectUrl }) { + const urlObj = new URL(objectUrl); + // Notion IDs are 32-character hexadecimal strings (UUID without dashes) + // They can be embedded in path segments like "MK-Notes-4dd0bd3dc73648a9a55dcf05dd03080f" + const notionIdRegex = /[a-f0-9]{32}/gi; + const matches = urlObj.pathname.match(notionIdRegex); + if (!matches || matches.length === 0) { + throw new Error('Invalid Notion URL: No valid Notion ID found'); } - return lastSegmentWithoutQueryParams; + // Return the last match (closest to the end of the URL path) + return matches[matches.length - 1]; } - async destinationIsAccessible({ parentPageId, }) { + async destinationIsAccessible({ parentObjectId, }) { try { - await this.getPage({ pageId: parentPageId }); + await this.getPage({ pageId: parentObjectId }); return true; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { - return false; + try { + await this.getDatabaseById({ databaseId: parentObjectId }); + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (_err) { + return false; + } } } async getPageById({ notionPageId, }) { @@ -78208,16 +78947,41 @@ class NotionDestinationRepository { isLocked: pageObjectResponse.is_locked ?? false, }); } - async createPage({ parentPageId, pageElement, filePath, }) { + async createPage({ parentObjectId, parentObjectType, pageElement, filePath, }) { // Set the current file path for image resolution if (filePath) { this.notionConverter.setCurrentFilePath(filePath); } - const notionPage = await this.notionConverter.convertFromElement(pageElement); + if (parentObjectType === 'unknown') { + throw new Error('Unknown parent object type'); + } + let parent; + const availableProperties = []; + if (parentObjectType === 'page') { + parent = { type: 'page_id', page_id: parentObjectId }; + } + if (parentObjectType === 'database') { + const database = await this.getDatabaseById({ + databaseId: parentObjectId, + }); + if (!('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + const datasource = await this.getDatasourceByDatasourceId({ + datasourceId: database.data_sources[0].id, + }); + parent = { type: 'data_source_id', data_source_id: datasource.id }; + availableProperties.push(...Object.entries(datasource.properties).map(([name, property]) => ({ + name, + definition: property, + type: property.type, + }))); + } + const notionPage = await this.notionConverter.convertFromElement(pageElement, availableProperties); const NOTION_BLOCK_LIMIT = 100; // First create the page without children const { id: notionPageId } = await this.client.pages.create({ - parent: { type: 'page_id', page_id: parentPageId }, + parent, properties: notionPage.properties, icon: notionPage.icon, children: [], // Create page without children initially @@ -78368,6 +79132,31 @@ class NotionDestinationRepository { } return isLocked ? 'locked' : 'unlocked'; } + async getDatabaseById({ databaseId, }) { + return this.client.databases.retrieve({ + database_id: databaseId, + }); + } + async getObjectType({ id, }) { + try { + await this.client.pages.retrieve({ page_id: id }); + return 'page'; + } + catch { + try { + await this.client.databases.retrieve({ database_id: id }); + return 'database'; + } + catch { + return 'unknown'; + } + } + } + async getDatasourceByDatasourceId({ datasourceId, }) { + return this.client.dataSources.retrieve({ + data_source_id: datasourceId, + }); + } } exports.NotionDestinationRepository = NotionDestinationRepository; @@ -84762,7 +85551,7 @@ var lexer = _Lexer.lex; /***/ ((module) => { "use strict"; -module.exports = /*#__PURE__*/JSON.parse('{"name":"@notionhq/client","version":"5.1.0","description":"A simple and easy to use client for the Notion API","engines":{"node":">=18"},"homepage":"https://developers.notion.com/docs/getting-started","bugs":{"url":"https://github.com/makenotion/notion-sdk-js/issues"},"repository":{"type":"git","url":"https://github.com/makenotion/notion-sdk-js/"},"keywords":["notion","notionapi","rest","notion-api"],"main":"./build/src","types":"./build/src/index.d.ts","scripts":{"prepare":"npm run build","prepublishOnly":"npm run checkLoggedIn && npm run lint && npm run test","build":"tsc","prettier":"prettier --write .","lint":"prettier --check . && eslint . --ext .ts && cspell \'**/*\' ","test":"jest ./test","check-links":"git ls-files | grep md$ | xargs -n 1 markdown-link-check","prebuild":"npm run clean","clean":"rm -rf ./build","checkLoggedIn":"./scripts/verifyLoggedIn.sh","install:examples":"for dir in examples/*/; do echo \\"Installing dependencies in $dir...\\"; (cd \\"$dir\\" && npm install); done","examples:install":"npm run install:examples","examples:typecheck":"for dir in examples/*/; do echo \\"Typechecking $dir...\\"; (cd \\"$dir\\" && npx tsc --noEmit) || exit 1; done"},"author":"","license":"MIT","files":["build/package.json","build/src/**"],"devDependencies":{"@types/jest":"28.1.4","@typescript-eslint/eslint-plugin":"5.39.0","@typescript-eslint/parser":"5.39.0","cspell":"5.4.1","eslint":"7.24.0","jest":"28.1.2","markdown-link-check":"3.13.7","prettier":"2.8.8","ts-jest":"28.0.5","typescript":"5.9.2"}}'); +module.exports = /*#__PURE__*/JSON.parse('{"name":"@notionhq/client","version":"5.4.0","description":"A simple and easy to use client for the Notion API","engines":{"node":">=18"},"homepage":"https://developers.notion.com/docs/getting-started","bugs":{"url":"https://github.com/makenotion/notion-sdk-js/issues"},"repository":{"type":"git","url":"https://github.com/makenotion/notion-sdk-js/"},"keywords":["notion","notionapi","rest","notion-api"],"main":"./build/src","types":"./build/src/index.d.ts","scripts":{"prepare":"npm run build","prepublishOnly":"npm run checkLoggedIn && npm run lint && npm run test","build":"tsc","prettier":"prettier --write .","lint":"prettier --check . && eslint . --ext .ts && cspell \'**/*\' ","test":"jest ./test","check-links":"git ls-files | grep md$ | xargs -n 1 markdown-link-check","prebuild":"npm run clean","clean":"rm -rf ./build","checkLoggedIn":"./scripts/verifyLoggedIn.sh","install:examples":"for dir in examples/*/; do echo \\"Installing dependencies in $dir...\\"; (cd \\"$dir\\" && npm install); done","examples:install":"npm run install:examples","examples:typecheck":"for dir in examples/*/; do echo \\"Typechecking $dir...\\"; (cd \\"$dir\\" && npx tsc --noEmit) || exit 1; done"},"author":"","license":"MIT","files":["build/package.json","build/src/**"],"devDependencies":{"@types/jest":"28.1.4","@typescript-eslint/eslint-plugin":"5.39.0","@typescript-eslint/parser":"5.39.0","cspell":"5.4.1","eslint":"7.24.0","jest":"28.1.2","markdown-link-check":"3.13.7","prettier":"2.8.8","ts-jest":"28.0.5","typescript":"5.9.2"}}'); /***/ }), From 379f072870d09ce2be3158cf75a6186a8ac494b1 Mon Sep 17 00:00:00 2001 From: Myastr0 <14167316+Myastr0@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:14:58 +0100 Subject: [PATCH 3/6] feat(wip): wip --- .../__fakes__/fakeDestination.repository.ts | 23 +++++ .../features/synchronizeMarkdownToNotion.ts | 98 ++++++++++++++++--- .../synchronization/destination.repository.ts | 13 +++ .../notion/notion.destination.ts | 38 +++++++ 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/__tests__/__fakes__/fakeDestination.repository.ts b/__tests__/__fakes__/fakeDestination.repository.ts index e384586..262cd21 100644 --- a/__tests__/__fakes__/fakeDestination.repository.ts +++ b/__tests__/__fakes__/fakeDestination.repository.ts @@ -115,4 +115,27 @@ export class FakeDestinationRepository }): Promise { return 'unlocked'; } + + async getObjectIdInDatabaseByMkNotesInternalId({ + dataSourceId, + mkNotesInternalId, + }: { + dataSourceId: string; + mkNotesInternalId: string; + }): Promise { + return Promise.resolve([]); + } + + async getDataSourceIdFromDatabaseId({ + databaseId, + }: { + databaseId: string; + }): Promise { + return Promise.resolve(''); + } + + async deleteObjectById({ objectId }: { objectId: string }): Promise { + // no-op in fake repository for testing + return Promise.resolve(); + } } diff --git a/src/domains/features/synchronizeMarkdownToNotion.ts b/src/domains/features/synchronizeMarkdownToNotion.ts index 70f092d..2df2df7 100644 --- a/src/domains/features/synchronizeMarkdownToNotion.ts +++ b/src/domains/features/synchronizeMarkdownToNotion.ts @@ -82,22 +82,6 @@ export class SynchronizeMarkdownToNotion { throw new Error('Parent object type is unknown'); } - // If clean sync is enabled, delete all existing content first - if (cleanSync) { - this.logger.info('Clean sync enabled - removing existing content'); - try { - await this.destinationRepository.deleteChildBlocks({ - parentPageId: notionObjectId, - }); - this.logger.info('Successfully removed existing content'); - } catch (error) { - this.logger.warn( - 'Failed to remove existing content, continuing with sync', - { error } - ); - } - } - try { this.logger.info('Starting synchronization process'); @@ -113,6 +97,7 @@ export class SynchronizeMarkdownToNotion { parentObjectId: notionObjectId, parentObjectType, lockPage, + cleanSync, }); this.logger.info('Synchronization process completed successfully'); @@ -172,11 +157,13 @@ export class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }: { node: TreeNode; parentObjectId: string; parentObjectType: ObjectType; lockPage: boolean; + cleanSync: boolean; }): Promise { this.logger.info( `Adding content from ${node.filepath} to parent ${parentObjectType}` @@ -184,11 +171,32 @@ export class SynchronizeMarkdownToNotion { const pageElement = await this.fetchAndConvertToPageElement(node.filepath); + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } + if (parentObjectType === 'page') { + // If clean sync is enabled, delete all existing content first + if (cleanSync) { + this.logger.info('Clean sync enabled - removing existing content'); + try { + await this.destinationRepository.deleteChildBlocks({ + parentPageId: parentObjectId, + }); + this.logger.info('Successfully removed existing content'); + } catch (error) { + this.logger.warn( + 'Failed to remove existing content, continuing with sync', + { error } + ); + } + } + await this.destinationRepository.appendToPage({ pageId: parentObjectId, pageElement, }); + this.logger.info(`Added content from ${node.filepath} to parent page`); await this.lockPageIfNeeded(parentObjectId, lockPage); @@ -196,6 +204,13 @@ export class SynchronizeMarkdownToNotion { return parentObjectId; } + if (cleanSync) { + await this.cleanSyncDatabase({ + databaseId: parentObjectId, + pageElement, + }); + } + // parentObjectType === 'database' const newPage = await this.destinationRepository.createPage({ pageElement, @@ -211,6 +226,52 @@ export class SynchronizeMarkdownToNotion { return newPage.pageId; } + private async cleanSyncDatabase({ + databaseId, + pageElement, + }: { + databaseId: string; + pageElement: PageElement; + }): Promise { + const dataSourceId = + await this.destinationRepository.getDataSourceIdFromDatabaseId({ + databaseId, + }); + + if (pageElement.mkNotesInternalId === undefined) { + this.logger.warn( + 'mk-notes-internal-id is undefined, skipping clean sync' + ); + return; + } + + const objectIds = + await this.destinationRepository.getObjectIdInDatabaseByMkNotesInternalId( + { + dataSourceId, + mkNotesInternalId: pageElement.mkNotesInternalId, + } + ); + + if (objectIds.length === 0) { + this.logger.warn('No object IDs found, skipping clean sync'); + return; + } + + if (objectIds.length > 1) { + this.logger.info( + `Multiple object IDs found with ${pageElement.mkNotesInternalId}, deleting all objects` + ); + } + + await Promise.all( + objectIds.map(async (objectId) => + this.destinationRepository.deleteObjectById({ + objectId, + }) + ) + ); + } /** * Synchronizes a child node and its descendants recursively */ @@ -269,11 +330,13 @@ export class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }: { node: TreeNode; parentObjectId: string; parentObjectType: ObjectType; lockPage: boolean; + cleanSync: boolean; }): Promise { let parentPageId: string = parentObjectId; @@ -287,6 +350,7 @@ export class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } else { parentPageId = await this.synchronizeRootNode({ @@ -294,6 +358,7 @@ export class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } break; @@ -304,6 +369,7 @@ export class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } break; diff --git a/src/domains/synchronization/destination.repository.ts b/src/domains/synchronization/destination.repository.ts index c022fbb..e7a34a0 100644 --- a/src/domains/synchronization/destination.repository.ts +++ b/src/domains/synchronization/destination.repository.ts @@ -42,6 +42,7 @@ export interface DestinationRepository { }: { parentPageId: string; }) => Promise; + deleteObjectById: ({ objectId }: { objectId: string }) => Promise; appendToPage: ({ pageId, pageElement, @@ -69,4 +70,16 @@ export interface DestinationRepository { pageId: string; }) => Promise; getObjectType: ({ id }: { id: string }) => Promise; + getObjectIdInDatabaseByMkNotesInternalId: ({ + dataSourceId, + mkNotesInternalId, + }: { + dataSourceId: string; + mkNotesInternalId: string; + }) => Promise; + getDataSourceIdFromDatabaseId: ({ + databaseId, + }: { + databaseId: string; + }) => Promise; } diff --git a/src/infrastructure/notion/notion.destination.ts b/src/infrastructure/notion/notion.destination.ts index 4245ae1..46d86a8 100644 --- a/src/infrastructure/notion/notion.destination.ts +++ b/src/infrastructure/notion/notion.destination.ts @@ -11,6 +11,7 @@ import { import winston from 'winston'; import { PageElement } from '@/domains/elements/Element'; +import { MK_NOTES_INTERNAL_ID_PROPERTY_NAME } from '@/domains/notion/constants'; import { isNotionNestingValidationError, NotionNestingValidationError, @@ -483,6 +484,19 @@ export class NotionDestinationRepository } } + async getDataSourceIdFromDatabaseId({ + databaseId, + }: { + databaseId: string; + }): Promise { + const database = await this.getDatabaseById({ databaseId }); + + if (!('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + return database.data_sources[0].id; + } + async getDatasourceByDatasourceId({ datasourceId, }: { @@ -492,4 +506,28 @@ export class NotionDestinationRepository data_source_id: datasourceId, }); } + + async getObjectIdInDatabaseByMkNotesInternalId({ + dataSourceId, + mkNotesInternalId, + }: { + dataSourceId: string; + mkNotesInternalId: string; + }): Promise { + const items = await this.client.dataSources.query({ + data_source_id: dataSourceId, + filter: { + property: MK_NOTES_INTERNAL_ID_PROPERTY_NAME, + rich_text: { + equals: mkNotesInternalId, + }, + }, + }); + + return items.results.map((item) => item.id); + } + + async deleteObjectById({ objectId }: { objectId: string }): Promise { + await this.client.blocks.delete({ block_id: objectId }); + } } From 6cef82de7cc26341220709da3131d41075c454e7 Mon Sep 17 00:00:00 2001 From: Myastr0 <14167316+Myastr0@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:15:17 +0100 Subject: [PATCH 4/6] build(build actions): build actions --- preview/index.js | 89 ++++++++++++++++++++++++++++++++++++++++-------- sync/index.js | 89 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 148 insertions(+), 30 deletions(-) diff --git a/preview/index.js b/preview/index.js index 9937109..fa906bb 100644 --- a/preview/index.js +++ b/preview/index.js @@ -75895,19 +75895,6 @@ class SynchronizeMarkdownToNotion { if (parentObjectType === 'unknown') { throw new Error('Parent object type is unknown'); } - // If clean sync is enabled, delete all existing content first - if (cleanSync) { - this.logger.info('Clean sync enabled - removing existing content'); - try { - await this.destinationRepository.deleteChildBlocks({ - parentPageId: notionObjectId, - }); - this.logger.info('Successfully removed existing content'); - } - catch (error) { - this.logger.warn('Failed to remove existing content, continuing with sync', { error }); - } - } try { this.logger.info('Starting synchronization process'); const filePaths = await this.sourceRepository.getFilePathList(others); @@ -75918,6 +75905,7 @@ class SynchronizeMarkdownToNotion { parentObjectId: notionObjectId, parentObjectType, lockPage, + cleanSync, }); this.logger.info('Synchronization process completed successfully'); } @@ -75960,10 +75948,26 @@ class SynchronizeMarkdownToNotion { * Synchronizes the root node to the parent object (page or database) * Returns the page ID to use as parent for child nodes */ - async synchronizeRootNode({ node, parentObjectId, parentObjectType, lockPage, }) { + async synchronizeRootNode({ node, parentObjectId, parentObjectType, lockPage, cleanSync, }) { this.logger.info(`Adding content from ${node.filepath} to parent ${parentObjectType}`); const pageElement = await this.fetchAndConvertToPageElement(node.filepath); + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } if (parentObjectType === 'page') { + // If clean sync is enabled, delete all existing content first + if (cleanSync) { + this.logger.info('Clean sync enabled - removing existing content'); + try { + await this.destinationRepository.deleteChildBlocks({ + parentPageId: parentObjectId, + }); + this.logger.info('Successfully removed existing content'); + } + catch (error) { + this.logger.warn('Failed to remove existing content, continuing with sync', { error }); + } + } await this.destinationRepository.appendToPage({ pageId: parentObjectId, pageElement, @@ -75972,6 +75976,12 @@ class SynchronizeMarkdownToNotion { await this.lockPageIfNeeded(parentObjectId, lockPage); return parentObjectId; } + if (cleanSync) { + await this.cleanSyncDatabase({ + databaseId: parentObjectId, + pageElement, + }); + } // parentObjectType === 'database' const newPage = await this.destinationRepository.createPage({ pageElement, @@ -75984,6 +75994,29 @@ class SynchronizeMarkdownToNotion { } return newPage.pageId; } + async cleanSyncDatabase({ databaseId, pageElement, }) { + const dataSourceId = await this.destinationRepository.getDataSourceIdFromDatabaseId({ + databaseId, + }); + if (pageElement.mkNotesInternalId === undefined) { + this.logger.warn('mk-notes-internal-id is undefined, skipping clean sync'); + return; + } + const objectIds = await this.destinationRepository.getObjectIdInDatabaseByMkNotesInternalId({ + dataSourceId, + mkNotesInternalId: pageElement.mkNotesInternalId, + }); + if (objectIds.length === 0) { + this.logger.warn('No object IDs found, skipping clean sync'); + return; + } + if (objectIds.length > 1) { + this.logger.info(`Multiple object IDs found with ${pageElement.mkNotesInternalId}, deleting all objects`); + } + await Promise.all(objectIds.map(async (objectId) => this.destinationRepository.deleteObjectById({ + objectId, + }))); + } /** * Synchronizes a child node and its descendants recursively */ @@ -76020,7 +76053,7 @@ class SynchronizeMarkdownToNotion { /** * Main orchestrator for synchronizing a tree node and its children */ - async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, }) { + async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, cleanSync, }) { let parentPageId = parentObjectId; switch (parentObjectType) { case 'unknown': @@ -76032,6 +76065,7 @@ class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } else { @@ -76040,6 +76074,7 @@ class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } break; @@ -76050,6 +76085,7 @@ class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } break; @@ -78828,6 +78864,7 @@ exports.NotionConverterRepository = NotionConverterRepository; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.NotionDestinationRepository = void 0; const client_1 = __nccwpck_require__(8342); +const constants_1 = __nccwpck_require__(8642); const error_1 = __nccwpck_require__(8109); const NotionPage_1 = __nccwpck_require__(3913); const utils_1 = __nccwpck_require__(7100); @@ -79112,11 +79149,33 @@ class NotionDestinationRepository { } } } + async getDataSourceIdFromDatabaseId({ databaseId, }) { + const database = await this.getDatabaseById({ databaseId }); + if (!('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + return database.data_sources[0].id; + } async getDatasourceByDatasourceId({ datasourceId, }) { return this.client.dataSources.retrieve({ data_source_id: datasourceId, }); } + async getObjectIdInDatabaseByMkNotesInternalId({ dataSourceId, mkNotesInternalId, }) { + const items = await this.client.dataSources.query({ + data_source_id: dataSourceId, + filter: { + property: constants_1.MK_NOTES_INTERNAL_ID_PROPERTY_NAME, + rich_text: { + equals: mkNotesInternalId, + }, + }, + }); + return items.results.map((item) => item.id); + } + async deleteObjectById({ objectId }) { + await this.client.blocks.delete({ block_id: objectId }); + } } exports.NotionDestinationRepository = NotionDestinationRepository; diff --git a/sync/index.js b/sync/index.js index cb078e5..b01126d 100644 --- a/sync/index.js +++ b/sync/index.js @@ -75935,19 +75935,6 @@ class SynchronizeMarkdownToNotion { if (parentObjectType === 'unknown') { throw new Error('Parent object type is unknown'); } - // If clean sync is enabled, delete all existing content first - if (cleanSync) { - this.logger.info('Clean sync enabled - removing existing content'); - try { - await this.destinationRepository.deleteChildBlocks({ - parentPageId: notionObjectId, - }); - this.logger.info('Successfully removed existing content'); - } - catch (error) { - this.logger.warn('Failed to remove existing content, continuing with sync', { error }); - } - } try { this.logger.info('Starting synchronization process'); const filePaths = await this.sourceRepository.getFilePathList(others); @@ -75958,6 +75945,7 @@ class SynchronizeMarkdownToNotion { parentObjectId: notionObjectId, parentObjectType, lockPage, + cleanSync, }); this.logger.info('Synchronization process completed successfully'); } @@ -76000,10 +75988,26 @@ class SynchronizeMarkdownToNotion { * Synchronizes the root node to the parent object (page or database) * Returns the page ID to use as parent for child nodes */ - async synchronizeRootNode({ node, parentObjectId, parentObjectType, lockPage, }) { + async synchronizeRootNode({ node, parentObjectId, parentObjectType, lockPage, cleanSync, }) { this.logger.info(`Adding content from ${node.filepath} to parent ${parentObjectType}`); const pageElement = await this.fetchAndConvertToPageElement(node.filepath); + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } if (parentObjectType === 'page') { + // If clean sync is enabled, delete all existing content first + if (cleanSync) { + this.logger.info('Clean sync enabled - removing existing content'); + try { + await this.destinationRepository.deleteChildBlocks({ + parentPageId: parentObjectId, + }); + this.logger.info('Successfully removed existing content'); + } + catch (error) { + this.logger.warn('Failed to remove existing content, continuing with sync', { error }); + } + } await this.destinationRepository.appendToPage({ pageId: parentObjectId, pageElement, @@ -76012,6 +76016,12 @@ class SynchronizeMarkdownToNotion { await this.lockPageIfNeeded(parentObjectId, lockPage); return parentObjectId; } + if (cleanSync) { + await this.cleanSyncDatabase({ + databaseId: parentObjectId, + pageElement, + }); + } // parentObjectType === 'database' const newPage = await this.destinationRepository.createPage({ pageElement, @@ -76024,6 +76034,29 @@ class SynchronizeMarkdownToNotion { } return newPage.pageId; } + async cleanSyncDatabase({ databaseId, pageElement, }) { + const dataSourceId = await this.destinationRepository.getDataSourceIdFromDatabaseId({ + databaseId, + }); + if (pageElement.mkNotesInternalId === undefined) { + this.logger.warn('mk-notes-internal-id is undefined, skipping clean sync'); + return; + } + const objectIds = await this.destinationRepository.getObjectIdInDatabaseByMkNotesInternalId({ + dataSourceId, + mkNotesInternalId: pageElement.mkNotesInternalId, + }); + if (objectIds.length === 0) { + this.logger.warn('No object IDs found, skipping clean sync'); + return; + } + if (objectIds.length > 1) { + this.logger.info(`Multiple object IDs found with ${pageElement.mkNotesInternalId}, deleting all objects`); + } + await Promise.all(objectIds.map(async (objectId) => this.destinationRepository.deleteObjectById({ + objectId, + }))); + } /** * Synchronizes a child node and its descendants recursively */ @@ -76060,7 +76093,7 @@ class SynchronizeMarkdownToNotion { /** * Main orchestrator for synchronizing a tree node and its children */ - async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, }) { + async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, cleanSync, }) { let parentPageId = parentObjectId; switch (parentObjectType) { case 'unknown': @@ -76072,6 +76105,7 @@ class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } else { @@ -76080,6 +76114,7 @@ class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } break; @@ -76090,6 +76125,7 @@ class SynchronizeMarkdownToNotion { parentObjectId, parentObjectType, lockPage, + cleanSync, }); } break; @@ -78868,6 +78904,7 @@ exports.NotionConverterRepository = NotionConverterRepository; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.NotionDestinationRepository = void 0; const client_1 = __nccwpck_require__(8342); +const constants_1 = __nccwpck_require__(8642); const error_1 = __nccwpck_require__(8109); const NotionPage_1 = __nccwpck_require__(3913); const utils_1 = __nccwpck_require__(7100); @@ -79152,11 +79189,33 @@ class NotionDestinationRepository { } } } + async getDataSourceIdFromDatabaseId({ databaseId, }) { + const database = await this.getDatabaseById({ databaseId }); + if (!('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + return database.data_sources[0].id; + } async getDatasourceByDatasourceId({ datasourceId, }) { return this.client.dataSources.retrieve({ data_source_id: datasourceId, }); } + async getObjectIdInDatabaseByMkNotesInternalId({ dataSourceId, mkNotesInternalId, }) { + const items = await this.client.dataSources.query({ + data_source_id: dataSourceId, + filter: { + property: constants_1.MK_NOTES_INTERNAL_ID_PROPERTY_NAME, + rich_text: { + equals: mkNotesInternalId, + }, + }, + }); + return items.results.map((item) => item.id); + } + async deleteObjectById({ objectId }) { + await this.client.blocks.delete({ block_id: objectId }); + } } exports.NotionDestinationRepository = NotionDestinationRepository; From c961bd10e861b7ea8fc200876cea19847c69ded1 Mon Sep 17 00:00:00 2001 From: Myastr0 <14167316+Myastr0@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:20:28 +0100 Subject: [PATCH 5/6] test(refactor tests according new feature): refactor tests according new feature --- .../__fakes__/fakeDestination.repository.ts | 16 +- .../synchronizeMarkdownToNotion.test.ts | 27 ++- .../markdown/markdown.parser.test.ts | 56 +++++ .../notion/notion.destination.test.ts | 222 +++++++++++++++++- 4 files changed, 298 insertions(+), 23 deletions(-) diff --git a/__tests__/__fakes__/fakeDestination.repository.ts b/__tests__/__fakes__/fakeDestination.repository.ts index 262cd21..239f4e6 100644 --- a/__tests__/__fakes__/fakeDestination.repository.ts +++ b/__tests__/__fakes__/fakeDestination.repository.ts @@ -16,7 +16,19 @@ export class FakeDestinationRepository } getObjectIdFromObjectUrl({ objectUrl }: { objectUrl: string }): string { - return objectUrl.split('/').pop() ?? ''; + const urlObj = new URL(objectUrl); + + // Notion IDs are 32-character hexadecimal strings (UUID without dashes) + // They can be embedded in path segments like "MK-Notes-4dd0bd3dc73648a9a55dcf05dd03080f" + const notionIdRegex = /[a-f0-9]{32}/gi; + const matches = urlObj.pathname.match(notionIdRegex); + + if (!matches || matches.length === 0) { + throw new Error('Invalid Notion URL: No valid Notion ID found'); + } + + // Return the last match (closest to the end of the URL path) + return matches[matches.length - 1]; } // Simulate creating a new page @@ -24,9 +36,11 @@ export class FakeDestinationRepository async createPage({ pageElement, parentObjectId, + parentObjectType, }: { pageElement: PageElement; parentObjectId: string; + parentObjectType: ObjectType; }): Promise { // Here you would implement the logic to create a new page in the fake destination const fakePage = new FakePage({ diff --git a/src/domains/features/synchronizeMarkdownToNotion.test.ts b/src/domains/features/synchronizeMarkdownToNotion.test.ts index fb7c2d7..c5ffe56 100644 --- a/src/domains/features/synchronizeMarkdownToNotion.test.ts +++ b/src/domains/features/synchronizeMarkdownToNotion.test.ts @@ -36,6 +36,8 @@ describe('SynchronizeMarkdownToNotion', () => { const defaultArgs = { notionParentPageUrl: validNotionUrl, path: 'test/path', + cleanSync: false, + lockPage: false, }; beforeEach(() => { @@ -54,6 +56,9 @@ describe('SynchronizeMarkdownToNotion', () => { jest .spyOn(destinationRepository, 'createPage') .mockResolvedValue(new FakeNotionPage({ pageId: 'new-page-id' })); + jest + .spyOn(destinationRepository, 'getObjectType') + .mockResolvedValue('page'); }); it("should check the accessibility of the destination", async () => { @@ -61,7 +66,7 @@ describe('SynchronizeMarkdownToNotion', () => { expect( destinationRepository.destinationIsAccessible ).toHaveBeenCalledWith({ - parentPageId: 'Test-Page-12345678901234567890123456789012', + parentObjectId: '12345678901234567890123456789012', }); }); @@ -104,7 +109,8 @@ describe('SynchronizeMarkdownToNotion', () => { expect(elementConverter.convertToElement).toHaveBeenCalled(); expect(destinationRepository.createPage).toHaveBeenCalledWith({ pageElement: expect.any(PageElement), - parentPageId: 'Test-Page-12345678901234567890123456789012', + parentObjectId: '12345678901234567890123456789012', + parentObjectType: 'page', filePath: 'file1.md', }); }); @@ -191,7 +197,7 @@ describe('SynchronizeMarkdownToNotion', () => { // Verify that appendToPage is called exactly once (only for root index.md) expect(appendToPageSpy).toHaveBeenCalledTimes(1); expect(appendToPageSpy).toHaveBeenCalledWith({ - pageId: 'Test-Page-12345678901234567890123456789012', + pageId: '12345678901234567890123456789012', pageElement: rootContent, }); @@ -203,7 +209,8 @@ describe('SynchronizeMarkdownToNotion', () => { pageElement: expect.objectContaining({ title: 'Section Overview' }), - parentPageId: 'Test-Page-12345678901234567890123456789012', + parentObjectId: '12345678901234567890123456789012', + parentObjectType: 'page', filePath: '01_Section/00_Root.md', }); @@ -212,7 +219,8 @@ describe('SynchronizeMarkdownToNotion', () => { pageElement: expect.objectContaining({ title: 'Subsection' }), - parentPageId: 'new-page-id', // This should be the Section page ID + parentObjectId: 'new-page-id', // This should be the Section page ID + parentObjectType: 'page', filePath: '01_Section/01_Subsection.md', }); }); @@ -245,7 +253,7 @@ describe('SynchronizeMarkdownToNotion', () => { // Verify that setPageLockedStatus is called with locked status expect(setPageLockedStatusSpy).toHaveBeenCalledWith({ - pageId: 'Test-Page-12345678901234567890123456789012', + pageId: '12345678901234567890123456789012', lockStatus: 'locked', }); }); @@ -271,10 +279,7 @@ describe('SynchronizeMarkdownToNotion', () => { const setPageLockedStatusSpy = jest.spyOn(destinationRepository, 'setPageLockedStatus'); - await synchronizer.execute({ - ...defaultArgs, - lockPage: false, - }); + await synchronizer.execute(defaultArgs); // Verify that setPageLockedStatus is not called expect(setPageLockedStatusSpy).not.toHaveBeenCalled(); @@ -412,7 +417,7 @@ describe('SynchronizeMarkdownToNotion', () => { // Verify root page is locked expect(setPageLockedStatusSpy).toHaveBeenCalledWith({ - pageId: 'Test-Page-12345678901234567890123456789012', + pageId: '12345678901234567890123456789012', lockStatus: 'locked', }); diff --git a/src/infrastructure/markdown/markdown.parser.test.ts b/src/infrastructure/markdown/markdown.parser.test.ts index f73d16a..730fbd8 100644 --- a/src/infrastructure/markdown/markdown.parser.test.ts +++ b/src/infrastructure/markdown/markdown.parser.test.ts @@ -470,6 +470,62 @@ Content expect(result.content).toHaveLength(1); }); + it('should parse front matter metadata with id', () => { + const markdown = `--- +title: Test Title +id: unique-page-id-123 +icon: 📄 +--- +Content +`; + + const result = parser.parse({ content: markdown }); + + expect(result.title).toBe('Test Title'); + expect(result.mkNotesInternalId).toBe('unique-page-id-123'); + expect(result.icon).toBe('📄' as SupportedEmoji); + expect(result.content).toHaveLength(1); + }); + + it('should parse front matter metadata with properties array', () => { + const markdown = `--- +title: Database Entry +id: entry-001 +properties: + - name: status + value: active + - name: priority + value: high +--- +Content +`; + + const result = parser.parse({ content: markdown }); + + expect(result.title).toBe('Database Entry'); + expect(result.mkNotesInternalId).toBe('entry-001'); + expect(result.properties).toBeDefined(); + expect(result.properties).toHaveLength(2); + expect(result.properties).toEqual([ + { name: 'status', value: 'active' }, + { name: 'priority', value: 'high' }, + ]); + }); + + it('should handle front matter without optional id field', () => { + const markdown = `--- +title: Simple Page +--- +Content +`; + + const result = parser.parse({ content: markdown }); + + expect(result.title).toBe('Simple Page'); + expect(result.mkNotesInternalId).toBeUndefined(); + expect(result.properties).toBeUndefined(); + }); + it('should handle HTML content', () => { const markdown = '
HTML content
'; mockHtmlParser.parse.mockReturnValue({ diff --git a/src/infrastructure/notion/notion.destination.test.ts b/src/infrastructure/notion/notion.destination.test.ts index f591d94..d77897e 100644 --- a/src/infrastructure/notion/notion.destination.test.ts +++ b/src/infrastructure/notion/notion.destination.test.ts @@ -29,8 +29,17 @@ describe('NotionDestinationRepository', () => { blocks: { children: { list: jest.fn(), + append: jest.fn(), }, update: jest.fn(), + delete: jest.fn(), + }, + databases: { + retrieve: jest.fn(), + }, + dataSources: { + retrieve: jest.fn(), + query: jest.fn(), }, search: jest.fn(), } as unknown as jest.Mocked; @@ -111,7 +120,7 @@ describe('NotionDestinationRepository', () => { }); describe.skip('createPage', () => { - it('should create page with converted content', async () => { + it('should create page with converted content when parent is a page', async () => { const pageElement = new PageElement({ title: 'Test Page', content: [], @@ -143,7 +152,8 @@ describe('NotionDestinationRepository', () => { expect(mockClient.pages.create).toHaveBeenCalledWith({ parent: { type: 'page_id', page_id: 'parent-id' }, properties: mockNotionPage.properties, - children: mockNotionPage.children, + icon: undefined, + children: [], }); expect(result).toMatchObject({ @@ -151,6 +161,19 @@ describe('NotionDestinationRepository', () => { children: [], }); }); + + it('should throw an error when parent object type is unknown', async () => { + const pageElement = new PageElement({ + title: 'Test Page', + content: [], + }); + + await expect(repository.createPage({ + parentObjectId: 'parent-id', + parentObjectType: 'unknown', + pageElement, + })).rejects.toThrow('Unknown parent object type'); + }); }); // describe('updatePage', () => { @@ -258,7 +281,7 @@ describe('NotionDestinationRepository', () => { // }); // }); - describe('getPageIdFromPageUrl', () => { + describe('getObjectIdFromObjectUrl', () => { it('should extract the page ID from a standard Notion URL', () => { const pageUrl = 'https://www.notion.so/workspace/Test-Page-12345678901234567890123456789012'; const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); @@ -277,24 +300,25 @@ describe('NotionDestinationRepository', () => { expect(result).toBe('12345678901234567890123456789012'); }); - it('should throw an error for a URL without a page ID', () => { + it('should throw an error for a URL without a valid Notion ID', () => { const pageUrl = 'https://www.notion.so/workspace/Test-Page'; - expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL: No valid Notion ID found'); }); - it('should throw an error for a URL with an invalid page ID length', () => { + it('should throw an error for a URL with an invalid ID length', () => { const pageUrl = 'https://www.notion.so/workspace/Test-Page-123456'; - expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL: No valid Notion ID found'); }); - it('should throw an error for a non-Notion URL', () => { + it('should throw an error for a non-Notion URL without valid ID', () => { const pageUrl = 'https://example.com/some-page'; - expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL'); + expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Invalid Notion URL: No valid Notion ID found'); }); - it('should throw an error when the URL is a Notion Database', () => { + it('should extract the database ID from a Notion Database URL', () => { const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?v=7d43042815524daa9c5c3a7a4f8e1fe4&pvs=4'; - expect(() => repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl })).toThrow('Notion Databases are not supported yet. Please use a Notion Page URL'); + const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); + expect(result).toBe('16d4754ea1e980d1a2fdc2ab5fa4dfaf'); }); it('should extract the page ID from a URL with direct ID and query parameters', () => { @@ -302,6 +326,12 @@ describe('NotionDestinationRepository', () => { const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); expect(result).toBe('16d4754ea1e980d1a2fdc2ab5fa4dfaf'); }); + + it('should extract the ID from a URL with name prefix like MK-Notes-', () => { + const pageUrl = 'https://www.notion.so/MK-Notes-4dd0bd3dc73648a9a55dcf05dd03080f'; + const result = repository.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); + expect(result).toBe('4dd0bd3dc73648a9a55dcf05dd03080f'); + }); }); describe('setPageLockedStatus', () => { @@ -430,4 +460,174 @@ describe('NotionDestinationRepository', () => { }); }); + describe('getObjectType', () => { + it('should return "page" when the object is a page', async () => { + jest.spyOn(mockClient.pages, 'retrieve').mockResolvedValue({ + id: 'page-id', + object: 'page', + } as any); + + const result = await repository.getObjectType({ id: 'page-id' }); + + expect(result).toBe('page'); + expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ + page_id: 'page-id', + }); + }); + + it('should return "database" when the object is a database', async () => { + jest.spyOn(mockClient.pages, 'retrieve').mockRejectedValue(new Error('Not found')); + jest.spyOn(mockClient.databases, 'retrieve').mockResolvedValue({ + id: 'database-id', + object: 'database', + } as any); + + const result = await repository.getObjectType({ id: 'database-id' }); + + expect(result).toBe('database'); + expect(mockClient.databases.retrieve).toHaveBeenCalledWith({ + database_id: 'database-id', + }); + }); + + it('should return "unknown" when the object is neither a page nor a database', async () => { + jest.spyOn(mockClient.pages, 'retrieve').mockRejectedValue(new Error('Not found')); + jest.spyOn(mockClient.databases, 'retrieve').mockRejectedValue(new Error('Not found')); + + const result = await repository.getObjectType({ id: 'invalid-id' }); + + expect(result).toBe('unknown'); + }); + }); + + describe('getDatabaseById', () => { + it('should retrieve a database by ID', async () => { + const mockDatabase = { + id: 'database-id', + object: 'database', + title: [{ plain_text: 'Test Database' }], + }; + + jest.spyOn(mockClient.databases, 'retrieve').mockResolvedValue(mockDatabase as any); + + const result = await repository.getDatabaseById({ databaseId: 'database-id' }); + + expect(result).toEqual(mockDatabase); + expect(mockClient.databases.retrieve).toHaveBeenCalledWith({ + database_id: 'database-id', + }); + }); + }); + + describe('deleteObjectById', () => { + it('should delete an object by ID using blocks.delete', async () => { + jest.spyOn(mockClient.blocks, 'delete').mockResolvedValue({} as any); + + await repository.deleteObjectById({ objectId: 'object-id' }); + + expect(mockClient.blocks.delete).toHaveBeenCalledWith({ + block_id: 'object-id', + }); + }); + }); + + describe('getDataSourceIdFromDatabaseId', () => { + it('should return the data source ID from a database', async () => { + const mockDatabase = { + id: 'database-id', + object: 'database', + data_sources: [{ id: 'data-source-id' }], + }; + + jest.spyOn(mockClient.databases, 'retrieve').mockResolvedValue(mockDatabase as any); + + const result = await repository.getDataSourceIdFromDatabaseId({ databaseId: 'database-id' }); + + expect(result).toBe('data-source-id'); + }); + + it('should throw an error if database has no data sources', async () => { + const mockDatabase = { + id: 'database-id', + object: 'database', + }; + + jest.spyOn(mockClient.databases, 'retrieve').mockResolvedValue(mockDatabase as any); + + await expect(repository.getDataSourceIdFromDatabaseId({ databaseId: 'database-id' })) + .rejects.toThrow('Database does not have any datasources'); + }); + }); + + describe('getObjectIdInDatabaseByMkNotesInternalId', () => { + it('should return object IDs matching the mk-notes internal ID', async () => { + const mockQueryResult = { + results: [ + { id: 'object-1' }, + { id: 'object-2' }, + ], + }; + + jest.spyOn(mockClient.dataSources, 'query').mockResolvedValue(mockQueryResult as any); + + const result = await repository.getObjectIdInDatabaseByMkNotesInternalId({ + dataSourceId: 'data-source-id', + mkNotesInternalId: 'mk-notes-123', + }); + + expect(result).toEqual(['object-1', 'object-2']); + expect(mockClient.dataSources.query).toHaveBeenCalledWith({ + data_source_id: 'data-source-id', + filter: { + property: 'mk-notes-id', + rich_text: { + equals: 'mk-notes-123', + }, + }, + }); + }); + + it('should return empty array when no objects match', async () => { + const mockQueryResult = { + results: [], + }; + + jest.spyOn(mockClient.dataSources, 'query').mockResolvedValue(mockQueryResult as any); + + const result = await repository.getObjectIdInDatabaseByMkNotesInternalId({ + dataSourceId: 'data-source-id', + mkNotesInternalId: 'non-existent-id', + }); + + expect(result).toEqual([]); + }); + }); + + describe('destinationIsAccessible with database support', () => { + it('should return true when database is accessible', async () => { + jest.spyOn(mockClient.pages, 'retrieve').mockRejectedValue(new Error('Not found')); + jest.spyOn(mockClient.databases, 'retrieve').mockResolvedValue({ + id: 'database-id', + object: 'database', + } as any); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'database-id', + }); + + expect(result).toBe(true); + }); + + it('should return false when neither page nor database is accessible', async () => { + jest.spyOn(mockClient.pages, 'retrieve').mockRejectedValue(new Error('Not found')); + jest.spyOn(mockClient.databases, 'retrieve').mockRejectedValue(new Error('Not found')); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'invalid-id', + }); + + expect(result).toBe(false); + }); + }); + }); From d4e16125bf15c9d320c3f0440fe4de3be7cea38d Mon Sep 17 00:00:00 2001 From: Myastr0 <14167316+Myastr0@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:07:47 +0100 Subject: [PATCH 6/6] docs(update docs for database support): update docs for database support --- .../1-your-first-synchronization.mdx | 24 +- docs/content/docs/cli/guides/cli-commands.mdx | 83 +++++- .../content/docs/cli/guides/database-sync.mdx | 277 ++++++++++++++++++ docs/content/docs/cli/guides/meta.json | 1 + .../available-actions/2-sync-action.mdx | 98 ++++++- .../github-actions/first-synchronization.mdx | 12 +- docs/content/docs/index.mdx | 3 +- .../docs/writing/styling-notion-page.mdx | 25 +- 8 files changed, 486 insertions(+), 37 deletions(-) create mode 100644 docs/content/docs/cli/guides/database-sync.mdx diff --git a/docs/content/docs/cli/getting-started/1-your-first-synchronization.mdx b/docs/content/docs/cli/getting-started/1-your-first-synchronization.mdx index aabfcf5..68d636d 100644 --- a/docs/content/docs/cli/getting-started/1-your-first-synchronization.mdx +++ b/docs/content/docs/cli/getting-started/1-your-first-synchronization.mdx @@ -4,17 +4,17 @@ title: Your first synchronization description: Tutorial to make your first synchronization with Mk Notes --- -In this tutorial, you will learn how to synchronize your markdown files in a Notion page with Mk Notes. +In this tutorial, you will learn how to synchronize your markdown files to Notion with Mk Notes. --- ## Requirements -- A **Notion Integration** with read-write access on your Notion page +- A **Notion Integration** with read-write access on your Notion page or your Notion Database - Read the official - [Notion guide](https://developers.notion.com/docs/authorization) to create - and setup a Notion Integration on your workspace + Read the official [Notion + guide](https://developers.notion.com/docs/authorization) to create and setup + a Notion Integration on your workspace ## Step-by-step guide 👇 @@ -41,18 +41,22 @@ Launch the following command: ```bash mk-notes sync \ --input \ - --destination \ + --destination \ --notion-api-key ``` - `--input` : The path to your markdown file or directory containing markdown files. -- `--destination` : The Notion page URL where you want to synchronize your markdown files. +- `--destination` : The Notion page or Notion Database URL where you want to synchronize your markdown files. - `--notion-api-key` : Your Notion secret token. - - Please note that you can only synchronize markdown files to **Notion Pages**. - Notion Databases are not supported for now. + + Mk Notes supports both **Notion Pages** and **Notion Databases** as + destinations. When syncing to a database, pages are created as database items. + +For more information about synchronization into a database, you can read the [Database Synchronization](../guides/database-sync) guide. + + --- diff --git a/docs/content/docs/cli/guides/cli-commands.mdx b/docs/content/docs/cli/guides/cli-commands.mdx index ad0ff9f..99389f9 100644 --- a/docs/content/docs/cli/guides/cli-commands.mdx +++ b/docs/content/docs/cli/guides/cli-commands.mdx @@ -10,25 +10,56 @@ MK Notes provides two main commands to help you manage your markdown to Notion s ## `sync` -The `sync` command synchronizes markdown files to a Notion page. You can sync either a single markdown file or an entire directory of markdown files, creating a matching page hierarchy for directories. +The `sync` command synchronizes markdown files to a Notion page or database. You can sync either a single markdown file or an entire directory of markdown files, creating a matching page hierarchy for directories. ### Usage ```bash -mk-notes sync -i -d -k +mk-notes sync -i -d -k ``` ### Required Options - `-i, --input `: Path to a markdown file or directory containing your markdown files -- `-d, --destination `: URL of the parent Notion page where content will be synchronized +- `-d, --destination `: URL of the parent Notion page or database where content will be synchronized - `-k, --notion-api-key `: Your Notion API key for authentication ### Optional Options -- `-c, --clean`: Clean sync mode - **WARNING: removes ALL existing content** from the destination page before syncing, including any manually added content or blocks not created by mk-notes. This prevents duplicate content when repeatedly syncing to the same destination, but will delete any custom content you've added to the page. +- `-c, --clean`: Clean sync mode - behavior depends on the destination type: + + + + Removes ALL existing content from the destination page before + syncing, including any manually added content or blocks not created by + mk-notes. + + + + + + Finds and deletes existing pages with the same `id` (using the `mk-notes-id` + notion database property) before creating new ones. This requires the `id` + property to be set in your markdown frontmatter. + + For more information about synchronization into a database, you can read the [Database Synchronization](../database-sync) guide. + + + - `-l, --lock`: Lock the Notion page after syncing to prevent further editing. This is useful when you want to preserve the synchronized content and prevent accidental modifications. +### Destination Types + +Mk Notes supports two types of destinations: + +#### Notion Page + +When the destination is a Notion page, content is appended directly to the page (or replaces existing content with `--clean`). Child pages are created as sub-pages. + +#### Notion Database + +When the destination is a Notion database, pages are created as database items. This is useful for managing collections of documents where you want to leverage Notion's database features like filtering, sorting, and views. + ### Examples #### Syncing a Directory @@ -112,13 +143,55 @@ mk-notes sync \ This command will: -1. Remove ALL existing content from the destination Notion page (including pages that arelocked) +1. Remove ALL existing content from the destination Notion page (including pages that are locked) 2. Read all markdown files in the `./my-docs` directory 3. Create a matching page hierarchy in Notion 4. Convert and sync the content to your specified Notion page 5. **Lock the Notion page** to prevent further editing 6. Display a success message with the Notion page URL when complete +#### Syncing to a Notion Database + +```bash +mk-notes sync \ + --input ./my-docs \ + --destination https://notion.so/myworkspace/database-123456 \ + --notion-api-key secret_abc123... +``` + +This command will: + +1. Read all markdown files in the `./my-docs` directory +2. Create pages as items in the specified Notion database +3. Display a success message when complete + +#### Syncing to a Database with Clean Sync + +```bash +mk-notes sync \ + --input ./my-docs \ + --destination https://notion.so/myworkspace/database-123456 \ + --notion-api-key secret_abc123... \ + --clean +``` + +For database destinations with clean sync, make sure your markdown files include an `id` in the frontmatter: + +```markdown +--- +id: my-unique-page-id +title: My Document +--- + +Content here... +``` + +This command will: + +1. Find existing pages in the database with matching `mk-notes-id` property +2. Delete those existing pages +3. Create new pages as database items with the content from your markdown files + ## `preview-sync` The `preview-sync` command lets you preview how your markdown files will be organized in Notion before actually performing the synchronization. This is useful for verifying the structure before making any changes. diff --git a/docs/content/docs/cli/guides/database-sync.mdx b/docs/content/docs/cli/guides/database-sync.mdx new file mode 100644 index 0000000..0651637 --- /dev/null +++ b/docs/content/docs/cli/guides/database-sync.mdx @@ -0,0 +1,277 @@ +--- +id: database-sync +title: Database Synchronization +description: How to synchronize markdown files to Notion databases +--- + +This guide explains how to sync your markdown content to Notion databases instead of regular pages, enabling you to leverage Notion's powerful database features like filtering, sorting, and views. + +--- + +## Why Use Database Sync? + +Syncing to a Notion database offers several advantages over regular page sync: + +- **Better organization**: Use Notion's table, board, gallery, or calendar views +- **Filtering & sorting**: Quickly find content using database filters +- **Custom properties**: Add metadata like status, tags, or dates to your pages +- **Scalable**: Ideal for managing large collections of documents + +--- + +## Setting Up Your Database + +Before syncing, you need to prepare your Notion database with a special property. + +### Creating the `mk-notes-id` Property + +For MK Notes to identify and update existing pages (when using `--clean` mode), your database must have a **text property** named `mk-notes-id`. + +
+
+ +### Open your Notion database + +Navigate to the database where you want to sync your markdown content. + +
+
+ +### Add a new property + +Click the **+** button in the database header to add a new property. + +
+
+ +### Configure the property + +- **Name**: `mk-notes-id` +- **Type**: `Text` + +
+
+ + + +The property name must be exactly `mk-notes-id` (all lowercase with hyphens). MK Notes will not recognize variations like `MkNotesId` or `mk_notes_id`. + + + +--- + +## Configuring Your Markdown Files + +### The `id` Frontmatter Property + +To enable page identification and updates, add an `id` property to your markdown frontmatter: + +```markdown +--- +id: my-unique-page-id +title: My Document Title +--- + +Your content here... +``` + +When syncing to a database: + +1. The `id` value from your frontmatter is stored in the `mk-notes-id` database property +2. MK Notes uses this ID to find and update existing pages during clean sync +3. Without an `id`, pages will be created but cannot be updated via clean sync + + + +Use stable, unique identifiers for your `id` values. Good choices include: + +- File paths: `guides/getting-started` +- Slugs: `api-authentication` +- UUIDs: `550e8400-e29b-41d4-a716-446655440000` + +Avoid using titles as IDs since they may change over time. + + + +--- + +## Syncing Commands + +### Basic Database Sync + +To sync markdown files to a database: + +```bash +mk-notes sync \ + --input ./docs \ + --destination https://notion.so/myworkspace/database-123456 \ + --notion-api-key secret_abc123... +``` + +This creates new pages in the database for each markdown file. However, running this command multiple times will create duplicate entries. + +### Clean Sync (Recommended) + +For updating existing content, use the `--clean` flag: + +```bash +mk-notes sync \ + --input ./docs \ + --destination https://notion.so/myworkspace/database-123456 \ + --notion-api-key secret_abc123... \ + --clean +``` + +With clean sync enabled: + +1. MK Notes searches for existing pages with matching `mk-notes-id` values +2. Found pages are deleted +3. New pages are created with the updated content + + + +Clean sync only works for pages that have an `id` defined in their frontmatter. Pages without an `id` will be created but won't be cleaned up on subsequent syncs. + + + +--- + +## Adding Custom Database Properties + +You can populate additional database properties directly from your markdown frontmatter using the `properties` field: + +```markdown +--- +id: api-auth-guide +title: Authentication Guide +properties: + - name: status + value: published + - name: category + value: API + - name: priority + value: high +--- + +Your content here... +``` + +### Supported Property Types + +MK Notes automatically maps values to the appropriate Notion property type based on your database schema: + +| Notion Property Type | Frontmatter Value Format | +| -------------------- | ------------------------ | +| Text | `"any string"` | +| Number | `123` or `45.67` | +| Select | `"option-name"` | +| Multi-select | `"option1, option2"` | +| Checkbox | `true` or `false` | +| URL | `"https://example.com"` | +| Email | `"user@example.com"` | +| Phone | `"+1234567890"` | +| Date | `"2024-01-15"` | + + + +The database property must already exist in your Notion database. MK Notes will not create new properties automatically—it only populates existing ones. + + + +--- + +## Complete Example + +Here's a complete example of a markdown file optimized for database sync: + +```markdown +--- +id: getting-started-installation +title: Installation Guide +icon: 🚀 +properties: + - name: status + value: published + - name: category + value: Getting Started + - name: last-updated + value: 2024-01-15 +--- + +## Introduction + +Welcome to the installation guide... + +## Prerequisites + +Before you begin, ensure you have... + +## Installation Steps + +1. First, install the package... +``` + +--- + +## Best Practices + +### 1. Always Use Unique IDs + +Ensure each markdown file has a unique `id` to prevent conflicts: + +```markdown +--- +id: docs/guides/authentication # Use path-based IDs for uniqueness +title: Authentication +--- +``` + +### 2. Use Clean Sync for Updates + +Always use the `--clean` flag when updating existing content to avoid duplicates: + +```bash +mk-notes sync -i ./docs -d -k --clean +``` + +### 3. Preview Before Syncing + +Use the preview command to verify your structure: + +```bash +mk-notes preview-sync --input ./docs +``` + +### 4. Organize with Database Views + +After syncing, create Notion database views to organize your content: + +- **Table view**: For detailed property inspection +- **Board view**: Group by status or category +- **Gallery view**: Visual overview of your documentation + +--- + +## Troubleshooting + +### Pages are duplicated + +**Cause**: Running sync without `--clean` flag, or missing `id` in frontmatter. + +**Solution**: Add unique `id` values to all markdown files and use `--clean` flag. + +### Properties not appearing + +**Cause**: Property doesn't exist in the database or name doesn't match exactly. + +**Solution**: Ensure the property exists in your Notion database with the exact same name (case-sensitive). + +### Clean sync not deleting old pages + +**Cause**: The `mk-notes-id` property doesn't exist or has a different name. + +**Solution**: Create a text property named exactly `mk-notes-id` in your database. + +--- + +For more information about CLI commands, see the [CLI Commands](./cli-commands) documentation. diff --git a/docs/content/docs/cli/guides/meta.json b/docs/content/docs/cli/guides/meta.json index 2678258..ec79244 100644 --- a/docs/content/docs/cli/guides/meta.json +++ b/docs/content/docs/cli/guides/meta.json @@ -2,6 +2,7 @@ "title": "Guides", "pages": [ "cli-commands", + "database-sync", "architecture", "programmatic-usage" ] diff --git a/docs/content/docs/github-actions/available-actions/2-sync-action.mdx b/docs/content/docs/github-actions/available-actions/2-sync-action.mdx index df46550..f25260d 100644 --- a/docs/content/docs/github-actions/available-actions/2-sync-action.mdx +++ b/docs/content/docs/github-actions/available-actions/2-sync-action.mdx @@ -1,10 +1,10 @@ --- id: sync-action title: Sync Action -description: Synchronize markdown files to Notion pages using the sync GitHub Action +description: Synchronize markdown files to Notion pages or databases using the sync GitHub Action --- -The sync action synchronizes your markdown files to a dedicated Notion page, maintaining the structure and formatting of your content. +The sync action synchronizes your markdown files to a dedicated Notion page or database, maintaining the structure and formatting of your content. ## Usage @@ -17,7 +17,7 @@ steps: uses: Myastr0/mk-notes/sync with: input: './docs' # The path to the markdown file or directory to synchronize - destination: 'https://notion.so/your-page-id' + destination: 'https://notion.so/your-page-or-database-id' notion-api-key: ${{ secrets.NOTION_API_KEY }} ``` @@ -28,15 +28,26 @@ steps: You should use a GitHub Secret to store your Notion API key. You can find more information in the [Setting Up Secrets](../setup.mdx) guide.
+ ## Inputs -| Input | Description | Required | Default | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- | -| `input` | The path to the markdown file or directory to synchronize | `true` | - | -| `destination` | The Notion page URL where you want to synchronize your markdown files | `true` | - | -| `notion-api-key` | Your Notion secret token | `true` | - | -| `clean` | Clean sync mode - WARNING: removes ALL existing content from the destination page before syncing, including any custom content not created by mk-notes | `false` | `false` | -| `lock` | Lock the Notion page after syncing | `false` | `false` | +| Input | Description | Required | Default | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | +| `input` | The path to the markdown file or directory to synchronize | `true` | - | +| `destination` | The Notion page or database URL where you want to synchronize your markdown files | `true` | - | +| `notion-api-key` | Your Notion secret token | `true` | - | +| `clean` | Clean sync mode - For pages: removes ALL existing content. For databases: deletes pages with matching `mk-notes-id` before syncing | `false` | `false` | +| `lock` | Lock the Notion page after syncing | `false` | `false` | + +## Destination Types + +### Notion Page + +When the destination is a Notion page, content is appended directly to the page. Child pages are created as sub-pages. + +### Notion Database + +When the destination is a Notion database, pages are created as database items. This is useful for managing collections of documents where you want to leverage Notion's database features. ## Outputs @@ -117,9 +128,70 @@ jobs: clean: 'true' ``` +### Sync to a Notion Database + +```yaml +name: Sync to Notion Database + +on: + push: + branches: [main] + paths: ['docs/**'] + +jobs: + sync-to-database: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Sync to Notion database + uses: Myastr0/mk-notes/sync + with: + input: './docs' + destination: ${{ secrets.NOTION_DATABASE_URL }} + notion-api-key: ${{ secrets.NOTION_API_KEY }} +``` + +### Sync to Database with Clean Mode + +When using clean sync with a database, make sure your markdown files include an `id` in the frontmatter: + +```markdown +--- +id: my-unique-doc-id +title: My Document +--- + +Content here... +``` + +```yaml +name: Clean Sync to Database + +on: + workflow_dispatch: + +jobs: + clean-sync-database: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Clean sync to Notion database + uses: Myastr0/mk-notes/sync + with: + input: './docs' + destination: ${{ secrets.NOTION_DATABASE_URL }} + notion-api-key: ${{ secrets.NOTION_API_KEY }} + clean: 'true' +``` + ## Important Notes -- The `clean` option removes ALL existing content from the destination page -- Use clean sync only when you're sure you want to replace all content +- **For pages**: The `clean` option removes ALL existing content from the destination page +- **For databases**: The `clean` option deletes pages with matching `mk-notes-id` property before syncing +- Use clean sync only when you're sure you want to replace content - Always test with the [preview action](./1-preview-action.mdx) first -- Make sure your Notion integration has access to the target page +- Make sure your Notion integration has access to the target page or database diff --git a/docs/content/docs/github-actions/first-synchronization.mdx b/docs/content/docs/github-actions/first-synchronization.mdx index 9079dfa..615179c 100644 --- a/docs/content/docs/github-actions/first-synchronization.mdx +++ b/docs/content/docs/github-actions/first-synchronization.mdx @@ -4,13 +4,13 @@ title: Your first synchronization description: Tutorial to make your first synchronization with Mk Notes using GitHub Actions --- -In this tutorial, you will learn how to set up automated synchronization of your markdown files to a Notion page using Mk Notes GitHub Actions. +In this tutorial, you will learn how to set up automated synchronization of your markdown files to Notion using Mk Notes GitHub Actions. --- ## Requirements -- A **Notion Integration** with read-write access on your Notion page +- A **Notion Integration** with read-write access on your Notion page or your Notion Database Read the official [Notion guide](https://developers.notion.com/docs/authorization) to create and setup @@ -75,7 +75,7 @@ jobs: uses: Myastr0/mk-notes/sync with: input: './docs' # Path to your markdown files - destination: + destination: notion-api-key: ${{ secrets.NOTION_API_KEY }} ``` @@ -87,9 +87,9 @@ This workflow will automatically synchronize your markdown files to Notion whene `NOTION_API_KEY` can be found in your Notion Integration settings. - - Please note that you can only synchronize markdown files to **Notion Pages**. - Notion Databases are not supported for now. + + Mk Notes supports both **Notion Pages** and **Notion Databases** as + destinations. When syncing to a database, pages are created as database items. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 43dfc12..e47c737 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -9,9 +9,10 @@ description: Introduction to Mk Notes of the project. 🔄 **Mk Notes** allows you to synchronize your markdown files inside your Notion workspace : - You can synchronize individual markdown files or entire directories with automatic detection. +- You can sync to **Notion Pages** or **Notion Databases** for flexible content management. - You can use it to share your technical documentation inside your Notion workspace. - You can publish your documentation to the web using Notion Publish. -- It enables to you to use the Notion AI to find info in your technical documentation. +- It enables you to use the Notion AI to find info in your technical documentation. - You can automate synchronization with GitHub Actions for continuous documentation updates. 💅 Based on [GitHub Flavored markdown](https://github.github.com/gfm/) specification. diff --git a/docs/content/docs/writing/styling-notion-page.mdx b/docs/content/docs/writing/styling-notion-page.mdx index ec43f01..21dfca6 100644 --- a/docs/content/docs/writing/styling-notion-page.mdx +++ b/docs/content/docs/writing/styling-notion-page.mdx @@ -14,10 +14,11 @@ This document explains how to customize your Notion Page directly from your mark Mk Notes relies on `frontmatter` to be able to add metadata on markdown files. ```markdown -# Beggining of your markdown file +# Beginning of your markdown file --- +id: unique-page-identifier title: The notion page title icon: 💡 @@ -28,6 +29,26 @@ icon: 💡 Here's the Mk Notes supported properties to customize your Notion Page +### `id` + +- Type: `string` + +_optional_ + +This property allows you to specify a unique identifier for your page. This is particularly useful when syncing to a **Notion Database** with the `--clean` option enabled. + +When clean sync is enabled and the destination is a database, Mk Notes will use this `id` to find and delete existing pages with the same identifier before creating a new one. This ensures that repeated syncs don't create duplicate entries. + +```markdown +id: +``` + + + +When syncing to a database, the `id` value is stored in a property named `mk-notes-id` in your Notion database. + + + ### `title` - Type: `string` @@ -35,7 +56,7 @@ Here's the Mk Notes supported properties to customize your Notion Page _optional_ This property allows you to specify a Notion page title. -If you do not provide this property, Mk Notes will relies on the name of the file. +If you do not provide this property, Mk Notes will rely on the name of the file. ```markdown title: