diff --git a/__tests__/__fakes__/fakeConverter.repository.ts b/__tests__/__fakes__/elements/fake-converter.repository.ts similarity index 51% rename from __tests__/__fakes__/fakeConverter.repository.ts rename to __tests__/__fakes__/elements/fake-converter.repository.ts index d11bcd5..a0c9063 100644 --- a/__tests__/__fakes__/fakeConverter.repository.ts +++ b/__tests__/__fakes__/elements/fake-converter.repository.ts @@ -1,17 +1,15 @@ import { Logger } from 'winston'; import { PageElement } from '@/domains/elements'; -import { File, Page } from '@/domains/synchronization'; +import { File } from '@/domains/synchronization'; -import { FileConverter } from '../../src/infrastructure/filesystem'; -import { HtmlParser } from '../../src/infrastructure/html'; -import { MarkdownParser } from '../../src/infrastructure/markdown'; -import { - NotionConverterRepository, - NotionPage, -} from '../../src/infrastructure/notion'; -import { aFakePageElement } from './fakeElement'; -import { aFakeNotionPage } from './fakePage'; +import { NotionPage } from '../../../src/domains/notion/entities/NotionPage'; +import { FileConverter } from '../../../src/infrastructure/converters/file/file.converter'; +import { NotionConverterRepository } from '../../../src/infrastructure/converters/notion/notion.converter'; +import { HtmlParser } from '../../../src/infrastructure/parsers/html'; +import { MarkdownParser } from '../../../src/infrastructure/parsers/markdown'; +import { aFakePageElement } from '../../__fixtures__/element.fixture'; +import { aFakeNotionPage } from '../../__fixtures__/page.fixture'; export class FakeFileConverter extends FileConverter { constructor({ diff --git a/__tests__/__fakes__/fakeParser.repository.ts b/__tests__/__fakes__/elements/fake-parser.repository.ts similarity index 67% rename from __tests__/__fakes__/fakeParser.repository.ts rename to __tests__/__fakes__/elements/fake-parser.repository.ts index 72930bf..3e09c87 100644 --- a/__tests__/__fakes__/fakeParser.repository.ts +++ b/__tests__/__fakes__/elements/fake-parser.repository.ts @@ -1,14 +1,14 @@ import { Logger } from 'winston'; -import { Element } from '@/domains/elements/Element'; +import { Element } from '@/domains/elements/entities/Element'; import { ParseResult, ParserRepository, -} from '@/domains/elements/parser.repository'; +} from '@/domains/elements/repositories/parser.repository'; import { SupportedEmoji } from '@/domains/elements/types'; -import { MarkdownParser } from '../../src/infrastructure/markdown'; -import { FakeElement } from './fakeElement'; +import { MarkdownParser } from '../../../src/infrastructure/parsers/markdown'; +import { ElementFixture } from '../../__fixtures__/element.fixture'; // Fake implementation of the ParserRepository export class FakeParserRepository extends ParserRepository { @@ -21,8 +21,8 @@ export class FakeParserRepository extends ParserRepository { // Here you would implement the logic to parse the content into elements and other properties // For demonstration purposes, let's create a fake ParseResult with some fake elements and icon const fakeElements: Element[] = [ - new FakeElement('fakeId1', 'Fake Name 1'), - new FakeElement('fakeId2', 'Fake Name 2'), + new ElementFixture({ id: 'fakeId1', name: 'Fake Name 1' }), + new ElementFixture({ id: 'fakeId2', name: 'Fake Name 2' }), ]; const fakeIcon: SupportedEmoji = '😊'; // Example of a supported emoji @@ -36,8 +36,8 @@ export class FakeParserRepository extends ParserRepository { export class FakeMarkdownParser extends MarkdownParser { parse({ content }: { content: string }): ParseResult { const fakeElements: Element[] = [ - new FakeElement('fakeId1', 'Fake Name 1'), - new FakeElement('fakeId2', 'Fake Name 2'), + new ElementFixture({ id: 'fakeId1', name: 'Fake Name 1' }), + new ElementFixture({ id: 'fakeId2', name: 'Fake Name 2' }), ]; const fakeIcon: SupportedEmoji = '😊'; diff --git a/__tests__/__fakes__/fakeInfrastructureInstances.ts b/__tests__/__fakes__/fakeInfrastructureInstances.ts index 34aca04..58f46d2 100644 --- a/__tests__/__fakes__/fakeInfrastructureInstances.ts +++ b/__tests__/__fakes__/fakeInfrastructureInstances.ts @@ -5,14 +5,14 @@ import { InfrastructureInstances } from '@/infrastructure'; import { FakeFileConverter, FakeNotionConverter, -} from './fakeConverter.repository'; -import { FakeDestinationRepository } from './fakeDestination.repository'; -import { fakeLogger } from './fakeLogger'; +} from './elements/fake-converter.repository'; import { FakeMarkdownParser, FakeParserRepository, -} from './fakeParser.repository'; -import { FakeSourceRepository } from './fakeSource.repository'; +} from './elements/fake-parser.repository'; +import { fakeLogger } from './logger/fake-logger'; +import { FakeDestinationRepository } from './synchronization/fake-destination.repository'; +import { FakeSourceRepository } from './synchronization/fake-source.repository'; export type FakeInfrastructureInstances = ReturnType< typeof getFakeInfrastructureInstances diff --git a/__tests__/__fakes__/fakeLogger.ts b/__tests__/__fakes__/logger/fake-logger.ts similarity index 100% rename from __tests__/__fakes__/fakeLogger.ts rename to __tests__/__fakes__/logger/fake-logger.ts diff --git a/__tests__/__fakes__/notion/fake-notion-client.repository.ts b/__tests__/__fakes__/notion/fake-notion-client.repository.ts new file mode 100644 index 0000000..94e6708 --- /dev/null +++ b/__tests__/__fakes__/notion/fake-notion-client.repository.ts @@ -0,0 +1,306 @@ +import { + BlockObjectResponse, + DatabaseObjectResponse, + DataSourceObjectResponse, + SearchResponse, +} from '@notionhq/client/build/src/api-endpoints'; + +import { NotionPage } from '../../../src/domains/notion/entities/NotionPage'; +import { + CreatePageInput, + NotionClientRepository, +} from '../../../src/domains/notion/repositories/notion-client.repository'; +import { + BlockObjectRequest, + Icon, + PageProperties, +} from '../../../src/domains/notion/types'; + +export class FakeNotionClientRepository implements NotionClientRepository { + private pages: Map = new Map(); + private blocks: Map = new Map(); + private databases: Map = new Map(); + private dataSources: Map = new Map(); + private databaseToDataSource: Map = new Map(); + + /** + * ------------------------------------------------------------ + * GENERAL METHODS + * ------------------------------------------------------------ + */ + // eslint-disable-next-line @typescript-eslint/require-await + async search({ + filter, + }: { + filter: { property: 'object'; value: 'page' | 'data_source' }; + }): Promise { + return { + type: 'page_or_data_source', + page_or_data_source: {}, + object: 'list', + results: [], + next_cursor: null, + has_more: false, + }; + } + + /** + * ------------------------------------------------------------ + * DATABASES METHODS + * ------------------------------------------------------------ + */ + // eslint-disable-next-line @typescript-eslint/require-await + async getDatabaseById({ + databaseId, + }: { + databaseId: string; + }): Promise { + return this.databases.get(databaseId) ?? null; + } + + /** + * ------------------------------------------------------------ + * DATA SOURCES METHODS + * ------------------------------------------------------------ + */ + // eslint-disable-next-line @typescript-eslint/require-await + async getDataSourceById({ + dataSourceId, + }: { + dataSourceId: string; + }): Promise { + return this.dataSources.get(dataSourceId) ?? null; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async getDataSourceIdFromDatabaseId({ + databaseId, + }: { + databaseId: string; + }): Promise { + return this.databaseToDataSource.get(databaseId) ?? null; + } + + /** + * ------------------------------------------------------------ + * PAGES METHODS + * ------------------------------------------------------------ + */ + // eslint-disable-next-line @typescript-eslint/require-await + async createPage({ + parent, + properties, + icon, + children, + }: CreatePageInput): Promise { + const pageId = `fake-page-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const now = new Date(); + + const page = new NotionPage({ + pageId, + children: children ?? [], + createdAt: now, + updatedAt: now, + icon, + properties, + isLocked: false, + }); + + this.pages.set(pageId, page); + return page; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async updatePage({ + pageId, + icon, + properties, + archived, + isLocked, + }: { + pageId: string; + icon?: Icon; + properties?: PageProperties; + archived?: boolean; + isLocked?: boolean; + }): Promise { + const existingPage = this.pages.get(pageId); + const now = new Date(); + + const updatedPage = new NotionPage({ + pageId, + children: existingPage?.children ?? [], + createdAt: existingPage?.createdAt ?? now, + updatedAt: now, + icon: icon ?? existingPage?.icon, + properties: properties ?? existingPage?.properties, + isLocked: isLocked ?? existingPage?.isLocked, + }); + + this.pages.set(pageId, updatedPage); + return updatedPage; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async deletePage({ pageId }: { pageId: string }): Promise { + this.pages.delete(pageId); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async getPage({ pageId }: { pageId: string }): Promise { + return this.pages.get(pageId) ?? null; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async getPageBlocks({ + pageId, + }: { + pageId: string; + }): Promise { + const page = this.pages.get(pageId); + if (!page) { + return []; + } + return page.children as BlockObjectResponse[]; + } + + /** + * ------------------------------------------------------------ + * BLOCKS METHODS + * ------------------------------------------------------------ + */ + // eslint-disable-next-line @typescript-eslint/require-await + async appendChildToBlock({ + blockId, + children, + afterBlockId, + }: { + blockId: string; + children: BlockObjectRequest[]; + afterBlockId?: string; + }): Promise { + const createdBlocks: BlockObjectResponse[] = children.map( + (child, index) => { + const newBlockId = `fake-block-${Date.now()}-${index}-${Math.random().toString(36).substring(7)}`; + const block = { + ...child, + id: newBlockId, + object: 'block', + created_time: new Date().toISOString(), + last_edited_time: new Date().toISOString(), + has_children: false, + archived: false, + in_trash: false, + parent: { type: 'block_id', block_id: blockId }, + created_by: { object: 'user', id: 'fake-user' }, + last_edited_by: { object: 'user', id: 'fake-user' }, + } as unknown as BlockObjectResponse; + + this.blocks.set(newBlockId, block); + return block; + } + ); + + return createdBlocks; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async deleteBlock({ blockId }: { blockId: string }): Promise { + this.blocks.delete(blockId); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async deleteBlocks({ blockIds }: { blockIds: string[] }): Promise { + for (const blockId of blockIds) { + this.blocks.delete(blockId); + } + } + + // eslint-disable-next-line @typescript-eslint/require-await + async getBlock({ + blockId, + }: { + blockId: string; + }): Promise { + const block = this.blocks.get(blockId); + if (!block) { + throw new Error(`Block with id ${blockId} not found`); + } + return block; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async updateBlock({ + blockId, + block, + }: { + blockId: string; + block: BlockObjectRequest; + }): Promise { + const existingBlock = this.blocks.get(blockId); + const updatedBlock = { + ...existingBlock, + ...block, + id: blockId, + last_edited_time: new Date().toISOString(), + } as unknown as BlockObjectResponse; + + this.blocks.set(blockId, updatedBlock); + return updatedBlock; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async getBlockChildren({ + blockId, + }: { + blockId: string; + }): Promise { + const children: BlockObjectResponse[] = []; + for (const block of this.blocks.values()) { + const parent = block.parent as { type: string; block_id?: string }; + if (parent?.type === 'block_id' && parent?.block_id === blockId) { + children.push(block); + } + } + return children; + } + + /** + * ------------------------------------------------------------ + * TEST HELPER METHODS + * ------------------------------------------------------------ + */ + setPage(pageId: string, page: NotionPage): void { + this.pages.set(pageId, page); + } + + setBlock(blockId: string, block: BlockObjectResponse): void { + this.blocks.set(blockId, block); + } + + setDatabase(databaseId: string, database: DatabaseObjectResponse): void { + this.databases.set(databaseId, database); + } + + setDataSource( + dataSourceId: string, + dataSource: DataSourceObjectResponse + ): void { + this.dataSources.set(dataSourceId, dataSource); + } + + setDatabaseToDataSourceMapping( + databaseId: string, + dataSourceId: string + ): void { + this.databaseToDataSource.set(databaseId, dataSourceId); + } + + clear(): void { + this.pages.clear(); + this.blocks.clear(); + this.databases.clear(); + this.dataSources.clear(); + this.databaseToDataSource.clear(); + } +} diff --git a/__tests__/__fakes__/fakeDestination.repository.ts b/__tests__/__fakes__/synchronization/fake-destination.repository.ts similarity index 92% rename from __tests__/__fakes__/fakeDestination.repository.ts rename to __tests__/__fakes__/synchronization/fake-destination.repository.ts index 239f4e6..678c086 100644 --- a/__tests__/__fakes__/fakeDestination.repository.ts +++ b/__tests__/__fakes__/synchronization/fake-destination.repository.ts @@ -4,9 +4,9 @@ import { ObjectType, Page, PageLockedStatus, -} from '@/domains/synchronization/destination.repository'; +} from '@/domains/synchronization/repositories/destination.repository'; -import { FakePage } from './fakePage'; +import { PageFixture } from '../../__fixtures__/page.fixture'; export class FakeDestinationRepository implements DestinationRepository @@ -15,6 +15,10 @@ export class FakeDestinationRepository return Promise.resolve('page'); } + async getPage({ pageId }: { pageId: string }): Promise { + return Promise.resolve(null); + } + getObjectIdFromObjectUrl({ objectUrl }: { objectUrl: string }): string { const urlObj = new URL(objectUrl); @@ -43,7 +47,7 @@ export class FakeDestinationRepository parentObjectType: ObjectType; }): Promise { // Here you would implement the logic to create a new page in the fake destination - const fakePage = new FakePage({ + const fakePage = new PageFixture({ pageId: 'fakePageId', createdAt: new Date(), updatedAt: new Date(), @@ -62,7 +66,7 @@ export class FakeDestinationRepository pageElement: PageElement; }): Promise { // Here you would implement the logic to update an existing page in the fake destination - const updatedFakePage = new FakePage({ + const updatedFakePage = new PageFixture({ pageId, createdAt: new Date(), updatedAt: new Date(), diff --git a/__tests__/__fakes__/fakeSource.repository.ts b/__tests__/__fakes__/synchronization/fake-source.repository.ts similarity index 85% rename from __tests__/__fakes__/fakeSource.repository.ts rename to __tests__/__fakes__/synchronization/fake-source.repository.ts index 3c384f7..fb7e4c9 100644 --- a/__tests__/__fakes__/fakeSource.repository.ts +++ b/__tests__/__fakes__/synchronization/fake-source.repository.ts @@ -2,7 +2,7 @@ import { SupportedEmoji } from '@/domains/elements'; import { File, FileContent, SourceRepository } from '@/domains/synchronization'; -import { FakeFile } from './fakeFile'; +import { FileFixture } from '../../__fixtures__/file.fixture'; export class FakeSourceRepository implements SourceRepository { // Simulate getting a list of file paths from the source @@ -13,6 +13,11 @@ export class FakeSourceRepository implements SourceRepository { return ['fakeFilePath1', 'fakeFilePath2']; } + async updateFile(file: File): Promise { + // Here you would implement the logic to update the file content in the fake source + return Promise.resolve(); + } + // Simulate getting a file content from the source // eslint-disable-next-line @typescript-eslint/require-await async getFile(args: T): Promise { @@ -22,7 +27,7 @@ export class FakeSourceRepository implements SourceRepository { const fakeExtension = '.txt'; const fakeIcon: SupportedEmoji = '😊'; // Example of a supported emoji - return new FakeFile({ + return new FileFixture({ name: 'fakeFileName', content: fakeContent, lastUpdated: fakeLastUpdated, diff --git a/__tests__/__fakes__/fakeElement.ts b/__tests__/__fixtures__/element.fixture.ts similarity index 65% rename from __tests__/__fakes__/fakeElement.ts rename to __tests__/__fixtures__/element.fixture.ts index 545ab83..b0cb2ab 100644 --- a/__tests__/__fakes__/fakeElement.ts +++ b/__tests__/__fixtures__/element.fixture.ts @@ -1,31 +1,39 @@ -import { Element, ElementType, PageElement } from '@/domains/elements/Element'; +import { + Element, + ElementType, + PageElement, +} from '@/domains/elements/entities/Element'; import { SupportedEmoji } from '../../src'; -export class FakeElement implements Element { +export class ElementFixture implements Element { id: string; name: string; type: ElementType; - constructor(id: string, name: string) { + constructor({ id, name }: { id: string; name: string }) { this.type = 'fake' as ElementType; this.id = id; this.name = name; } - withType(type: ElementType): FakeElement { + withType(type: ElementType): ElementFixture { this.type = type; return this; } - withName(name: string): FakeElement { + withName(name: string): ElementFixture { this.name = name; return this; } + + toContentString(): string { + return this.name; + } } -export const aFakeElement = (): FakeElement => { - return new FakeElement('id', 'name'); +export const aFakeElement = (): ElementFixture => { + return new ElementFixture({ id: 'id', name: 'name' }); }; export class FakePageElement extends PageElement { diff --git a/__tests__/__fakes__/fakeFile.ts b/__tests__/__fixtures__/file.fixture.ts similarity index 84% rename from __tests__/__fakes__/fakeFile.ts rename to __tests__/__fixtures__/file.fixture.ts index 44ec4c8..594084c 100644 --- a/__tests__/__fakes__/fakeFile.ts +++ b/__tests__/__fixtures__/file.fixture.ts @@ -2,12 +2,13 @@ import { SupportedEmoji } from '@/domains/elements/types'; import { File, FileContent, SourceRepository } from '@/domains/synchronization'; // Define a fake file implementation for testing purposes -export class FakeFile implements File { +export class FileFixture implements File { name: string; icon?: SupportedEmoji; content: FileContent; lastUpdated: Date; extension: string; + path: string = 'fake-file-path'; constructor({ name, @@ -15,12 +16,14 @@ export class FakeFile implements File { lastUpdated, extension, icon, + path, }: { name?: string; content?: FileContent; lastUpdated?: Date; extension?: string; icon?: SupportedEmoji; + path?: string; } = {}) { this.name = name ?? 'fake-file-name'; this.content = content ?? '# Test'; @@ -29,5 +32,6 @@ export class FakeFile implements File { if (icon !== undefined) { this.icon = icon; } + this.path = path ?? this.path; } } diff --git a/__tests__/__fakes__/fakePage.ts b/__tests__/__fixtures__/page.fixture.ts similarity index 91% rename from __tests__/__fakes__/fakePage.ts rename to __tests__/__fixtures__/page.fixture.ts index 75f7358..f130c7f 100644 --- a/__tests__/__fakes__/fakePage.ts +++ b/__tests__/__fixtures__/page.fixture.ts @@ -1,8 +1,8 @@ import { Page } from '@/domains/synchronization'; -import { NotionPage } from '../../src/infrastructure/notion'; +import { NotionPage } from '../../src/domains/notion/entities/NotionPage'; -export class FakePage implements Page { +export class PageFixture implements Page { pageId: string; createdAt: Date; updatedAt: Date; diff --git a/docs/content/blog/2025-12-11-v3.1-incremental-sync/index.mdx b/docs/content/blog/2025-12-11-v3.1-incremental-sync/index.mdx new file mode 100644 index 0000000..650227e --- /dev/null +++ b/docs/content/blog/2025-12-11-v3.1-incremental-sync/index.mdx @@ -0,0 +1,134 @@ +--- +slug: v3.1-incremental-sync +date: 2025-12-11 +title: 'Mk Notes v3.1.0: Incremental Sync & Persistent Page Links' +author: Myastr0 +description: Introducing --save-id and --force-new options for true incremental updates that preserve your Notion page links. +--- + +We're excited to release **Mk Notes v3.1.0** – a feature update focused on making your sync workflows smarter and preserving your Notion page links across updates. + +{/* truncate */} + +## Listening to the Community + +This release directly addresses feedback from our community. Two issues in particular shaped this update: + +- [Issue #66](https://github.com/Myastr0/mk-notes/issues/66): A request for a `--save-id` parameter that stores Notion page IDs back to markdown files, enabling true incremental updates +- [Issue #67](https://github.com/Myastr0/mk-notes/issues/67): A request for persistent page links – when pages are shared in Slack or linked across Notion, those links should remain valid after re-syncing + +The common theme? **Stability**. Users wanted their synced content to be updateable without breaking existing links or creating duplicates. + +## The Problem with Clean Sync + +In v3.0.0, we introduced database sync with a `--clean` option. While useful, it had a significant limitation: every sync created brand new pages. This meant: + +- Links shared in Slack or other tools would break after each sync +- References to pages within Notion would become stale +- Comments on blocks would be lost +- There was no way to "update in place" + +## Introducing `--save-id` + +The new `--save-id` option solves this by creating a bidirectional link between your markdown files and Notion pages. + +```bash +mk-notes sync \ + --input ./docs \ + --destination https://notion.so/myworkspace/page-123456 \ + --notion-api-key secret_abc123... \ + --save-id +``` + +After syncing, Mk Notes writes the Notion page ID back to your markdown file's frontmatter: + +```markdown +--- +id: 12345678-1234-1234-1234-123456789abc +title: My Document +--- + +Your content here... +``` + +On subsequent syncs, Mk Notes recognizes this ID and **updates the existing page** instead of creating a new one. Your links stay valid, your page history is preserved, and you get true incremental updates. + +### Why This Matters + +- **Persistent links**: Share a doc link in Slack – it works forever +- **Notion references**: Link pages within Notion – references stay valid +- **Simpler setup**: No need for special database properties +- **Git-friendly**: Page mappings are stored in your repo alongside your content + +## Introducing `--force-new` + +Sometimes you _do_ want to start fresh. The new `--force-new` option ignores any stored page IDs and creates new pages: + +```bash +mk-notes sync \ + --input ./docs \ + --destination https://notion.so/myworkspace/page-123456 \ + --notion-api-key secret_abc123... \ + --force-new +``` + +This is useful when: + +- Migrating content to a new Notion workspace +- Testing sync without affecting production pages +- Intentionally creating a fresh copy of your documentation + +### Combining Options + +You can combine these options for different workflows: + +| Options | Behavior | +| ------------------------------- | ---------------------------------------------------- | +| `--save-id` | Updates existing pages, saves IDs to frontmatter | +| `--force-new` | Creates new pages, ignores existing IDs | +| `--clean --save-id` | Clears destination, creates new pages, saves new IDs | +| `--clean --force-new --save-id` | Full reset: clears all, creates new, saves new IDs | + +## GitHub Actions Support + +Both options are available in the GitHub Action: + +```yaml +- name: Sync to Notion with incremental updates + uses: Myastr0/mk-notes/sync + with: + input: './docs' + destination: ${{ secrets.NOTION_PAGE_URL }} + notion-api-key: ${{ secrets.NOTION_API_KEY }} + save-id: 'true' + +- name: Commit updated page IDs + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add docs/ + git diff --staged --quiet || git commit -m "chore: update Notion page IDs" + git push +``` + +## Breaking Change: Simplified ID Handling + +As part of this update, we've simplified how page tracking works. The `mk-notes-id` database property is no longer used. Instead, all page tracking is handled through the `id` frontmatter field and the `--save-id` option. + +This makes the sync process simpler and more predictable – your markdown files are the single source of truth for page mappings. + +## Getting Started + +Update to v3.1.0: + +```bash +npm install -g @mk-notes/cli@latest +``` + +Then add `--save-id` to your sync command to enable incremental updates. Check out the updated [CLI Commands](/docs/cli/guides/cli-commands) documentation for all the details. + +--- + +Thank you to everyone who shared feedback and feature requests. This release is a direct result of your input! As always, we welcome your thoughts – [open an issue](https://github.com/Myastr0/mk-notes/issues) or start a discussion on GitHub. + +Happy syncing! 🔗 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 68d636d..449b129 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 @@ -57,6 +57,20 @@ For more information about synchronization into a database, you can read the [Da + + +Add `--save-id` to your command to save Notion page IDs back to your markdown files. This enables **incremental updates** on subsequent syncs, so MK Notes will update existing pages instead of creating duplicates: + +```bash +mk-notes sync \ + --input \ + --destination \ + --notion-api-key \ + --save-id +``` + + + --- @@ -65,4 +79,12 @@ For more information about synchronization into a database, you can read the [Da Mk Notes allows you to style the created Notion pages directly from your markdown. You can find more information in the [Styling Notion Page](../guides/styling-notion-page) guide. + + +If you plan to sync your documentation regularly, we recommend using the `--save-id` option. This saves Notion page IDs to your markdown frontmatter, enabling MK Notes to update existing pages on subsequent syncs rather than creating duplicates. + +Learn more about all available options in the [CLI Commands](../guides/cli-commands) guide. + + + --- diff --git a/docs/content/docs/cli/guides/cli-commands.mdx b/docs/content/docs/cli/guides/cli-commands.mdx index 99389f9..b2d1bcb 100644 --- a/docs/content/docs/cli/guides/cli-commands.mdx +++ b/docs/content/docs/cli/guides/cli-commands.mdx @@ -38,16 +38,30 @@ mk-notes sync -i -d -k - 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. + Removes ALL existing content from the database before syncing. - For more information about synchronization into a database, you can read the [Database Synchronization](../database-sync) guide. + For more information about synchronization into a database, you can read the [Database Synchronization](../guides/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. +- `-s, --save-id`: Save the Notion page ID back to your markdown file's frontmatter after synchronization. This enables incremental updates on subsequent syncs by allowing MK Notes to identify and update existing pages rather than creating duplicates. + + + + After a successful sync, MK Notes writes the Notion page ID to the `id` field in your markdown frontmatter. On subsequent syncs, MK Notes uses this ID to update the existing page instead of creating a new one. + + + +- `-f, --force-new`: Force creation of new pages even if your markdown files contain existing page IDs. This is useful when you want to recreate all pages from scratch, ignoring any previously saved IDs. + + + + Using `--force-new` will create duplicate pages if you have existing pages with the same content. Consider using `--clean` together with `--force-new` if you want to replace existing content. + + + ### Destination Types Mk Notes supports two types of destinations: @@ -188,9 +202,77 @@ 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 +1. Delete existing content from the database +2. Create new pages as database items with the content from your markdown files + +#### Syncing with Save ID (Incremental Updates) + +```bash +mk-notes sync \ + --input ./my-docs \ + --destination https://notion.so/myworkspace/doc-123456 \ + --notion-api-key secret_abc123... \ + --save-id +``` + +This command will: + +1. Read all markdown files in the `./my-docs` directory +2. Create or update pages in Notion +3. **Save the Notion page IDs** back to each markdown file's frontmatter +4. Display a success message with the Notion page URL when complete + +After running this command, your markdown files will have an `id` field in their frontmatter: + +```markdown +--- +id: 12345678-1234-1234-1234-123456789abc +title: My Document +--- + +Content here... +``` + +On subsequent syncs with `--save-id`, MK Notes will update the existing pages instead of creating new ones. + +#### Syncing with Force New (Recreate Pages) + +```bash +mk-notes sync \ + --input ./my-docs \ + --destination https://notion.so/myworkspace/doc-123456 \ + --notion-api-key secret_abc123... \ + --force-new +``` + +This command will: + +1. Read all markdown files in the `./my-docs` directory +2. **Ignore any existing page IDs** in the markdown frontmatter +3. Create new pages in Notion for all files +4. Display a success message with the Notion page URL when complete + +#### Combining Force New with Clean and Save ID + +```bash +mk-notes sync \ + --input ./my-docs \ + --destination https://notion.so/myworkspace/doc-123456 \ + --notion-api-key secret_abc123... \ + --clean \ + --force-new \ + --save-id +``` + +This command will: + +1. Remove ALL existing content from the destination Notion page +2. Read all markdown files in the `./my-docs` directory +3. Create new pages (ignoring any existing IDs) +4. Save the new page IDs back to the markdown files +5. Display a success message with the Notion page URL when complete + +This is useful when you want to completely reset your synchronization and start fresh with new page IDs. ## `preview-sync` diff --git a/docs/content/docs/cli/guides/database-sync.mdx b/docs/content/docs/cli/guides/database-sync.mdx index 0651637..c2369ab 100644 --- a/docs/content/docs/cli/guides/database-sync.mdx +++ b/docs/content/docs/cli/guides/database-sync.mdx @@ -19,121 +19,146 @@ Syncing to a Notion database offers several advantages over regular page sync: --- -## Setting Up Your Database +## How Page Tracking Works -Before syncing, you need to prepare your Notion database with a special property. +MK Notes uses the `id` field in your markdown frontmatter to track which Notion pages correspond to which markdown files. When you use the `--save-id` option, MK Notes automatically writes the Notion page ID back to your markdown files after synchronization. -### Creating the `mk-notes-id` Property +On subsequent syncs, MK Notes uses this stored ID to update the existing Notion page instead of creating a new one. -For MK Notes to identify and update existing pages (when using `--clean` mode), your database must have a **text property** named `mk-notes-id`. - -
-
+### The `id` Frontmatter Property -### Open your Notion database +The `id` property is automatically generated when you sync with the `--save-id` option: -Navigate to the database where you want to sync your markdown content. +```markdown +--- +id: 12345678-1234-1234-1234-123456789abc +title: My Document Title +--- -
-
+Your content here... +``` -### Add a new property + -Click the **+** button in the database header to add a new property. +You don't need to set this property manually. Use the `--save-id` flag when running the sync command, and MK Notes will automatically populate this field with the Notion page ID. -
-
+ -### Configure the property +--- -- **Name**: `mk-notes-id` -- **Type**: `Text` +## Syncing Commands -
-
+### Basic Database Sync - +To sync markdown files to a database: -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`. +```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. ---- +### Using Save ID (Recommended) -## Configuring Your Markdown Files +Use the `--save-id` option to enable incremental updates. This saves the Notion page ID directly to your markdown file's frontmatter after synchronization: -### The `id` Frontmatter Property +```bash +mk-notes sync \ + --input ./docs \ + --destination https://notion.so/myworkspace/database-123456 \ + --notion-api-key secret_abc123... \ + --save-id +``` -To enable page identification and updates, add an `id` property to your markdown frontmatter: +After syncing, your markdown file will be updated with the Notion page ID: ```markdown --- -id: my-unique-page-id +id: 12345678-1234-1234-1234-123456789abc 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 +On subsequent syncs, MK Notes will use this ID to update the existing page directly. - + -Use stable, unique identifiers for your `id` values. Good choices include: +Using `--save-id` provides: -- 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. +- **Incremental updates**: Only changed content is updated +- **No duplicates**: Existing pages are updated instead of recreated +- **Simple setup**: No additional configuration needed in Notion +- **Source of truth**: Your markdown files track the page mappings ---- +### Clean Sync -## Syncing Commands - -### Basic Database Sync - -To sync markdown files to a database: +The `--clean` flag removes existing content before syncing: ```bash mk-notes sync \ --input ./docs \ --destination https://notion.so/myworkspace/database-123456 \ - --notion-api-key secret_abc123... + --notion-api-key secret_abc123... \ + --clean ``` -This creates new pages in the database for each markdown file. However, running this command multiple times will create duplicate entries. +With clean sync enabled: + +1. Existing content in the destination is deleted +2. New pages are created with the updated content -### Clean Sync (Recommended) + -For updating existing content, use the `--clean` flag: +Clean sync removes ALL existing content from the destination. Consider using `--save-id` instead for incremental updates that preserve your Notion page structure. + + + +### Force New Pages + +The `--force-new` option ignores any existing page IDs stored in your markdown frontmatter and creates new pages: ```bash mk-notes sync \ --input ./docs \ --destination https://notion.so/myworkspace/database-123456 \ --notion-api-key secret_abc123... \ - --clean + --force-new ``` -With clean sync enabled: +This is useful when you want to: -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 +- Create a fresh copy of your documentation in the database +- Test synchronization without affecting existing pages +- Migrate content to a new database - + -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. +Using `--force-new` without `--clean` will create duplicate entries in your database. If you want to replace all content, combine it with `--clean`: + +```bash +mk-notes sync -i ./docs -d -k --clean --force-new +``` +### Combining Options + +You can combine `--save-id` and `--force-new` with `--clean` for different workflows: + +| Options | Behavior | +| ------------------------------- | ---------------------------------------------------------- | +| `--save-id` | Updates existing pages by ID, saves new IDs to frontmatter | +| `--force-new` | Creates new pages, ignores existing IDs | +| `--clean --save-id` | Deletes old pages, creates new ones, saves new IDs | +| `--clean --force-new --save-id` | Full reset: deletes all, creates new pages, saves new IDs | + --- ## Adding Custom Database Properties @@ -182,11 +207,11 @@ The database property must already exist in your Notion database. MK Notes will ## Complete Example -Here's a complete example of a markdown file optimized for database sync: +Here's a complete example of a markdown file after syncing with `--save-id`: ```markdown --- -id: getting-started-installation +id: 12345678-1234-1234-1234-123456789abc title: Installation Guide icon: 🚀 properties: @@ -211,30 +236,25 @@ Before you begin, ensure you have... 1. First, install the package... ``` ---- - -## Best Practices + -### 1. Always Use Unique IDs +The `id` field is automatically generated by MK Notes when using `--save-id`. You only need to provide `title`, `icon`, and `properties` in your initial markdown file. -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 +## Best Practices + +### 1. Use Save ID for Incremental Updates -Always use the `--clean` flag when updating existing content to avoid duplicates: +Always use the `--save-id` flag to enable incremental updates and avoid duplicates: ```bash -mk-notes sync -i ./docs -d -k --clean +mk-notes sync -i ./docs -d -k --save-id ``` -### 3. Preview Before Syncing +### 2. Preview Before Syncing Use the preview command to verify your structure: @@ -256,9 +276,9 @@ After syncing, create Notion database views to organize your content: ### Pages are duplicated -**Cause**: Running sync without `--clean` flag, or missing `id` in frontmatter. +**Cause**: Running sync without `--save-id` flag, so pages are created each time instead of updated. -**Solution**: Add unique `id` values to all markdown files and use `--clean` flag. +**Solution**: Use `--save-id` to automatically save Notion page IDs to your frontmatter, enabling incremental updates. ### Properties not appearing @@ -266,11 +286,17 @@ After syncing, create Notion database views to organize your content: **Solution**: Ensure the property exists in your Notion database with the exact same name (case-sensitive). -### Clean sync not deleting old pages +### Pages not updating with save-id + +**Cause**: The page ID stored in the frontmatter no longer exists in Notion (page was deleted manually). + +**Solution**: Use `--force-new` to create new pages, or remove the `id` from your frontmatter to let MK Notes create a new page. + +### Want to start fresh -**Cause**: The `mk-notes-id` property doesn't exist or has a different name. +**Cause**: You want to recreate all pages from scratch. -**Solution**: Create a text property named exactly `mk-notes-id` in your database. +**Solution**: Use `--clean --force-new --save-id` to delete existing content, create new pages, and save the new page IDs. --- diff --git a/docs/content/docs/cli/guides/programmatic-usage.mdx b/docs/content/docs/cli/guides/programmatic-usage.mdx index 9dd0b59..f24093a 100644 --- a/docs/content/docs/cli/guides/programmatic-usage.mdx +++ b/docs/content/docs/cli/guides/programmatic-usage.mdx @@ -8,16 +8,172 @@ Mk Notes can be used programmatically in your Javascript/Typescript projects. --- -The library exposes a MkClient class that allows you to use the main functionalities of Mk Notes directly inside you code. +The library exposes a `MkNotes` class that allows you to use the main functionalities of Mk Notes directly inside your code. + +## Installation + +```bash +npm install mk-notes +``` + +## Basic Usage + +```ts +import MkNotes from 'mk-notes'; + +const client = new MkNotes({ + notionApiKey: 'YOUR_NOTION_SECRET', +}); + +// Preview synchronization +client + .previewSynchronization({ + inputPath: './notes/', + format: 'plainText', + }) + .then(console.log); +``` + +--- + +## Constructor Options + +| Option | Type | Description | Default | +| -------------- | ---------------------------------------- | --------------------------------------- | --------- | +| `notionApiKey` | `string` | Your Notion API secret token (required) | - | +| `LOG_LEVEL` | `'error' \| 'warn' \| 'info' \| 'debug'` | Log level for the client | `'error'` | +| `logger` | `winston.Logger` | Custom Winston logger instance | - | ```ts -import { MkClient } from 'mk-notes'; +import MkNotes from 'mk-notes'; -const client = new MkClient({ - notionApiToken: 'YOUR_NOTION_SECRET', +const client = new MkNotes({ + notionApiKey: process.env.NOTION_API_KEY, + LOG_LEVEL: 'info', }); +``` + +--- + +## Methods + +### `previewSynchronization` + +Preview how your markdown files will be organized in Notion without making any changes. + +```ts +const preview = await client.previewSynchronization({ + inputPath: './docs/', + format: 'plainText', // or 'json' + output: './preview.txt', // optional: save to file +}); + +console.log(preview); +``` + +#### Parameters + +| Parameter | Type | Description | Required | +| ----------- | ----------------------- | ------------------------------------ | -------- | +| `inputPath` | `string` | Path to a markdown file or directory | Yes | +| `format` | `'plainText' \| 'json'` | Output format for the preview | Yes | +| `output` | `string` | Path to save the preview output | No | + +--- + +### `synchronizeMarkdownToNotionFromFileSystem` + +Synchronize markdown files to a Notion page or database. + +```ts +await client.synchronizeMarkdownToNotionFromFileSystem({ + inputPath: './docs/', + parentNotionPageId: 'https://notion.so/myworkspace/page-123456', + cleanSync: false, + lockPage: false, + saveId: true, + forceNew: false, +}); +``` -client.previewSynchronization({ inputPath: './notes/' }).then(console.log); +#### Parameters + +| Parameter | Type | Description | Default | +| -------------------- | --------- | -------------------------------------------------- | -------- | +| `inputPath` | `string` | Path to a markdown file or directory | Required | +| `parentNotionPageId` | `string` | URL of the Notion page or database | Required | +| `cleanSync` | `boolean` | Remove existing content before syncing | `false` | +| `lockPage` | `boolean` | Lock the Notion page after syncing | `false` | +| `saveId` | `boolean` | Save Notion page IDs back to markdown frontmatter | `false` | +| `forceNew` | `boolean` | Force creation of new pages, ignoring existing IDs | `false` | + +--- + +## Examples + +### Basic Sync + +```ts +import MkNotes from 'mk-notes'; + +const client = new MkNotes({ + notionApiKey: process.env.NOTION_API_KEY, +}); + +await client.synchronizeMarkdownToNotionFromFileSystem({ + inputPath: './docs/', + parentNotionPageId: process.env.NOTION_PAGE_URL, +}); +``` + +### Sync with Incremental Updates + +Use `saveId: true` to enable incremental updates. After the first sync, page IDs are saved to your markdown files and subsequent syncs will update existing pages. + +```ts +await client.synchronizeMarkdownToNotionFromFileSystem({ + inputPath: './docs/', + parentNotionPageId: process.env.NOTION_PAGE_URL, + saveId: true, +}); +``` + +### Clean Sync with Locking + +```ts +await client.synchronizeMarkdownToNotionFromFileSystem({ + inputPath: './docs/', + parentNotionPageId: process.env.NOTION_PAGE_URL, + cleanSync: true, + lockPage: true, +}); +``` + +### Force New Pages + +Create new pages regardless of existing IDs in the frontmatter: + +```ts +await client.synchronizeMarkdownToNotionFromFileSystem({ + inputPath: './docs/', + parentNotionPageId: process.env.NOTION_PAGE_URL, + forceNew: true, + saveId: true, // Save the new page IDs +}); +``` + +### Complete Reset + +Combine options for a complete reset: + +```ts +await client.synchronizeMarkdownToNotionFromFileSystem({ + inputPath: './docs/', + parentNotionPageId: process.env.NOTION_PAGE_URL, + cleanSync: true, // Delete existing content + forceNew: true, // Ignore existing IDs + saveId: true, // Save new IDs +}); ``` --- 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 f25260d..1c1c6b7 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 @@ -31,13 +31,15 @@ steps: ## Inputs -| 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` | +| 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 - removes ALL existing content from the destination before syncing | `false` | `false` | +| `lock` | Lock the Notion page after syncing | `false` | `false` | +| `save-id` | Save Notion page IDs back to the source markdown files' frontmatter, enabling incremental updates on subsequent syncs | `false` | `false` | +| `force-new` | Force creation of new pages, ignoring any existing page IDs in the markdown frontmatter | `false` | `false` | ## Destination Types @@ -188,10 +190,115 @@ jobs: clean: 'true' ``` +### Sync with Save ID (Incremental Updates) + +Use `save-id` to enable incremental updates. After syncing, the Notion page IDs are saved back to your markdown files, allowing subsequent syncs to update existing pages instead of creating duplicates. + + + +When using `save-id`, you need to commit the updated markdown files back to your repository. The example below shows how to do this automatically. + + + +```yaml +name: Sync with Incremental Updates + +on: + push: + branches: [main] + paths: ['docs/**'] + +jobs: + sync-incremental: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Sync to Notion with save-id + uses: Myastr0/mk-notes/sync + with: + input: './docs' + destination: ${{ secrets.NOTION_DOCS_PAGE_URL }} + notion-api-key: ${{ secrets.NOTION_API_KEY }} + save-id: 'true' + + - name: Commit updated page IDs + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add docs/ + git diff --staged --quiet || git commit -m "chore: update Notion page IDs" + git push +``` + +### Force New Pages + +Use `force-new` when you want to create new pages, ignoring any existing page IDs stored in the markdown frontmatter: + +```yaml +name: Force New Sync + +on: + workflow_dispatch: + +jobs: + force-new-sync: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Force create new pages in Notion + uses: Myastr0/mk-notes/sync + with: + input: './docs' + destination: ${{ secrets.NOTION_DOCS_PAGE_URL }} + notion-api-key: ${{ secrets.NOTION_API_KEY }} + force-new: 'true' +``` + +### Complete Reset with New IDs + +Combine `clean`, `force-new`, and `save-id` to completely reset your synchronization: + +```yaml +name: Reset Sync + +on: + workflow_dispatch: + +jobs: + reset-sync: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Reset sync to Notion + uses: Myastr0/mk-notes/sync + with: + input: './docs' + destination: ${{ secrets.NOTION_DOCS_PAGE_URL }} + notion-api-key: ${{ secrets.NOTION_API_KEY }} + clean: 'true' + force-new: 'true' + save-id: 'true' + + - name: Commit new page IDs + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add docs/ + git diff --staged --quiet || git commit -m "chore: reset Notion page IDs" + git push +``` + ## Important Notes -- **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 +- **For clean sync**: The `clean` option removes ALL existing content from the destination before syncing +- **For save-id**: Remember to commit the updated markdown files back to your repository +- **For force-new**: Use with caution as it may create duplicate pages if used without `clean` - 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 or database diff --git a/docs/content/docs/writing/styling-notion-page.mdx b/docs/content/docs/writing/styling-notion-page.mdx index 21dfca6..883b3d0 100644 --- a/docs/content/docs/writing/styling-notion-page.mdx +++ b/docs/content/docs/writing/styling-notion-page.mdx @@ -35,17 +35,17 @@ Here's the Mk Notes supported properties to customize your Notion Page _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. +This property stores the Notion page ID that was created during synchronization. When you use the `--save-id` option, MK Notes automatically writes the Notion page ID back to your markdown file's frontmatter. -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. +On subsequent syncs, MK Notes uses this `id` to update the existing Notion page instead of creating a new one. This enables **incremental updates** and prevents duplicate pages. ```markdown -id: +id: 12345678-1234-1234-1234-123456789abc ``` - + -When syncing to a database, the `id` value is stored in a property named `mk-notes-id` in your Notion database. +You don't need to set this property manually. Use the `--save-id` flag when running the sync command, and MK Notes will automatically populate this field with the Notion page ID after synchronization. diff --git a/docs/docs/api/README.md b/docs/docs/api/README.md new file mode 100644 index 0000000..83497e9 --- /dev/null +++ b/docs/docs/api/README.md @@ -0,0 +1,13 @@ +**@mk-notes/cli** + +--- + +# @mk-notes/cli + +## Modules + +- [bin/github-actions/preview](bin/github-actions/preview/README.md) +- [bin/github-actions/sync](bin/github-actions/sync/README.md) +- [bin/github-actions/utils](bin/github-actions/utils/README.md) +- [domains](domains/README.md) +- [infrastructure](infrastructure/README.md) diff --git a/docs/docs/api/bin/github-actions/preview/README.md b/docs/docs/api/bin/github-actions/preview/README.md new file mode 100644 index 0000000..35318e2 --- /dev/null +++ b/docs/docs/api/bin/github-actions/preview/README.md @@ -0,0 +1,11 @@ +[**@mk-notes/cli**](../../../README.md) + +--- + +[@mk-notes/cli](../../../README.md) / bin/github-actions/preview + +# bin/github-actions/preview + +## Functions + +- [preview](functions/preview.md) diff --git a/docs/docs/api/bin/github-actions/preview/functions/preview.md b/docs/docs/api/bin/github-actions/preview/functions/preview.md new file mode 100644 index 0000000..6217195 --- /dev/null +++ b/docs/docs/api/bin/github-actions/preview/functions/preview.md @@ -0,0 +1,21 @@ +[**@mk-notes/cli**](../../../../README.md) + +--- + +[@mk-notes/cli](../../../../README.md) / [bin/github-actions/preview](../README.md) / preview + +# Function: preview() + +> **preview**(`earlyExit`): `Promise`\<`void`\> + +Defined in: [bin/github-actions/preview.ts:16](https://github.com/Myastr0/mk-notes/blob/97b74f53ad66ca00f586effdd38d74b62f51ee66/src/bin/github-actions/preview.ts#L16) + +## Parameters + +### earlyExit + +`boolean` = `false` + +## Returns + +`Promise`\<`void`\> diff --git a/docs/docs/api/bin/github-actions/sync/README.md b/docs/docs/api/bin/github-actions/sync/README.md new file mode 100644 index 0000000..9339de6 --- /dev/null +++ b/docs/docs/api/bin/github-actions/sync/README.md @@ -0,0 +1,11 @@ +[**@mk-notes/cli**](../../../README.md) + +--- + +[@mk-notes/cli](../../../README.md) / bin/github-actions/sync + +# bin/github-actions/sync + +## Functions + +- [sync](functions/sync.md) diff --git a/docs/docs/api/bin/github-actions/sync/functions/sync.md b/docs/docs/api/bin/github-actions/sync/functions/sync.md new file mode 100644 index 0000000..2f334fb --- /dev/null +++ b/docs/docs/api/bin/github-actions/sync/functions/sync.md @@ -0,0 +1,21 @@ +[**@mk-notes/cli**](../../../../README.md) + +--- + +[@mk-notes/cli](../../../../README.md) / [bin/github-actions/sync](../README.md) / sync + +# Function: sync() + +> **sync**(`earlyExit`): `Promise`\<`void`\> + +Defined in: [bin/github-actions/sync.ts:17](https://github.com/Myastr0/mk-notes/blob/97b74f53ad66ca00f586effdd38d74b62f51ee66/src/bin/github-actions/sync.ts#L17) + +## Parameters + +### earlyExit + +`boolean` = `false` + +## Returns + +`Promise`\<`void`\> diff --git a/docs/docs/api/bin/github-actions/utils/README.md b/docs/docs/api/bin/github-actions/utils/README.md new file mode 100644 index 0000000..054c445 --- /dev/null +++ b/docs/docs/api/bin/github-actions/utils/README.md @@ -0,0 +1,11 @@ +[**@mk-notes/cli**](../../../README.md) + +--- + +[@mk-notes/cli](../../../README.md) / bin/github-actions/utils + +# bin/github-actions/utils + +## Functions + +- [getInputAsBool](functions/getInputAsBool.md) diff --git a/docs/docs/api/bin/github-actions/utils/functions/getInputAsBool.md b/docs/docs/api/bin/github-actions/utils/functions/getInputAsBool.md new file mode 100644 index 0000000..33069e1 --- /dev/null +++ b/docs/docs/api/bin/github-actions/utils/functions/getInputAsBool.md @@ -0,0 +1,25 @@ +[**@mk-notes/cli**](../../../../README.md) + +--- + +[@mk-notes/cli](../../../../README.md) / [bin/github-actions/utils](../README.md) / getInputAsBool + +# Function: getInputAsBool() + +> **getInputAsBool**(`name`, `options`?): `boolean` + +Defined in: [bin/github-actions/utils/index.ts:3](https://github.com/Myastr0/mk-notes/blob/97b74f53ad66ca00f586effdd38d74b62f51ee66/src/bin/github-actions/utils/index.ts#L3) + +## Parameters + +### name + +`string` + +### options? + +`InputOptions` + +## Returns + +`boolean` diff --git a/docs/docs/api/domains/README.md b/docs/docs/api/domains/README.md new file mode 100644 index 0000000..33cc9dd --- /dev/null +++ b/docs/docs/api/domains/README.md @@ -0,0 +1,7 @@ +[**@mk-notes/cli**](../README.md) + +--- + +[@mk-notes/cli](../README.md) / domains + +# domains diff --git a/docs/docs/api/infrastructure/README.md b/docs/docs/api/infrastructure/README.md new file mode 100644 index 0000000..8f9ed0e --- /dev/null +++ b/docs/docs/api/infrastructure/README.md @@ -0,0 +1,7 @@ +[**@mk-notes/cli**](../README.md) + +--- + +[@mk-notes/cli](../README.md) / infrastructure + +# infrastructure diff --git a/preview/index.js b/preview/index.js index fa906bb..e18504e 100644 --- a/preview/index.js +++ b/preview/index.js @@ -14894,7 +14894,7 @@ module.exports.Type = __nccwpck_require__(7600); module.exports.Schema = __nccwpck_require__(5947); module.exports.FAILSAFE_SCHEMA = __nccwpck_require__(7891); module.exports.JSON_SCHEMA = __nccwpck_require__(6188); -module.exports.CORE_SCHEMA = __nccwpck_require__(8733); +module.exports.CORE_SCHEMA = __nccwpck_require__(3495); module.exports.DEFAULT_SAFE_SCHEMA = __nccwpck_require__(2685); module.exports.DEFAULT_FULL_SCHEMA = __nccwpck_require__(4637); module.exports.load = loader.load; @@ -17747,7 +17747,7 @@ module.exports = Schema; /***/ }), -/***/ 8733: +/***/ 3495: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { "use strict"; @@ -17825,7 +17825,7 @@ var Schema = __nccwpck_require__(5947); module.exports = new Schema({ include: [ - __nccwpck_require__(8733) + __nccwpck_require__(3495) ], implicit: [ __nccwpck_require__(4867), @@ -19617,7 +19617,7 @@ module.exports = $gOPD; var origSymbol = typeof Symbol !== 'undefined' && Symbol; -var hasSymbolSham = __nccwpck_require__(5876); +var hasSymbolSham = __nccwpck_require__(8733); /** @type {import('.')} */ module.exports = function hasNativeSymbols() { @@ -19632,7 +19632,7 @@ module.exports = function hasNativeSymbols() { /***/ }), -/***/ 5876: +/***/ 8733: /***/ ((module) => { "use strict"; @@ -19691,7 +19691,7 @@ module.exports = function hasSymbols() { "use strict"; -var hasSymbols = __nccwpck_require__(5876); +var hasSymbols = __nccwpck_require__(8733); /** @type {import('.')} */ module.exports = function hasToStringTagShams() { @@ -75020,7 +75020,7 @@ class MkNotes { /** * Synchronize a markdown file to Notion */ - async synchronizeMarkdownToNotionFromFileSystem({ inputPath, parentNotionPageId, cleanSync = false, lockPage = false, }) { + async synchronizeMarkdownToNotionFromFileSystem({ inputPath, parentNotionPageId, cleanSync = false, lockPage = false, saveId = false, forceNew = false, }) { const synchronizeMarkdownToNotion = new domains_1.SynchronizeMarkdownToNotion({ logger: this.logger, destinationRepository: this.infrastructureInstances.notionDestination, @@ -75032,6 +75032,8 @@ class MkNotes { notionParentPageUrl: parentNotionPageId, cleanSync, lockPage, + saveId, + forceNew, }); } } @@ -75048,7 +75050,7 @@ exports.MkNotes = MkNotes; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.preview = void 0; const core_1 = __nccwpck_require__(7484); -const previewSynchronization_1 = __nccwpck_require__(9257); +const preview_synchronization_feature_1 = __nccwpck_require__(3833); const MkNotes_1 = __nccwpck_require__(9550); var Inputs; (function (Inputs) { @@ -75065,7 +75067,7 @@ const preview = async (earlyExit = false) => { const input = (0, core_1.getInput)(Inputs.Input, { required: true }); const output = (0, core_1.getInput)(Inputs.Output, { required: false }); const format = (0, core_1.getInput)(Inputs.Format, { required: true }); - if (!(0, previewSynchronization_1.isValidFormat)(format)) { + if (!(0, preview_synchronization_feature_1.isValidFormat)(format)) { throw new Error(`Invalid format: ${format} - must be "plainText" or "json"`); } const mkNotes = new MkNotes_1.MkNotes({ @@ -75108,15 +75110,15 @@ if (require.main === require.cache[eval('__filename')]) { /***/ }), -/***/ 7512: +/***/ 9548: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.CalloutElement = exports.SpecialCalloutType = void 0; -const Element_class_1 = __nccwpck_require__(5238); -const types_1 = __nccwpck_require__(9461); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); const specialCalloutRegex = // eslint-disable-next-line no-useless-escape /^\s*\[\!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)/ims; @@ -75135,8 +75137,8 @@ class CalloutElement extends Element_class_1.Element { static isSpecialCalloutText(text) { return specialCalloutRegex.test(text.trim()); } - constructor({ icon, text }) { - super(types_1.ElementType.Callout); + constructor({ id, icon, text, }) { + super({ id, type: types_1.ElementType.Callout }); this.icon = icon; this.text = text; const { text: parsedText, calloutType } = this.getSpecialCalloutTypeAndText(text); @@ -75180,21 +75182,24 @@ class CalloutElement extends Element_class_1.Element { } return this.icon; } + toContentString() { + return `[!${this.calloutType}](${this.text})`; + } } exports.CalloutElement = CalloutElement; /***/ }), -/***/ 9769: +/***/ 1925: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); var ElementCodeLanguage; (function (ElementCodeLanguage) { ElementCodeLanguage["JavaScript"] = "javascript"; @@ -75226,29 +75231,35 @@ exports.isElementCodeLanguage = isElementCodeLanguage; class CodeElement extends Element_class_1.Element { language; text; - constructor({ language, text, }) { - super(types_1.ElementType.Code); + constructor({ id, language, text, }) { + super({ id, type: types_1.ElementType.Code }); this.language = language; this.text = text; } + toContentString() { + return `\`\`\`${this.language}\n${this.text}\n\`\`\``; + } } exports.CodeElement = CodeElement; /***/ }), -/***/ 1983: +/***/ 9755: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class DividerElement extends Element_class_1.Element { - constructor() { - super(types_1.ElementType.Divider); + constructor({ id } = { id: undefined }) { + super({ id, type: types_1.ElementType.Divider }); + } + toContentString() { + return '------'; } } exports.DividerElement = DividerElement; @@ -75256,7 +75267,7 @@ exports.DividerElement = DividerElement; /***/ }), -/***/ 5238: +/***/ 4154: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -75264,25 +75275,30 @@ exports.DividerElement = DividerElement; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.Element = void 0; class Element { + id; type; - constructor(type) { + constructor({ id, type }) { + this.id = id; this.type = type; } + toContentString() { + throw new Error('toContentString must be implemented by the subclass'); + } } exports.Element = Element; /***/ }), -/***/ 1020: +/***/ 8792: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class EquationElement extends Element_class_1.Element { equation; styles = { @@ -75292,8 +75308,8 @@ class EquationElement extends Element_class_1.Element { underline: false, code: false, }; - constructor({ equation, styles, }) { - super(types_1.ElementType.Equation); + constructor({ id, equation, styles, }) { + super({ id, type: types_1.ElementType.Equation }); this.equation = equation; this.styles.bold = styles?.bold || false; this.styles.italic = styles?.italic || false; @@ -75301,21 +75317,34 @@ class EquationElement extends Element_class_1.Element { this.styles.underline = styles?.underline || false; this.styles.code = styles?.code || false; } + toContentString() { + let { equation } = this; + if (this.styles.italic) { + equation = `_${equation}_`; + } + if (this.styles.strikethrough) { + equation = `~~${equation}~~`; + } + if (this.styles.underline) { + equation = `__${equation}__`; + } + return equation; + } } exports.EquationElement = EquationElement; /***/ }), -/***/ 5320: +/***/ 9524: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); /** * Element that represents a file in the system */ @@ -75325,50 +75354,56 @@ class FileElement extends Element_class_1.Element { creationDate; lastUpdatedDate; extension; - constructor({ content, name, creationDate, lastUpdatedDate, extension, }) { - super(types_1.ElementType.File); + constructor({ id, content, name, creationDate, lastUpdatedDate, extension, }) { + super({ id, type: types_1.ElementType.File }); this.content = content; this.name = name; this.creationDate = creationDate; this.lastUpdatedDate = lastUpdatedDate; this.extension = extension; } + toContentString() { + return `[${this.name}](${this.content})`; + } } exports.FileElement = FileElement; /***/ }), -/***/ 877: +/***/ 5193: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class HtmlElement extends Element_class_1.Element { html; - constructor({ html }) { - super(types_1.ElementType.Html); + constructor({ id, html }) { + super({ id, type: types_1.ElementType.Html }); this.html = html; } + toContentString() { + return this.html; + } } exports.HtmlElement = HtmlElement; /***/ }), -/***/ 7047: +/***/ 7515: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class ImageElement extends Element_class_1.Element { base64; url; @@ -75378,8 +75413,8 @@ class ImageElement extends Element_class_1.Element { lastUpdatedDate; extension; filepath; - constructor({ base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { - super(types_1.ElementType.Image); + constructor({ id, base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { + super({ id, type: types_1.ElementType.Image }); this.name = name; this.creationDate = creationDate; this.lastUpdatedDate = lastUpdatedDate; @@ -75389,30 +75424,38 @@ class ImageElement extends Element_class_1.Element { this.caption = caption; this.filepath = filepath; } + toContentString() { + return `![${this.name}](${this.url})`; + } } exports.ImageElement = ImageElement; /***/ }), -/***/ 538: +/***/ 3070: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class LinkElement extends Element_class_1.Element { url; text; caption; - constructor({ url, text, caption, }) { - super(types_1.ElementType.Link); + filepath; + constructor({ id, url, text, caption, filepath, }) { + super({ id, type: types_1.ElementType.Link }); this.url = url; this.text = text; this.caption = caption; + this.filepath = filepath; + } + toContentString() { + return `[${this.text}](${this.url})`; } } exports.LinkElement = LinkElement; @@ -75420,56 +75463,69 @@ exports.LinkElement = LinkElement; /***/ }), -/***/ 1539: +/***/ 3119: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class ListItemElement extends Element_class_1.Element { listType; text; children; - constructor({ listType, text, children, }) { - super(types_1.ElementType.ListItem); + constructor({ id, listType, text, children, }) { + super({ id, type: types_1.ElementType.ListItem }); this.listType = listType; this.text = text; this.children = children; } + toContentString() { + let content = ''; + if (this.listType === 'ordered') { + content = `1. ${this.text.map((element) => element.toContentString()).join('')}`; + } + else { + content = `- ${this.text.map((element) => element.toContentString()).join('')}`; + } + if (this.children) { + content += `\n${this.children.map((element) => element.toContentString()).join('')}`; + } + return content; + } } exports.ListItemElement = ListItemElement; /***/ }), -/***/ 2407: +/***/ 6939: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); /** * 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; + source; + constructor({ id, title, icon, content = [], properties, source, }) { + super({ id, type: types_1.ElementType.Page }); this.title = title; this.icon = icon; this.content = content; this.properties = properties; + this.source = source; } getIcon() { return this.icon; @@ -75486,60 +75542,69 @@ exports.PageElement = PageElement; /***/ }), -/***/ 9248: +/***/ 8076: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class QuoteElement extends Element_class_1.Element { text; - constructor({ text }) { - super(types_1.ElementType.Quote); + constructor({ id, text }) { + super({ id, type: types_1.ElementType.Quote }); this.text = text; } + toContentString() { + return `> ${this.text}`; + } } exports.QuoteElement = QuoteElement; /***/ }), -/***/ 9704: +/***/ 60: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class TableElement extends Element_class_1.Element { rows; - constructor({ rows }) { - super(types_1.ElementType.Table); + constructor({ id, rows }) { + super({ id, type: types_1.ElementType.Table }); this.rows = rows; } + toContentString() { + return this.rows.map((row) => row.join(' | ')).join('\n'); + } } exports.TableElement = TableElement; /***/ }), -/***/ 4728: +/***/ 5172: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class TableOfContentsElement extends Element_class_1.Element { - constructor() { - super(types_1.ElementType.TableOfContents); + constructor({ id } = { id: undefined }) { + super({ id, type: types_1.ElementType.TableOfContents }); + } + toContentString() { + return ''; } } exports.TableOfContentsElement = TableOfContentsElement; @@ -75547,15 +75612,15 @@ exports.TableOfContentsElement = TableOfContentsElement; /***/ }), -/***/ 8731: +/***/ 4375: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); var TextElementLevel; (function (TextElementLevel) { TextElementLevel["Heading1"] = "heading_1"; @@ -75583,8 +75648,8 @@ class TextElement extends Element_class_1.Element { underline: false, code: false, }; - constructor({ text, level = TextElementLevel.Paragraph, styles, }) { - super(types_1.ElementType.Text); + constructor({ id, text, level = TextElementLevel.Paragraph, styles, }) { + super({ id, type: types_1.ElementType.Text }); this.text = text; this.level = level; this.styles.bold = styles?.bold || false; @@ -75593,36 +75658,57 @@ class TextElement extends Element_class_1.Element { this.styles.underline = styles?.underline || false; this.styles.code = styles?.code || false; } + toContentString() { + let { text } = this; + if (typeof text === 'string') { + return text; + } + text = text.map((element) => element.toContentString()).join(''); + if (this.styles.italic) { + text = `_${text}_`; + } + if (this.styles.strikethrough) { + text = `~~${text}~~`; + } + if (this.styles.underline) { + text = `__${text}__`; + } + return text; + } } exports.TextElement = TextElement; /***/ }), -/***/ 1690: +/***/ 8878: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class ToggleElement extends Element_class_1.Element { title; children; - constructor({ title, children }) { - super(types_1.ElementType.Toggle); + constructor({ id, title, children, }) { + super({ id, type: types_1.ElementType.Toggle }); this.title = title; this.children = children; } + toContentString() { + const { title } = this; + return `[${title}](${this.children.map((element) => element.toContentString()).join('')})`; + } } exports.ToggleElement = ToggleElement; /***/ }), -/***/ 2052: +/***/ 7672: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -75642,28 +75728,28 @@ 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); +__exportStar(__nccwpck_require__(9548), exports); +__exportStar(__nccwpck_require__(1925), exports); +__exportStar(__nccwpck_require__(9755), exports); +__exportStar(__nccwpck_require__(4154), exports); +__exportStar(__nccwpck_require__(8792), exports); +__exportStar(__nccwpck_require__(9524), exports); +__exportStar(__nccwpck_require__(5193), exports); +__exportStar(__nccwpck_require__(7515), exports); +__exportStar(__nccwpck_require__(3070), exports); +__exportStar(__nccwpck_require__(3119), exports); +__exportStar(__nccwpck_require__(6939), exports); +__exportStar(__nccwpck_require__(8076), exports); +__exportStar(__nccwpck_require__(60), exports); +__exportStar(__nccwpck_require__(5172), exports); +__exportStar(__nccwpck_require__(4375), exports); +__exportStar(__nccwpck_require__(8878), exports); +__exportStar(__nccwpck_require__(7153), exports); /***/ }), -/***/ 9461: +/***/ 7153: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -75690,16 +75776,6 @@ var ElementType; })(ElementType || (exports.ElementType = ElementType = {})); -/***/ }), - -/***/ 2057: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); - - /***/ }), /***/ 7591: @@ -75722,15 +75798,25 @@ 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__(2057), exports); -__exportStar(__nccwpck_require__(2052), exports); -__exportStar(__nccwpck_require__(464), exports); +__exportStar(__nccwpck_require__(7672), exports); +__exportStar(__nccwpck_require__(7120), exports); +__exportStar(__nccwpck_require__(467), exports); __exportStar(__nccwpck_require__(5666), exports); /***/ }), -/***/ 464: +/***/ 7120: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); + + +/***/ }), + +/***/ 467: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -75788,331 +75874,8 @@ 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__(9257), exports); -__exportStar(__nccwpck_require__(5270), exports); - - -/***/ }), - -/***/ 9257: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PreviewSynchronization = exports.isValidFormat = void 0; -const sitemap_1 = __nccwpck_require__(4617); -const serializers_1 = __nccwpck_require__(9151); -const isValidFormat = (format) => { - return (typeof format === 'string' && (format === 'plainText' || format === 'json')); -}; -exports.isValidFormat = isValidFormat; -class PreviewSynchronization { - sourceRepository; - constructor(params) { - this.sourceRepository = params.sourceRepository; - } - async execute(args, { format } = {}) { - // Check if the GitHub repository is accessible - try { - await this.sourceRepository.sourceIsAccessible(args); - } - catch (err) { - throw new Error(`Source is not accessible:`, { - cause: err, - }); - } - let sitemapSerializer; - if (!format) { - sitemapSerializer = serializers_1.serializeInPlainText; - } - else { - switch (format) { - case 'plainText': - sitemapSerializer = serializers_1.serializeInPlainText; - break; - case 'json': - sitemapSerializer = serializers_1.serializeInJson; - break; - default: - throw new Error(`Invalid serialization format:`, format); - } - } - const filePaths = await this.sourceRepository.getFilePathList(args); - const siteMap = sitemap_1.SiteMap.buildFromFilePaths(filePaths); - return sitemapSerializer(siteMap); - } -} -exports.PreviewSynchronization = PreviewSynchronization; - - -/***/ }), - -/***/ 5270: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.SynchronizeMarkdownToNotion = void 0; -const elements_1 = __nccwpck_require__(7591); -const sitemap_1 = __nccwpck_require__(4617); -class SynchronizeMarkdownToNotion { - sourceRepository; - destinationRepository; - elementConverter; - logger; - constructor(params) { - this.sourceRepository = params.sourceRepository; - this.destinationRepository = params.destinationRepository; - this.elementConverter = params.elementConverter; - this.logger = params.logger; - } - async execute(args) { - const { notionParentPageUrl, cleanSync, lockPage, ...others } = args; - 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'); - } - try { - 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, - parentObjectId: notionObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - this.logger.info('Synchronization process completed successfully'); - } - catch (error) { - if (error instanceof Error) { - this.logger.error(`Synchronization process failed`, { - error, - }); - } - throw error; - } - } - /** - * 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, 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, - }); - this.logger.info(`Added content from ${node.filepath} to parent page`); - await this.lockPageIfNeeded(parentObjectId, lockPage); - return parentObjectId; - } - if (cleanSync) { - await this.cleanSyncDatabase({ - databaseId: parentObjectId, - pageElement, - }); - } - // 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; - } - 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 - */ - 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, cleanSync, }) { - 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, - cleanSync, - }); - } - else { - parentPageId = await this.synchronizeRootNode({ - node: node.children[0], - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - } - break; - case 'page': - if (this.getIsRootNode(node)) { - parentPageId = await this.synchronizeRootNode({ - node, - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - } - 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; - } - } - } - getIsRootNode(node) { - return node.parent === null && !['', undefined].includes(node.filepath); - } -} -exports.SynchronizeMarkdownToNotion = SynchronizeMarkdownToNotion; +__exportStar(__nccwpck_require__(3833), exports); +__exportStar(__nccwpck_require__(4122), exports); /***/ }), @@ -76145,7 +75908,7 @@ __exportStar(__nccwpck_require__(1230), exports); /***/ }), -/***/ 3913: +/***/ 9749: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -76190,18 +75953,6 @@ 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: @@ -76235,114 +75986,7 @@ exports.isNotionNestingValidationError = isNotionNestingValidationError; /***/ }), -/***/ 8188: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isPageObjectResponse = void 0; -const isPageObjectResponse = (obj) => { - return (typeof obj === 'object' && - obj !== null && - obj.object === 'page' && - typeof obj.id === 'string' && - typeof obj.created_time === 'string' && - typeof obj.last_edited_time === 'string' && - typeof obj.archived === 'boolean' && - typeof obj.in_trash === 'boolean' && - typeof obj.url === 'string' && - (typeof obj.public_url === 'string' || obj.public_url === null) && - isParent(obj.parent) && - typeof obj.properties === 'object' && - isIcon(obj.icon) && - isCover(obj.cover) && - isCreatedBy(obj.created_by) && - isLastEditedBy(obj.last_edited_by)); -}; -exports.isPageObjectResponse = isPageObjectResponse; -// Helper function to check the parent field -function isParent(parent) { - return (typeof parent === 'object' && - parent !== null && - 'type' in parent && - 'database_id' in parent && - ((parent.type === 'database_id' && - typeof parent.database_id === 'string') || - (parent.type === 'page_id' && - 'page_id' in parent && - typeof parent.page_id === 'string') || - (parent.type === 'block_id' && - 'block_id' in parent && - typeof parent.block_id === 'string') || - (parent.type === 'workspace' && - 'workspace' in parent && - parent.workspace === true))); -} -// Helper function to check the icon field -function isIcon(icon) { - return (icon === null || - (typeof icon === 'object' && - (('type' in icon && - typeof icon.type === 'string' && - icon.type === 'emoji' && - 'emoji' in icon && - typeof icon.emoji === 'string') || - ('type' in icon && - typeof icon.type === 'string' && - icon.type === 'external' && - 'external' in icon && - typeof icon.external === 'object' && - icon.external !== null && - 'url' in icon.external && - typeof icon.external.url === 'string') || - ('type' in icon && - typeof icon.type === 'string' && - icon.type === 'file' && - 'file' in icon && - typeof icon.file === 'object' && - icon.file !== null && - 'url' in icon.file && - 'expiry_time' in icon.file && - typeof icon.file.url === 'string' && - typeof icon.file.expiry_time === 'string')))); -} -// Helper function to check the cover field -function isCover(cover) { - return (cover === null || - (typeof cover === 'object' && - (('type' in cover && - typeof cover.type === 'string' && - cover.type === 'external' && - 'external' in cover && - typeof cover.external === 'object' && - cover.external !== null && - 'url' in cover.external && - typeof cover.external.url === 'string') || - ('type' in cover && - typeof cover.type === 'string' && - cover.type === 'file' && - 'file' in cover && - cover.file !== null && - typeof cover.file === 'object' && - 'url' in cover.file && - 'expiry_time' in cover.file && - typeof cover.file.url === 'string' && - typeof cover.file.expiry_time === 'string')))); -} -// Helper function to check created_by field -function isCreatedBy(created_by) { - return typeof created_by === 'object'; // Assuming PartialUserObjectResponse is an object, refine this if needed -} -// Helper function to check last_edited_by field -function isLastEditedBy(last_edited_by) { - return typeof last_edited_by === 'object'; // Assuming PartialUserObjectResponse is an object, refine this if needed -} - - -/***/ }), - -/***/ 4350: +/***/ 386: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -76383,7 +76027,7 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SiteMap = void 0; const path = __importStar(__nccwpck_require__(6928)); -const TreeNode_1 = __nccwpck_require__(2553); +const TreeNode_1 = __nccwpck_require__(1349); class SiteMap { _root; constructor() { @@ -76529,7 +76173,7 @@ exports.SiteMap = SiteMap; /***/ }), -/***/ 2553: +/***/ 1349: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -76617,12 +76261,12 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.TreeNode = exports.SiteMap = exports.serializers = void 0; -exports.serializers = __importStar(__nccwpck_require__(9151)); -var SiteMap_1 = __nccwpck_require__(4350); +exports.serializers = exports.TreeNode = exports.SiteMap = void 0; +var SiteMap_1 = __nccwpck_require__(386); Object.defineProperty(exports, "SiteMap", ({ enumerable: true, get: function () { return SiteMap_1.SiteMap; } })); -var TreeNode_1 = __nccwpck_require__(2553); +var TreeNode_1 = __nccwpck_require__(1349); Object.defineProperty(exports, "TreeNode", ({ enumerable: true, get: function () { return TreeNode_1.TreeNode; } })); +exports.serializers = __importStar(__nccwpck_require__(9151)); /***/ }), @@ -76717,12 +76361,367 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); /***/ }), -/***/ 7226: -/***/ ((__unused_webpack_module, exports) => { +/***/ 3833: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PreviewSynchronization = exports.isValidFormat = void 0; +const sitemap_1 = __nccwpck_require__(4617); +const serializers_1 = __nccwpck_require__(9151); +const isValidFormat = (format) => { + return (typeof format === 'string' && (format === 'plainText' || format === 'json')); +}; +exports.isValidFormat = isValidFormat; +class PreviewSynchronization { + sourceRepository; + constructor(params) { + this.sourceRepository = params.sourceRepository; + } + async execute(args, { format } = {}) { + // Check if the source repository is accessible + try { + await this.sourceRepository.sourceIsAccessible(args); + } + catch (err) { + throw new Error(`Source is not accessible:`, { + cause: err, + }); + } + let sitemapSerializer; + if (!format) { + sitemapSerializer = serializers_1.serializeInPlainText; + } + else { + switch (format) { + case 'plainText': + sitemapSerializer = serializers_1.serializeInPlainText; + break; + case 'json': + sitemapSerializer = serializers_1.serializeInJson; + break; + default: + throw new Error(`Invalid serialization format:`, format); + } + } + const filePaths = await this.sourceRepository.getFilePathList(args); + const siteMap = sitemap_1.SiteMap.buildFromFilePaths(filePaths); + return sitemapSerializer(siteMap); + } +} +exports.PreviewSynchronization = PreviewSynchronization; + + +/***/ }), + +/***/ 4122: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SynchronizeMarkdownToNotion = void 0; +const elements_1 = __nccwpck_require__(7591); +const sitemap_1 = __nccwpck_require__(4617); +class SynchronizeMarkdownToNotion { + sourceRepository; + destinationRepository; + elementConverter; + logger; + constructor(params) { + this.sourceRepository = params.sourceRepository; + this.destinationRepository = params.destinationRepository; + this.elementConverter = params.elementConverter; + this.logger = params.logger; + } + async execute(args) { + const { notionParentPageUrl, cleanSync, lockPage, saveId, forceNew, ...others } = args; + 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'); + } + try { + 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 + const pages = await this.synchronizeTreeNode({ + node: siteMap.root, + parentObjectId: notionObjectId, + parentObjectType, + lockPage, + cleanSync, + forceNew, + }); + this.logger.info('Synchronization process completed successfully'); + if (saveId) { + await this.executeSaveIdOperation(pages); + this.logger.info('Page IDs saved to source repository'); + } + } + catch (error) { + if (error instanceof Error) { + this.logger.error(`Synchronization process failed`, { + error, + }); + } + throw error; + } + } + /** + * ------------- + * PRIVATE METHODS + * ------------- + */ + /** + * Executes the save ID operation + */ + async executeSaveIdOperation(syncResult) { + const promises = syncResult.map(async (element) => { + const file = await this.elementConverter.convertFromElement(element.page); + await this.sourceRepository.updateFile(file); + }); + await Promise.all(promises); + } + /** + * Fetches a file and converts it to a PageElement + */ + async fetchAndConvertToPageElement(filePath, { forceNew } = {}) { + const file = await this.sourceRepository.getFile({ path: filePath }); + const element = this.elementConverter.convertToElement(file); + if (!(element instanceof elements_1.PageElement)) { + throw new Error('Element is not a PageElement'); + } + if (forceNew) { + element.id = undefined; + } + 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}`); + } + } + /** + * Main orchestrator for synchronizing a tree node and its children + */ + async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, cleanSync, forceNew, }) { + this.validateParentObjectType(parentObjectType); + const nodeToSync = this.getNodeToSynchronize(node, parentObjectType); + const results = []; + const { page: rootPageElement, treeNodeId: rootTreeNodeId } = await this.synchronizeRootNode({ + node: nodeToSync, + parentObjectId, + parentObjectType, + lockPage, + cleanSync, + forceNew, + }); + results.push({ page: rootPageElement, treeNodeId: rootTreeNodeId }); + for (const childNode of node.children) { + try { + const childResults = await this.synchronizeChildNode({ + childNode, + parentPageId: rootPageElement.id, + lockPage, + forceNew, + }); + results.push(...childResults); + } + catch (error) { + this.logger.error(`Failed to synchronize file: ${childNode.filepath}`, { + error, + }); + throw error; + } + } + return results; + } + /** + * 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, cleanSync, forceNew, }) { + this.logger.info(`Adding content from ${node.filepath} to parent ${parentObjectType}`); + const pageElement = await this.fetchAndConvertToPageElement(node.filepath, { + forceNew, + }); + if (pageElement.id !== undefined) { + const existingPage = await this.destinationRepository.getPage({ + pageId: pageElement.id, + }); + if (existingPage) { + await this.destinationRepository.updatePage({ + pageElement, + pageId: pageElement.id, + }); + return { + page: pageElement, + treeNodeId: node.id, + }; + } + } + 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 }); + } + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + }); + pageElement.id = newPage.pageId; + return { page: pageElement, treeNodeId: node.id }; + } + const updatedPage = await this.destinationRepository.updatePage({ + pageId: parentObjectId, + pageElement, + }); + pageElement.id = updatedPage.pageId; + this.logger.info(`Updated parent page ${parentObjectId}`); + await this.lockPageIfNeeded(parentObjectId, lockPage); + return { page: pageElement, treeNodeId: node.id }; + } + if (cleanSync) { + await this.destinationRepository.deleteChildBlocks({ + parentPageId: parentObjectId, + }); + } + // parentObjectType === 'database' + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + }); + if (!newPage.pageId) { + throw new Error('New page ID is undefined'); + } + pageElement.id = newPage.pageId; + return { page: pageElement, treeNodeId: node.id }; + } + /** + * Synchronizes a child node and its descendants recursively + */ + async synchronizeChildNode({ childNode, parentPageId, lockPage, forceNew, }) { + const syncResult = []; + const filePath = childNode.filepath; + this.logger.info(`Processing file: ${filePath}`); + const pageElement = await this.fetchAndConvertToPageElement(filePath, { + forceNew, + }); + if (pageElement.id !== undefined) { + await this.destinationRepository.updatePage({ + pageId: pageElement.id, + pageElement, + }); + } + else { + // 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', + }); + this.logger.info(`Created Notion page for file: ${filePath}`); + if (!newPage.pageId) { + throw new Error('Page ID is undefined'); + } + pageElement.id = newPage.pageId; + } + syncResult.push({ + page: pageElement, + treeNodeId: childNode.id, + }); + // Recursively process children + for (const grandChild of childNode.children) { + const grandChildSyncResult = await this.synchronizeChildNode({ + childNode: grandChild, + parentPageId: pageElement.id, + lockPage, + forceNew, + }); + syncResult.push(...grandChildSyncResult); + } + await this.lockPageIfNeeded(pageElement.id, lockPage); + return syncResult; + } + getIsRootNode(node) { + return node.parent === null && !['', undefined].includes(node.filepath); + } + /** + * Validates that the parent object type is supported for synchronization + */ + validateParentObjectType(parentObjectType) { + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } + if (!['database', 'page'].includes(parentObjectType)) { + throw new Error(`Invalid parent object type: ${parentObjectType}`); + } + } + /** + * Determines the effective node to synchronize as root based on parent type + * For database parents with non-root nodes, uses the first child + * For page parents, returns null if the node is not a root node + */ + getNodeToSynchronize(node, parentObjectType) { + const isRootNode = this.getIsRootNode(node); + if (parentObjectType === 'database' && !isRootNode) { + return node.children[0]; + } + if (parentObjectType === 'page' && !isRootNode) { + return node.children[0]; + } + return node; + } +} +exports.SynchronizeMarkdownToNotion = SynchronizeMarkdownToNotion; /***/ }), @@ -76747,23 +76746,50 @@ 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__(7226), exports); -__exportStar(__nccwpck_require__(111), exports); +__exportStar(__nccwpck_require__(5873), exports); +__exportStar(__nccwpck_require__(8362), exports); + + +/***/ }), + +/***/ 5873: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); /***/ }), -/***/ 111: +/***/ 8362: /***/ ((__unused_webpack_module, exports) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.File = void 0; +class File { + name; + icon; + content; + path; + lastUpdated; + extension; + constructor({ name, content, path, lastUpdated, extension, }) { + this.name = name; + this.content = content; + this.path = path; + this.lastUpdated = lastUpdated; + this.extension = extension; + } +} +exports.File = File; /***/ }), -/***/ 9375: +/***/ 2378: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -76771,6 +76797,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.FileConverter = void 0; const elements_1 = __nccwpck_require__(7591); +const synchronization_1 = __nccwpck_require__(1230); class FileConverter { htmlParser; markdownParser; @@ -76780,11 +76807,6 @@ class FileConverter { this.markdownParser = markdownParser; this.logger = logger; } - setCurrentFilePath(filePath) { - if (this.markdownParser.setCurrentFilePath) { - this.markdownParser.setCurrentFilePath(filePath); - } - } convertToElement(file) { const { content } = file; const args = { @@ -76802,184 +76824,97 @@ class FileConverter { if (!parser) { throw new Error('File extension not supported'); } - const result = parser.parse({ content }); + const result = parser.parse({ content, filepath: file.path }); return new elements_1.PageElement({ ...args, ...result, + source: file, }); } -} -exports.FileConverter = FileConverter; - - -/***/ }), - -/***/ 8141: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.FileSystemSourceRepository = void 0; -const fs_1 = __nccwpck_require__(9896); -const path_1 = __nccwpck_require__(6928); -class FileSystemSourceRepository { - isFile(path) { - try { - const stats = (0, fs_1.statSync)(path); - return stats.isFile(); + convertFromElement(pageElement) { + if (!pageElement.source || !(pageElement.source instanceof synchronization_1.File)) { + throw new Error('Filepath is required to convert from PageElement to File'); + } + return new synchronization_1.File({ + name: pageElement.title, + extension: pageElement.source.extension, + content: [ + this.getFrontmatterString(pageElement), + this.removeFrontmatterFromContent(pageElement.source.content), + ].join('\n'), + lastUpdated: pageElement.source.lastUpdated, + path: pageElement.source.path, + }); + } + getFrontmatterString(pageElement) { + const frontmatter = ['---']; + if (pageElement.id) { + frontmatter.push(`id: ${pageElement.id}`); } - catch { - return false; + if (pageElement.title) { + frontmatter.push(`title: ${pageElement.title}`); } - } - isDirectory(path) { - try { - const stats = (0, fs_1.statSync)(path); - return stats.isDirectory(); + if (pageElement.icon) { + frontmatter.push(`icon: ${pageElement.icon}`); } - catch { - return false; + if (pageElement.properties) { + frontmatter.push('properties:'); + frontmatter.push(this.getPageElementPropertiesString(pageElement.properties)); } + frontmatter.push('---'); + return frontmatter.join('\n'); } - isReadableRecursiveSync(path) { - try { - // Check if the path is readable - (0, fs_1.accessSync)(path, fs_1.constants.R_OK); - // Get directory contents - const entries = (0, fs_1.readdirSync)(path, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = (0, path_1.join)(path, entry.name); - if (entry.isDirectory()) { - // Recursively check subdirectory readability - if (!this.isReadableRecursiveSync(fullPath)) - return false; - } - else { - // Check if the file is readable - try { - (0, fs_1.accessSync)(fullPath, fs_1.constants.R_OK); - } - catch { - return false; - } - } - } - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } - catch (error) { - return false; + getPageElementPropertiesString(properties) { + const propertiesString = []; + if (!properties) { + return ''; } + properties.forEach((property) => { + propertiesString.push(...[ + ` - name: ${property.name}`, + ` value: ${this.getPropertyValueString(property.value)}`, + ]); + }); + return propertiesString.join('\n'); } - isReadableFile(path) { - try { - (0, fs_1.accessSync)(path, fs_1.constants.R_OK); - return true; + getPropertyValueString(value) { + if (typeof value === 'string') { + return value; } - catch { - return false; + if (typeof value === 'number') { + return value.toString(); } - } - // eslint-disable-next-line @typescript-eslint/require-await - async sourceIsAccessible({ path }) { - if (this.isFile(path)) { - return this.isReadableFile(path); + if (typeof value === 'boolean') { + return value.toString(); } - else if (this.isDirectory(path)) { - return this.isReadableRecursiveSync(path); + if (Array.isArray(value)) { + return this.getPropertyValueStringArray(value); } - return false; - } - // eslint-disable-next-line @typescript-eslint/require-await - async getFilePathList({ path }) { - // If it's a single file, return it as a single-item array - if (this.isFile(path)) { - if (!path.endsWith('.md')) { - throw new Error(`File ${path} is not a markdown file. Only .md files are supported.`); - } - return [path]; + if (value === null) { + return 'null'; } - // If it's a directory, collect all markdown files recursively - if (!this.isDirectory(path)) { - throw new Error(`Path ${path} is neither a file nor a directory.`); + if (typeof value === 'undefined') { + return 'undefined'; } - const markdownFiles = []; - const collectMarkdownFiles = (dirPath) => { - try { - const entries = (0, fs_1.readdirSync)(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = (0, path_1.join)(dirPath, entry.name); - if (entry.isDirectory()) { - // Recursively process subdirectories - collectMarkdownFiles(fullPath); - } - else if (entry.isFile() && fullPath.endsWith('.md')) { - // Store markdown file path - markdownFiles.push(fullPath); - } - } - } - catch (error) { - throw new Error(`Error reading directory ${dirPath}`, { cause: error }); - } - }; - collectMarkdownFiles(path); - return markdownFiles; + throw new Error(`Unsupported property value type: ${typeof value}`); } - getLastUpdatedDate(filePath) { - const stats = (0, fs_1.statSync)(filePath); - return stats.mtime; // mtime (modification time) represents last updated date + getPropertyValueStringArray(value) { + return [ + `[`, + value.map((v) => this.getPropertyValueString(v)).join(','), + `]`, + ].join(''); } - // eslint-disable-next-line @typescript-eslint/require-await - async getFile({ path }) { - // Determine the display name for the Notion page - const base = (0, path_1.basename)(path); - let name = base; - if (base.toLowerCase().endsWith('.md')) { - // Remove .md extension for all other files - name = base.slice(0, -3); - } - return { - name, - content: (0, fs_1.readFileSync)(path, 'utf-8'), - extension: (0, path_1.extname)(path).slice(1), - lastUpdated: this.getLastUpdatedDate(path), - }; + removeFrontmatterFromContent(content) { + return content.replace(/^-{3,}\n.*?\n-{3,}/s, '').trim(); } } -exports.FileSystemSourceRepository = FileSystemSourceRepository; - - -/***/ }), - -/***/ 2503: -/***/ (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__(9375), exports); -__exportStar(__nccwpck_require__(8141), exports); +exports.FileConverter = FileConverter; /***/ }), -/***/ 3011: +/***/ 5900: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -77018,558 +76953,858 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.HtmlParser = void 0; -const DomSerializer = __importStar(__nccwpck_require__(9943)); -const domelementtype_1 = __nccwpck_require__(1108); -const htmlparser2_1 = __nccwpck_require__(3231); +exports.NotionConverterRepository = void 0; +const path = __importStar(__nccwpck_require__(6928)); const elements_1 = __nccwpck_require__(7591); -class HtmlParser extends elements_1.ParserRepository { - constructor({ logger }) { - super({ logger }); +const NotionPage_1 = __nccwpck_require__(9749); +const SUPPORTED_IMAGE_URL_EXTENSIONS = [ + '.bmp', + '.gif', + '.heic', + '.jpeg', + '.jpg', + '.png', + '.svg', + '.tif', + '.tiff', +]; +class NotionConverterRepository { + logger; + fileUploadService; + basePath; + constructor({ logger, fileUploadService, }) { + this.logger = logger; + this.fileUploadService = fileUploadService; } - parse({ content }) { - const document = (0, htmlparser2_1.parseDocument)(content); - const elements = []; - for (const node of document.children) { - if (node.type === domelementtype_1.ElementType.Tag) { - switch (node.name) { - case 'details': { - const summaryNode = htmlparser2_1.DomUtils.findOne((n) => n.name === 'summary', node.children); - const detailsContent = DomSerializer.render(node); - elements.push(new elements_1.ToggleElement({ - title: summaryNode ? htmlparser2_1.DomUtils.textContent(summaryNode) : '', - children: [new elements_1.TextElement({ text: detailsContent })], - })); - break; - } - case 'kbd': - case 'samp': - const codeElement = new elements_1.CodeElement({ - text: htmlparser2_1.DomUtils.textContent(node), - language: elements_1.ElementCodeLanguage.PlainText, - }); - elements.push(codeElement); - break; - case 'sub': - this.logger.warn(' tag is not supported'); - break; - case 'sup': - this.logger.warn(' tag is not supported'); - break; - case 'ins': - elements.push(new elements_1.TextElement({ - text: htmlparser2_1.DomUtils.textContent(node), - styles: { underline: true }, - })); - break; - case 'del': - elements.push(new elements_1.TextElement({ - text: htmlparser2_1.DomUtils.textContent(node), - styles: { strikethrough: true }, - })); - break; - case 'var': - elements.push(new elements_1.TextElement({ - text: htmlparser2_1.DomUtils.textContent(node), - styles: { italic: true }, - })); - break; - case 'q': - elements.push(new elements_1.QuoteElement({ - text: htmlparser2_1.DomUtils.textContent(node), - })); - break; - case 'div': - elements.push(new elements_1.DividerElement()); - break; - default: - break; - } - } + setBasePath(basePath) { + this.basePath = basePath; + } + /** + * Determine if an image URL is a local file path (relative or absolute local path) + */ + isLocalImagePath(url) { + if (!url) + return false; + // External URLs (http/https) + if (url.startsWith('http://') || url.startsWith('https://')) { + return false; + } + // Data URLs + if (url.startsWith('data:')) { + return false; + } + // Relative paths or absolute local paths + return true; + } + // ============================================ + // 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 { - content: elements, + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], }; } -} -exports.HtmlParser = HtmlParser; - - -/***/ }), - -/***/ 313: -/***/ (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__(3011), exports); - - -/***/ }), - -/***/ 947: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getInfrastructureInstances = void 0; -const filesystem_1 = __nccwpck_require__(2503); -const html_1 = __nccwpck_require__(313); -const markdown_1 = __nccwpck_require__(401); -const notion_1 = __nccwpck_require__(9717); -let infraInstances; -const buildInstances = ({ logger, notionApiKey, }) => { - const fileUploadService = new notion_1.NotionFileUploadService({ - apiKey: notionApiKey, - logger, - }); - const notionConverter = new notion_1.NotionConverterRepository({ - logger, - fileUploadService, - }); - const htmlParser = new html_1.HtmlParser({ logger }); - const markdownParser = new markdown_1.MarkdownParser({ htmlParser, logger }); - return { - fileSystemSource: new filesystem_1.FileSystemSourceRepository(), - fileConverter: new filesystem_1.FileConverter({ - logger, - htmlParser, - markdownParser, - }), - htmlParser, - markdownParser: new markdown_1.MarkdownParser({ - htmlParser, - logger, - }), - notionDestination: new notion_1.NotionDestinationRepository({ - logger, - notionConverter, - apiKey: notionApiKey, - }), - notionConverter, - }; -}; -const getInfrastructureInstances = (args) => { - if (!infraInstances) { - infraInstances = buildInstances(args); + /** + * 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, + }, + }, + ], + }; } - return infraInstances; -}; -exports.getInfrastructureInstances = getInfrastructureInstances; - - -/***/ }), - -/***/ 401: -/***/ (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]; } }; + /** + * 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, + }; } - 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__(8047), exports); -__exportStar(__nccwpck_require__(6772), exports); - - -/***/ }), - -/***/ 8047: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.MarkdownParser = void 0; -const front_matter_1 = __importDefault(__nccwpck_require__(2247)); -const marked_1 = __nccwpck_require__(1638); -const marked_katex_extension_1 = __importDefault(__nccwpck_require__(7647)); -const elements_1 = __nccwpck_require__(7591); -class MarkdownParser extends elements_1.ParserRepository { - htmlParser; - currentFilePath; - constructor({ htmlParser, logger, }) { - super({ logger }); - this.htmlParser = htmlParser; - marked_1.marked.use((0, marked_katex_extension_1.default)({ throwOnError: false, nonStandard: true })); + /** + * 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, + }; } - setCurrentFilePath(filePath) { - this.currentFilePath = filePath; + /** + * 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, + }, + }; } - preParseMarkdown(src) { - const { body } = (0, front_matter_1.default)(src); - return marked_1.marked.lexer(body); + /** + * 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, + }; } - getMetadata(src) { - const { attributes } = (0, front_matter_1.default)(src); - if (!attributes || typeof attributes !== 'object') { - return {}; + /** + * 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}`); } - return attributes; + // 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 + }; } - getTextLevelFromDepth(depth) { - const mapping = { - 1: elements_1.TextElementLevel.Heading1, - 2: elements_1.TextElementLevel.Heading2, - 3: elements_1.TextElementLevel.Heading3, - 4: elements_1.TextElementLevel.Heading4, - 5: elements_1.TextElementLevel.Heading5, - 6: elements_1.TextElementLevel.Heading6, + /** + * 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, }; - if (depth < 1 || depth > 6) { - return elements_1.TextElementLevel.Paragraph; + } + /** + * Converts a string value to a Notion EmailProperty + */ + convertToEmailProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); } - return mapping[depth]; + return { + type: 'email', + email: value || null, + }; } /** - * Parse a heading token + * Converts a string value to a Notion PhoneNumberProperty */ - parseHeadingToken(token) { - const level = this.getTextLevelFromDepth(token.depth); - return new elements_1.TextElement({ - text: token.text, - level, - }); + convertToPhoneNumberProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'phone_number', + phone_number: value || null, + }; } - parseListToken(token) { - return token.items.map((item) => { - let text = []; - const children = []; - const paragraph = item.tokens.shift(); - if (paragraph && paragraph.type === 'text') { - text = this.parseParagraphToken(paragraph); - } - // Check if the list item has nested tokens (like nested lists) - if (item.tokens) { - for (const nestedToken of item.tokens) { - const contentItem = this.parseToken(nestedToken); - children.push(...contentItem); - } + /** + * 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, + }, + }; } - return new elements_1.ListItemElement({ - listType: token.ordered ? 'ordered' : 'unordered', - text, - children: children.length > 0 ? children : undefined, - }); - }); + } + // 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, + }, + }; } - parseBlockQuoteToken(token) { - const text = token.text.trim(); - if (text.startsWith('[!NOTE]')) { - return new elements_1.CalloutElement({ - text: text.replace('[!NOTE]', '').trim(), - icon: '💡', - }); + /** + * 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; } - return new elements_1.QuoteElement({ - text: text, - }); } - parseCodeToken(token) { - const language = token.lang || elements_1.ElementCodeLanguage.PlainText; - if (language === 'js') { - return new elements_1.CodeElement({ - text: token.text, - language: elements_1.ElementCodeLanguage.JavaScript, - }); + /** + * 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); } - const isSupportedLanguage = (0, elements_1.isElementCodeLanguage)(language); - if (!isSupportedLanguage) { - return new elements_1.CodeElement({ - text: token.text, - language: elements_1.ElementCodeLanguage.PlainText, - }); + // 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 new elements_1.CodeElement({ - text: token.text, - language, - }); + return result; } - parseCalloutToken(token) { - if (!token.callout || typeof token.callout !== 'string') { - throw new Error('Callout token does not have a callout property'); + async convertPageElement(element, notionPropertyDefinitions = []) { + const title = { + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: element.title, + link: null, + }, + }, + ], + }; + const result = { + children: [], + properties: { + title, + ...this.convertPageElementProperties(element.properties, notionPropertyDefinitions), + }, + }; + for (const contentElement of element.content) { + const convertedElement = await this.convertElement(contentElement); + if (convertedElement) { + result.children?.push(convertedElement); + } } - return new elements_1.CalloutElement({ - text: token.callout, - icon: '💡', - }); + const icon = element.getIcon(); + if (icon) { + result.icon = { type: 'emoji', emoji: icon }; + } + return result; } - parseTableToken(token) { - const headers = token.header.map((cell) => cell.text); - const rows = token.rows.map((row) => row.map((cell) => cell.text)); - return new elements_1.TableElement({ - rows: [headers, ...rows], - }); + async convertElement(element) { + switch (element.type) { + case elements_1.ElementType.Page: + return null; // Pages should not be converted as child blocks + case elements_1.ElementType.Text: + return this.convertText(element); + case elements_1.ElementType.Quote: + return this.convertQuote(element); + case elements_1.ElementType.Callout: + return this.convertCallout(element); + case elements_1.ElementType.ListItem: + return this.convertListItem(element); + case elements_1.ElementType.Table: + return this.convertTable(element); + case elements_1.ElementType.Toggle: + return await this.convertToggle(element); + case elements_1.ElementType.Link: + return this.convertLink(element); + case elements_1.ElementType.Divider: + return this.convertDivider(); + case elements_1.ElementType.Code: + return this.convertCodeBlock(element); + case elements_1.ElementType.Image: + return await this.convertImage(element); + case elements_1.ElementType.Html: + return this.convertHtml(element); + case elements_1.ElementType.TableOfContents: + return this.convertTableOfContents(); + case elements_1.ElementType.Equation: + return this.convertEquation(element); + default: + this.logger.warn(`Unsupported element type: ${element.type}`); + return null; + } } - parseImageToken(token) { - return new elements_1.ImageElement({ - url: token.href, - caption: token.text, - filepath: this.currentFilePath, - }); + async convertFromElement(element, availableProperties = []) { + const notionPageInput = await this.convertPageElement(element, availableProperties); + return NotionPage_1.NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); } - parseHtmlToken(token) { - const { content } = this.htmlParser.parse({ content: token.text }); - return content; + convertText(element) { + switch (element.level) { + case elements_1.TextElementLevel.Heading1: + return { + type: 'heading_1', + object: 'block', + heading_1: { + rich_text: this.convertRichText(element.text), + color: 'default', + is_toggleable: false, // Set based on your requirements + }, + }; + case elements_1.TextElementLevel.Heading2: + return { + type: 'heading_2', + object: 'block', + heading_2: { + rich_text: this.convertRichText(element.text), + color: 'default', + is_toggleable: false, + }, + }; + case elements_1.TextElementLevel.Heading3: + return { + type: 'heading_3', + object: 'block', + heading_3: { + rich_text: this.convertRichText(element.text), + color: 'default', + is_toggleable: false, + }, + }; + case elements_1.TextElementLevel.Paragraph: + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: this.convertRichText(element.text), + color: 'default', + }, + }; + default: + this.logger.warn(`Unsupported text level ${element.level} - using paragraph`); + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: this.convertRichText(element.text), + color: 'default', + }, + }; + } } - parseLinkToken(token) { - return new elements_1.LinkElement({ - text: token.text, - url: token.href, - }); + convertQuote(element) { + return { + type: 'quote', + object: 'block', + quote: { + rich_text: this.convertRichText(element.text), + }, + }; } - parseTextToken(token) { - if (token.type === 'strong') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: true, - italic: false, - strikethrough: false, - underline: false, - code: false, - }, - }); + convertCallout(element) { + const icon = element.getIcon(); + const calloutParams = { + rich_text: this.convertRichText(element.text), + icon: undefined, + }; + if (icon) { + // @ts-expect-error - Notion API types are incorrect + calloutParams.icon = { type: 'emoji', emoji: icon }; } - if (token.type === 'em') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: false, - italic: true, - strikethrough: false, - underline: false, - code: false, - }, - }); + return { + type: 'callout', + object: 'block', + callout: calloutParams, + }; + } + async convertListItem(element) { + let item; + if (element.listType === 'unordered') { + item = await this.convertBulletedListItem(element); } - if (token.type === 'del') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: false, - italic: false, - strikethrough: true, - underline: false, - code: false, - }, - }); + else { + item = await this.convertNumberedListItem(element); } - if (token.type === 'codespan') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: false, - italic: false, - strikethrough: false, - underline: false, - code: true, - }, - }); + return item; + } + async convertBulletedListItem(element) { + return { + type: 'bulleted_list_item', + object: 'block', + bulleted_list_item: { + rich_text: this.convertRichText(element.text), + children: await this.convertListItemChildren(element.children), + }, + }; + } + async convertNumberedListItem(element) { + return { + type: 'numbered_list_item', + object: 'block', + numbered_list_item: { + rich_text: this.convertRichText(element.text), + children: await this.convertListItemChildren(element.children), + }, + }; + } + async convertListItemChildren(children) { + const convertedChildren = (await Promise.all(children?.map(async (child) => this.convertElement(child)) ?? [])).filter((child) => child !== null); + if (convertedChildren.length === 0) { + return undefined; } - return new elements_1.TextElement({ - text: token.text, - }); + return convertedChildren; } - parseBlockKatexToken(token) { - return new elements_1.EquationElement({ - equation: token.text, - styles: { - italic: false, - bold: false, - strikethrough: false, - underline: false, + convertTable(element) { + return { + type: 'table', + object: 'block', + table: { + table_width: element.rows[0]?.length || 0, + has_column_header: false, // Customize as needed + has_row_header: false, // Customize as needed + children: element.rows.map((row) => this.convertTableRow(row)), }, - }); + }; } - parseRawText(text) { - const tokens = this.preParseMarkdown(text); - const elements = []; - for (const t of tokens) { - switch (t.type) { - case 'paragraph': - elements.push(...this.parseParagraphToken(t)); - break; - case 'text': - elements.push(this.parseTextToken(t)); - break; + convertTableRow(row) { + return { + type: 'table_row', + object: 'block', + table_row: { + cells: row.map((cell) => this.convertRichText(cell)), + }, + }; + } + async convertToggle(element) { + const children = []; + for (const contentElement of element.children) { + const convertedElement = await this.convertElement(contentElement); + if (convertedElement) { + children.push(convertedElement); } } - return elements; + return { + type: 'toggle', + object: 'block', + toggle: { + rich_text: this.convertRichText(element.title), + children, + }, + }; } - parseParagraphToken(token) { - const elements = []; - token.tokens.forEach((t) => { - switch (t.type) { - case 'text': - elements.push(this.parseTextToken(t)); - break; - case 'inlineKatex': - elements.push(this.parseBlockKatexToken(t)); - break; - case 'strong': - elements.push(this.parseTextToken(t)); - break; - case 'em': - elements.push(this.parseTextToken(t)); - break; - case 'del': - elements.push(this.parseTextToken(t)); - break; - case 'codespan': - elements.push(this.parseTextToken(t)); - break; - case 'link': - elements.push(this.parseLinkToken(t)); - break; - case 'image': - elements.push(this.parseImageToken(t)); - break; - } - }); - return elements; + convertLink(element) { + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [ + { + text: { + content: element.text, + link: element.url.startsWith('http') + ? { url: element.url } + : null, + }, + }, + ], + color: 'default', + }, + }; } - parseToken(token) { - const elements = []; - switch (token.type) { - case 'heading': { - elements.push(this.parseHeadingToken(token)); - break; - } - case 'paragraph': { - if (token.tokens?.length === 1 && token.tokens[0].type === 'image') { - elements.push(this.parseImageToken(token.tokens[0])); - } - else { - elements.push(new elements_1.TextElement({ - text: this.parseParagraphToken(token), - level: elements_1.TextElementLevel.Paragraph, - })); - } - break; - } - case 'text': { - elements.push(this.parseTextToken(token)); - break; - } - case 'list': - const listItems = this.parseListToken(token); - elements.push(...listItems); - break; - case 'blockquote': { - elements.push(this.parseBlockQuoteToken(token)); - break; - } - case 'code': - elements.push(this.parseCodeToken(token)); - break; - case 'callout': - elements.push(this.parseCalloutToken(token)); - break; - case 'table': { - elements.push(this.parseTableToken(token)); - break; - } - case 'hr': - elements.push(new elements_1.DividerElement()); - break; - case 'image': - elements.push(this.parseImageToken(token)); - break; - case 'html': - elements.push(...this.parseHtmlToken(token)); - break; - case 'link': - elements.push(this.parseLinkToken(token)); - break; - case 'strong': - case 'em': - case 'del': - elements.push(this.parseTextToken(token)); - break; - case 'blockKatex': - elements.push(this.parseBlockKatexToken(token)); - break; - case 'inlineKatex': - elements.push(this.parseBlockKatexToken(token)); - break; - default: - break; + convertDivider() { + return { + type: 'divider', + object: 'block', + divider: {}, + }; + } + getNotionLanguageFromElementLanguage(language) { + const languageMap = { + [elements_1.ElementCodeLanguage.JavaScript]: 'javascript', + [elements_1.ElementCodeLanguage.TypeScript]: 'typescript', + [elements_1.ElementCodeLanguage.Python]: 'python', + [elements_1.ElementCodeLanguage.Java]: 'java', + [elements_1.ElementCodeLanguage.CSharp]: 'c#', + [elements_1.ElementCodeLanguage.CPlusPlus]: 'c++', + [elements_1.ElementCodeLanguage.Go]: 'go', + [elements_1.ElementCodeLanguage.Ruby]: 'ruby', + [elements_1.ElementCodeLanguage.Swift]: 'swift', + [elements_1.ElementCodeLanguage.Kotlin]: 'kotlin', + [elements_1.ElementCodeLanguage.Rust]: 'rust', + [elements_1.ElementCodeLanguage.Scala]: 'scala', + [elements_1.ElementCodeLanguage.Shell]: 'bash', // Mapping to 'bash' as Notion supports bash/shell + [elements_1.ElementCodeLanguage.SQL]: 'sql', + [elements_1.ElementCodeLanguage.HTML]: 'html', + [elements_1.ElementCodeLanguage.CSS]: 'css', + [elements_1.ElementCodeLanguage.JSON]: 'json', + [elements_1.ElementCodeLanguage.YAML]: 'yaml', + [elements_1.ElementCodeLanguage.Markdown]: 'markdown', + [elements_1.ElementCodeLanguage.Mermaid]: 'mermaid', + [elements_1.ElementCodeLanguage.PlainText]: 'plain text', // Mapping to 'plain text' as Notion supports this + }; + // Return the mapped Notion language, or 'plain text' as a fallback + return languageMap[language] || 'plain text'; + } + convertCodeBlock(element) { + return { + type: 'code', + object: 'block', + code: { + rich_text: this.convertRichText(element.text), + language: this.getNotionLanguageFromElementLanguage(element.language), + }, + }; + } + async convertImage(element) { + // Check if it's a local image path + if (this.isLocalImagePath(element.url)) { + return this.convertLocalImage(element); + } + else { + return this.convertExternalImage(element); + } + } + /** + * Convert local image using file upload service + */ + async convertLocalImage(element) { + if (!this.fileUploadService || !element.url) { + this.logger.warn('File upload service not available or no image URL provided, converting to paragraph'); + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: `[Image: ${element.caption || element.url || 'unknown'}]`, + }, + }, + ], + color: 'default', + }, + }; + } + try { + // Determine the base path for resolving relative image paths + // Use the filepath from the ImageElement (set during parsing), fallback to basePath + const imageBasePath = element.filepath + ? path.dirname(element.filepath) + : this.basePath; + this.logger.info(`Uploading local image: ${element.url}`); + const uploadResult = await this.fileUploadService.uploadFile({ + filePath: element.url, + basePath: imageBasePath, + }); + return { + type: 'image', + object: 'block', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + image: { + type: 'file_upload', + file_upload: { + id: uploadResult.id, + }, + caption: element.caption + ? [{ type: 'text', text: { content: element.caption } }] + : [], + }, // eslint-disable-line @typescript-eslint/no-explicit-any -- Notion file upload block structure not in types yet + }; + } + catch (error) { + this.logger.error(`Failed to upload local image ${element.url}:`, error); + // Fallback to paragraph with image reference + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: `[Failed to upload image: ${element.caption || element.url}]`, + }, + }, + ], + color: 'default', + }, + }; } - return elements; } - parse({ content }) { - const tokens = this.preParseMarkdown(content); - const elements = []; - for (const token of tokens) { - elements.push(...this.parseToken(token)); + /** + * Convert external image using external URL (original behavior) + */ + convertExternalImage(element) { + if (element.url && + !SUPPORTED_IMAGE_URL_EXTENSIONS.some((extension) => element.url?.endsWith(extension))) { + this.logger.warn(`Unsupported image URL extension: ${element.url}`); + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [], + color: 'default', + }, + }; } - const result = { - content: elements, + return { + type: 'image', + object: 'block', + image: { + type: 'external', + external: { + url: element.url || '', + }, + }, }; - const fileMetadata = this.getMetadata(content); - if (fileMetadata.id) { - result.mkNotesInternalId = fileMetadata.id; - } - if (fileMetadata.title) { - result.title = fileMetadata.title; + } + convertHtml(element) { + return { + type: 'code', + object: 'block', + code: { + language: 'html', + rich_text: this.convertRichText(element.html), + }, + }; + } + convertEquation(element) { + return { + type: 'equation', + object: 'block', + equation: { expression: element.equation }, + }; + } + convertRichText(content) { + if (content === undefined) { + return []; } - if (fileMetadata.icon) { - result.icon = fileMetadata.icon; + if (typeof content === 'string') { + // Split string into chunks of 2000 characters + const MAX_LENGTH = 2000; + const chunks = []; + for (let i = 0; i < content.length; i += MAX_LENGTH) { + chunks.push(content.slice(i, i + MAX_LENGTH)); + } + return chunks.map((chunk) => ({ + type: 'text', + text: { + content: chunk, + }, + })); } - if (fileMetadata.properties && Array.isArray(fileMetadata.properties)) { - result.properties = fileMetadata.properties; + if (Array.isArray(content)) { + return content.reduce((acc, element) => { + if (element.type === elements_1.ElementType.Text) { + acc.push({ + type: 'text', + text: { + content: element.text, + }, + annotations: { + bold: element.styles.bold, + italic: element.styles.italic, + strikethrough: element.styles.strikethrough, + underline: element.styles.underline, + code: element.styles.code, + }, + }); + } + if (element instanceof elements_1.LinkElement) { + acc.push({ + type: 'text', + text: { + content: element.text, + link: element.url.startsWith('http') + ? { url: element.url } + : null, + }, + }); + } + if (element instanceof elements_1.EquationElement) { + acc.push({ + type: 'equation', + equation: { + expression: element.equation, + }, + annotations: { + bold: element.styles.bold, + italic: element.styles.italic, + strikethrough: element.styles.strikethrough, + underline: element.styles.underline, + code: element.styles.code, + }, + }); + } + this.logger.warn(`Unsupported element type: ${element.type}`); + return acc; + }, []); } - return result; + throw new Error(`Unsupported content type: ${typeof content}`); + } + convertTableOfContents() { + return { + type: 'table_of_contents', + object: 'block', + table_of_contents: {}, + }; + } + convertToElement() { + throw new Error('Method not implemented.'); } } -exports.MarkdownParser = MarkdownParser; - - -/***/ }), - -/***/ 6772: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NotionConverterRepository = NotionConverterRepository; /***/ }), -/***/ 2792: +/***/ 1294: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -77726,7 +77961,6 @@ class NotionFileUploadService { const fileUpload = await this.createFileUpload(fileName, fileSize, resolvedPath); // Step 2: Send file content await this.sendFileContent(fileUpload.upload_url, resolvedPath); - // No step 3 needed - file is ready to use after step 2 this.logger.info(`Successfully uploaded file: ${fileName} with ID: ${fileUpload.id}`); return { id: fileUpload.id, @@ -77915,37 +78149,534 @@ exports.NotionFileUploadService = NotionFileUploadService; /***/ }), -/***/ 9717: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { +/***/ 1488: +/***/ ((__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(exports, "__esModule", ({ value: true })); +exports.NotionDestinationRepository = void 0; +const Element_1 = __nccwpck_require__(7672); +const error_1 = __nccwpck_require__(8109); +class NotionDestinationRepository { + notionClient; + logger; + notionConverter; + constructor({ logger, notionClient, notionConverter, }) { + this.notionClient = notionClient; + this.logger = logger; + this.notionConverter = notionConverter; + } + /** + * Delete all child blocks from a parent page + */ + async deleteChildBlocks({ parentPageId, }) { + try { + // Get all blocks in the parent page + const blocks = await this.notionClient.getBlockChildren({ + blockId: parentPageId, + }); + await this.notionClient.deleteBlocks({ + blockIds: blocks.map((block) => block.id), + }); + } + catch (error) { + // Deletion failed - throw the error to be handled upstream + throw error instanceof Error ? error : new Error(String(error)); + } + } + 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 the last match (closest to the end of the URL path) + return matches[matches.length - 1]; + } + async destinationIsAccessible({ parentObjectId, }) { + let page = null; + try { + page = await this.notionClient.getPage({ pageId: parentObjectId }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (_err) { + // Discard error, we'll check if it's a database + } + if (page) { + return true; + } + let database = null; + try { + database = await this.notionClient.getDatabaseById({ + databaseId: parentObjectId, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (_err) { + // Discard error, we'll check if it's a page + } + if (database) { + return true; + } + return false; + } + async getPage({ pageId }) { + const notionPage = await this.notionClient.getPage({ + pageId, + }); + if (!notionPage) { + return null; + } + const blocks = await this.notionClient.getPageBlocks({ pageId: pageId }); + notionPage.children = blocks; + return notionPage; + } + async createPage({ parentObjectId, parentObjectType, 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 datasourceId = await this.notionClient.getDataSourceIdFromDatabaseId({ + databaseId: parentObjectId, + }); + if (!datasourceId) { + throw new Error('Failed to get Datasource'); + } + const datasource = await this.notionClient.getDataSourceById({ + dataSourceId: datasourceId, + }); + if (!datasource) { + throw new Error('Failed to get Datasource'); + } + parent = { type: 'data_source_id', data_source_id: datasourceId }; + availableProperties.push(...Object.entries(datasource.properties).map(([name, property]) => ({ + name, + definition: property, + type: property.type, + }))); + } + const notionPage = await this.notionConverter.convertFromElement(pageElement, availableProperties); + // First create the page without children + const createdPage = await this.notionClient.createPage({ + parent, + properties: notionPage.properties ?? {}, + icon: notionPage.icon, + children: [], + }); + if (!createdPage.pageId) { + throw new Error('Failed to create Notion Page'); + } + // If there are children blocks, append them in chunks + if (notionPage.children && notionPage.children.length > 0) { + const children = notionPage.children; + const createdBlocks = await this.notionClient.appendChildToBlock({ + blockId: createdPage.pageId, + children: children, + }); + createdPage.children = createdBlocks; + } + const page = await this.getPage({ + pageId: createdPage.pageId, + }); + if (!page) { + throw new Error('Failed to create Notion Page'); + } + return page; + } + async updatePage({ pageId, pageElement, }) { + const notionPageId = pageId; + const notionPage = await this.notionConverter.convertFromElement(pageElement); + await this.notionClient.updatePage({ + pageId: notionPageId, + icon: notionPage.icon, + properties: notionPage.properties, + archived: false, + }); + let existingBlocks = await this.notionClient.getBlockChildren({ + blockId: notionPageId, + }); + let afterBlockId; + if (existingBlocks.length >= 2 && + existingBlocks[0].type === 'table_of_contents' && + existingBlocks[1].type === 'divider') { + this.logger.warn('First two blocks are TOC & Divider, appending to page after Divider'); + afterBlockId = existingBlocks[1]?.id; + existingBlocks = existingBlocks.slice(2); + } + // Remove all non-page blocks + await this.removeNonPageBlocks({ blocks: existingBlocks }); + if (notionPage.children && notionPage.children?.length > 0) { + let blocks = notionPage.children; + if (blocks.length >= 2 && + blocks[0]?.type === 'table_of_contents' && + blocks[1]?.type === 'divider') { + blocks = blocks.slice(2); + } + await this.notionClient.appendChildToBlock({ + blockId: notionPageId, + children: blocks, + afterBlockId: afterBlockId, + }); + } + await this.removeUnusedPageBlocks({ pageElement, blocks: existingBlocks }); + const page = await this.getPage({ pageId: notionPageId }); + if (!page) { + throw new Error('Failed to update Notion Page'); + } + return page; + } + async removeNonPageBlocks({ blocks, }) { + const blockIdsToDelete = blocks + .filter((block) => block.type !== 'child_page') + .map((block) => block.id); + await this.notionClient.deleteBlocks({ + blockIds: blockIdsToDelete, + }); + } + async removeUnusedPageBlocks({ pageElement, blocks, }) { + const pageBlocks = blocks.filter((block) => block.type === 'child_page'); + const newPageBlocksIds = pageElement.content + .filter((element) => element instanceof Element_1.PageElement) + .map((element) => element.id); + const unusedPageBlocks = pageBlocks + .filter((block) => !newPageBlocksIds.includes(block.id)) + .map((block) => block.id); + await this.notionClient.deleteBlocks({ + blockIds: unusedPageBlocks, + }); + } + // Used for root level index.md where the page is already present + async appendToPage({ pageId, pageElement, }) { + const notionPage = await this.notionConverter.convertFromElement(pageElement); + // Update page properties (title/icon) if specified in metadata + await this.updatePageProperties({ pageId, pageElement }); + if (notionPage.children && notionPage.children.length > 0) { + // Append blocks to the existing page + try { + await this.notionClient.appendChildToBlock({ + blockId: pageId, + children: notionPage.children, + }); + } + catch (error) { + if ((0, error_1.isNotionNestingValidationError)(error)) { + throw new error_1.NotionNestingValidationError({ message: 'Nesting error' }); + } + this.logger.debug(`Failed to append block to page ${pageId}:`, { + error, + block: notionPage.children, + }); + throw error; + } + } + } + async updatePageProperties({ pageId, pageElement, }) { + const notionPage = await this.notionConverter.convertFromElement(pageElement); + // Only update if there are properties to update + if (notionPage.properties || notionPage.icon) { + await this.notionClient.updatePage({ + pageId, + icon: notionPage.icon, + properties: notionPage.properties, + }); + } + } + async setPageLockedStatus({ pageId, lockStatus, }) { + const isLocked = lockStatus === 'locked'; + await this.notionClient.updatePage({ + pageId, + isLocked, + }); + } + async getPageLockedStatus({ pageId, }) { + const page = await this.notionClient.getPage({ pageId }); + if (!page) { + throw new Error('Page not found'); + } + const isLocked = page.isLocked ?? false; + if (isLocked === undefined) { + return 'unlocked'; + } + return isLocked ? 'locked' : 'unlocked'; + } + async getObjectType({ id, }) { + try { + await this.notionClient.getPage({ pageId: id }); + return 'page'; + } + catch { + try { + await this.notionClient.getDatabaseById({ databaseId: id }); + return 'database'; + } + catch { + return 'unknown'; + } + } + } +} +exports.NotionDestinationRepository = NotionDestinationRepository; + + +/***/ }), + +/***/ 947: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getInfrastructureInstances = void 0; +// Infrastructure imports +const file_converter_1 = __nccwpck_require__(2378); +const notion_converter_1 = __nccwpck_require__(5900); +const file_upload_service_1 = __nccwpck_require__(1294); +const notion_destination_1 = __nccwpck_require__(1488); +const notion_client_repository_1 = __nccwpck_require__(5530); +const html_1 = __nccwpck_require__(40); +const markdown_1 = __nccwpck_require__(456); +const fileSystem_source_1 = __nccwpck_require__(2718); +let infraInstances; +const buildInstances = ({ logger, notionApiKey, }) => { + const fileUploadService = new file_upload_service_1.NotionFileUploadService({ + apiKey: notionApiKey, + logger, + }); + const notionClient = new notion_client_repository_1.NotionClientRepository({ + apiKey: notionApiKey, + }); + const notionConverter = new notion_converter_1.NotionConverterRepository({ + logger, + fileUploadService, + }); + const htmlParser = new html_1.HtmlParser({ logger }); + const markdownParser = new markdown_1.MarkdownParser({ htmlParser, logger }); + return { + fileSystemSource: new fileSystem_source_1.FileSystemSourceRepository(), + fileConverter: new file_converter_1.FileConverter({ + logger, + htmlParser, + markdownParser, + }), + htmlParser, + markdownParser: new markdown_1.MarkdownParser({ + htmlParser, + logger, + }), + notionDestination: new notion_destination_1.NotionDestinationRepository({ + logger, + notionConverter, + notionClient, + }), + notionConverter, + }; +}; +const getInfrastructureInstances = (args) => { + if (!infraInstances) { + infraInstances = buildInstances(args); + } + return infraInstances; +}; +exports.getInfrastructureInstances = getInfrastructureInstances; + + +/***/ }), + +/***/ 5530: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NotionClientRepository = void 0; +const client_1 = __nccwpck_require__(8342); +const NotionPage_1 = __nccwpck_require__(9749); +class NotionClientRepository { + client; + constructor({ apiKey }) { + this.client = new client_1.Client({ + auth: apiKey, + logLevel: client_1.LogLevel.ERROR, + }); + } + /** + * ------------------------------------------------------------ + * GENERAL METHODS + * ------------------------------------------------------------ + */ + async search({ filter, }) { + return this.client.search({ filter }); + } + /** + * ------------------------------------------------------------ + * DATABASES METHODS + * ------------------------------------------------------------ + */ + async getDatabaseById({ databaseId, }) { + const response = await this.client.databases.retrieve({ + database_id: databaseId, + }); + if (!response) { + return null; + } + return response; + } + /** + * ------------------------------------------------------------ + * DATA SOURCES METHODS + * ------------------------------------------------------------ + */ + async getDataSourceById({ dataSourceId, }) { + const response = await this.client.dataSources.retrieve({ + data_source_id: dataSourceId, + }); + if (!response) { + return null; + } + return response; + } + async getDataSourceIdFromDatabaseId({ databaseId, }) { + const database = await this.getDatabaseById({ databaseId }); + if (!database || !('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + return database.data_sources[0].id; + } + /** + * ------------------------------------------------------------ + * PAGES METHODS + * ------------------------------------------------------------ + */ + async getPage({ pageId, }) { + const response = await this.client.pages.retrieve({ page_id: pageId }); + if (!(0, client_1.isFullPage)(response)) { + throw new Error('Not able to retrieve Notion Page'); + } + return response + ? this.toNotionPage({ page: response, children: [] }) + : null; + } + async createPage({ parent, properties, icon, children, }) { + const response = await this.client.pages.create({ + parent, + properties: properties, + icon, + children, + }); + return this.toNotionPage({ + page: response, + children: [], + }); + } + async getPageBlocks({ pageId, }) { + return this.getBlockChildren({ blockId: pageId }); + } + async updatePage({ pageId, icon, properties, archived, isLocked, }) { + const updateBody = { + page_id: pageId, + properties: {}, + archived, + is_locked: isLocked, + }; + if (icon) { + updateBody.icon = icon; + } + if (properties?.title) { + updateBody.properties['title'] = properties.title; + } + const response = await this.client.pages.update(updateBody); + return this.toNotionPage({ + page: response, + children: [], + }); + } + async deletePage({ pageId }) { + await this.deleteBlock({ blockId: pageId }); + } + toNotionPage({ page, children, }) { + if (!page.id) { + throw new Error('Page ID is required'); + } + return new NotionPage_1.NotionPage({ + pageId: page.id, + children, + createdAt: new Date(page.created_time), + updatedAt: new Date(page.last_edited_time), + isLocked: page.is_locked ?? false, + }); + } + /** + * ------------------------------------------------------------ + * BLOCKS METHODS + * ------------------------------------------------------------ + */ + /** + * There is a limit of 100 block children that can be appended by a single API request. + * Arrays of block children longer than 100 will result in an error. + * + * see: https://developers.notion.com/reference/patch-block-children + */ + APPEND_BLOCK_CHILDREN_CHUNK_SIZE = 100; + async appendChildToBlock({ blockId, children, afterBlockId, }) { + const createdBlocks = []; + // Split children into chunks of 100 blocks + for (let i = 0; i < children.length; i += this.APPEND_BLOCK_CHILDREN_CHUNK_SIZE) { + const chunk = children.slice(i, i + this.APPEND_BLOCK_CHILDREN_CHUNK_SIZE); + const response = await this.client.blocks.children.append({ + block_id: blockId, + children: chunk, + after: afterBlockId, + }); + if (response.results.length > 0) { + createdBlocks.push(...response.results); + } + afterBlockId = createdBlocks[createdBlocks.length - 1]?.id; + } + return createdBlocks; + } + async deleteBlock({ blockId }) { + await this.client.blocks.delete({ block_id: blockId }); + } + DELETE_BLOCKS_CHUNK_SIZE = 50; + async deleteBlocks({ blockIds, }) { + for (let i = 0; i < blockIds.length; i += this.DELETE_BLOCKS_CHUNK_SIZE) { + const chunk = blockIds.slice(i, i + this.DELETE_BLOCKS_CHUNK_SIZE); + await Promise.all(chunk.map(async (blockId) => this.client.blocks.delete({ block_id: blockId }))); + } + } + async getBlock({ blockId, }) { + const response = await this.client.blocks.retrieve({ block_id: blockId }); + return response; + } + async updateBlock({ blockId, block, }) { + const response = await this.client.blocks.update({ + block_id: blockId, + ...block, + }); + return response; + } + async getBlockChildren({ blockId, }) { + const response = await this.client.blocks.children.list({ + block_id: blockId, + }); + return response.results; } - 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__(3913), exports); -__exportStar(__nccwpck_require__(8188), exports); -__exportStar(__nccwpck_require__(2792), exports); -__exportStar(__nccwpck_require__(6918), exports); -__exportStar(__nccwpck_require__(3942), exports); -__exportStar(__nccwpck_require__(7100), exports); +} +exports.NotionClientRepository = NotionClientRepository; /***/ }), -/***/ 6918: +/***/ 3126: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -77984,1271 +78715,647 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.NotionConverterRepository = void 0; -const path = __importStar(__nccwpck_require__(6928)); +exports.HtmlParser = void 0; +const DomSerializer = __importStar(__nccwpck_require__(9943)); +const domelementtype_1 = __nccwpck_require__(1108); +const htmlparser2_1 = __nccwpck_require__(3231); const elements_1 = __nccwpck_require__(7591); -const constants_1 = __nccwpck_require__(8642); -const NotionPage_1 = __nccwpck_require__(3913); -const SUPPORTED_IMAGE_URL_EXTENSIONS = [ - '.bmp', - '.gif', - '.heic', - '.jpeg', - '.jpg', - '.png', - '.svg', - '.tif', - '.tiff', -]; -class NotionConverterRepository { - logger; - fileUploadService; - currentFilePath; - basePath; - constructor({ logger, fileUploadService, }) { - this.logger = logger; - this.fileUploadService = fileUploadService; - } - setCurrentFilePath(filePath) { - this.currentFilePath = filePath; - } - setBasePath(basePath) { - this.basePath = basePath; - } - /** - * Determine if an image URL is a local file path (relative or absolute local path) - */ - isLocalImagePath(url) { - if (!url) - return false; - // External URLs (http/https) - if (url.startsWith('http://') || url.startsWith('https://')) { - return false; - } - // Data URLs - if (url.startsWith('data:')) { - return false; - } - // Relative paths or absolute local paths - return true; - } - // ============================================ - // 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', - title: [ - { - type: 'text', - text: { - content: element.title, - link: null, - }, - }, - ], - }; - 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) { - const convertedElement = await this.convertElement(contentElement); - if (convertedElement) { - result.children?.push(convertedElement); - } - } - const icon = element.getIcon(); - if (icon) { - result.icon = { type: 'emoji', emoji: icon }; - } - return result; - } - async convertElement(element) { - switch (element.type) { - case elements_1.ElementType.Page: - return null; // Pages should not be converted as child blocks - case elements_1.ElementType.Text: - return this.convertText(element); - case elements_1.ElementType.Quote: - return this.convertQuote(element); - case elements_1.ElementType.Callout: - return this.convertCallout(element); - case elements_1.ElementType.ListItem: - return this.convertListItem(element); - case elements_1.ElementType.Table: - return this.convertTable(element); - case elements_1.ElementType.Toggle: - return await this.convertToggle(element); - case elements_1.ElementType.Link: - return this.convertLink(element); - case elements_1.ElementType.Divider: - return this.convertDivider(); - case elements_1.ElementType.Code: - return this.convertCodeBlock(element); - case elements_1.ElementType.Image: - return await this.convertImage(element); - case elements_1.ElementType.Html: - return this.convertHtml(element); - case elements_1.ElementType.TableOfContents: - return this.convertTableOfContents(); - case elements_1.ElementType.Equation: - return this.convertEquation(element); - default: - this.logger.warn(`Unsupported element type: ${element.type}`); - return null; - } - } - async convertFromElement(element, availableProperties = []) { - const notionPageInput = await this.convertPageElement(element, availableProperties); - return NotionPage_1.NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); +class HtmlParser extends elements_1.ParserRepository { + constructor({ logger }) { + super({ logger }); } - convertText(element) { - switch (element.level) { - case elements_1.TextElementLevel.Heading1: - return { - type: 'heading_1', - object: 'block', - heading_1: { - rich_text: this.convertRichText(element.text), - color: 'default', - is_toggleable: false, // Set based on your requirements - }, - }; - case elements_1.TextElementLevel.Heading2: - return { - type: 'heading_2', - object: 'block', - heading_2: { - rich_text: this.convertRichText(element.text), - color: 'default', - is_toggleable: false, - }, - }; - case elements_1.TextElementLevel.Heading3: - return { - type: 'heading_3', - object: 'block', - heading_3: { - rich_text: this.convertRichText(element.text), - color: 'default', - is_toggleable: false, - }, - }; - case elements_1.TextElementLevel.Paragraph: - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: this.convertRichText(element.text), - color: 'default', - }, - }; - default: - this.logger.warn(`Unsupported text level ${element.level} - using paragraph`); - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: this.convertRichText(element.text), - color: 'default', - }, - }; + parse({ content }) { + const document = (0, htmlparser2_1.parseDocument)(content); + const elements = []; + for (const node of document.children) { + if (node.type === domelementtype_1.ElementType.Tag) { + switch (node.name) { + case 'details': { + const summaryNode = htmlparser2_1.DomUtils.findOne((n) => n.name === 'summary', node.children); + const detailsContent = DomSerializer.render(node); + elements.push(new elements_1.ToggleElement({ + title: summaryNode ? htmlparser2_1.DomUtils.textContent(summaryNode) : '', + children: [new elements_1.TextElement({ text: detailsContent })], + })); + break; + } + case 'kbd': + case 'samp': + const codeElement = new elements_1.CodeElement({ + text: htmlparser2_1.DomUtils.textContent(node), + language: elements_1.ElementCodeLanguage.PlainText, + }); + elements.push(codeElement); + break; + case 'sub': + this.logger.warn(' tag is not supported'); + break; + case 'sup': + this.logger.warn(' tag is not supported'); + break; + case 'ins': + elements.push(new elements_1.TextElement({ + text: htmlparser2_1.DomUtils.textContent(node), + styles: { underline: true }, + })); + break; + case 'del': + elements.push(new elements_1.TextElement({ + text: htmlparser2_1.DomUtils.textContent(node), + styles: { strikethrough: true }, + })); + break; + case 'var': + elements.push(new elements_1.TextElement({ + text: htmlparser2_1.DomUtils.textContent(node), + styles: { italic: true }, + })); + break; + case 'q': + elements.push(new elements_1.QuoteElement({ + text: htmlparser2_1.DomUtils.textContent(node), + })); + break; + case 'div': + elements.push(new elements_1.DividerElement()); + break; + default: + break; + } + } } - } - convertQuote(element) { return { - type: 'quote', - object: 'block', - quote: { - rich_text: this.convertRichText(element.text), - }, + content: elements, }; } - convertCallout(element) { - const icon = element.getIcon(); - const calloutParams = { - rich_text: this.convertRichText(element.text), - icon: undefined, - }; - if (icon) { - // @ts-expect-error - Notion API types are incorrect - calloutParams.icon = { type: 'emoji', emoji: icon }; - } - return { - type: 'callout', - object: 'block', - callout: calloutParams, - }; +} +exports.HtmlParser = HtmlParser; + + +/***/ }), + +/***/ 40: +/***/ (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]; } }; } - async convertListItem(element) { - let item; - if (element.listType === 'unordered') { - item = await this.convertBulletedListItem(element); - } - else { - item = await this.convertNumberedListItem(element); - } - return item; + 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__(3126), exports); + + +/***/ }), + +/***/ 456: +/***/ (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]; } }; } - async convertBulletedListItem(element) { - return { - type: 'bulleted_list_item', - object: 'block', - bulleted_list_item: { - rich_text: this.convertRichText(element.text), - children: await this.convertListItemChildren(element.children), - }, - }; + 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__(5974), exports); +__exportStar(__nccwpck_require__(1521), exports); + + +/***/ }), + +/***/ 5974: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.MarkdownParser = void 0; +const front_matter_1 = __importDefault(__nccwpck_require__(2247)); +const marked_1 = __nccwpck_require__(9257); +const marked_katex_extension_1 = __importDefault(__nccwpck_require__(7647)); +const elements_1 = __nccwpck_require__(7591); +class MarkdownParser extends elements_1.ParserRepository { + htmlParser; + // Used during synchronous parse() call to provide context for image paths + parsingFilePath; + constructor({ htmlParser, logger, }) { + super({ logger }); + this.htmlParser = htmlParser; + marked_1.marked.use((0, marked_katex_extension_1.default)({ throwOnError: false, nonStandard: true })); } - async convertNumberedListItem(element) { - return { - type: 'numbered_list_item', - object: 'block', - numbered_list_item: { - rich_text: this.convertRichText(element.text), - children: await this.convertListItemChildren(element.children), - }, - }; + preParseMarkdown(src) { + const { body } = (0, front_matter_1.default)(src); + return marked_1.marked.lexer(body); } - async convertListItemChildren(children) { - const convertedChildren = (await Promise.all(children?.map(async (child) => this.convertElement(child)) ?? [])).filter((child) => child !== null); - if (convertedChildren.length === 0) { - return undefined; + getMetadata(src) { + const { attributes } = (0, front_matter_1.default)(src); + if (!attributes || typeof attributes !== 'object') { + return {}; } - return convertedChildren; + return attributes; } - convertTable(element) { - return { - type: 'table', - object: 'block', - table: { - table_width: element.rows[0]?.length || 0, - has_column_header: false, // Customize as needed - has_row_header: false, // Customize as needed - children: element.rows.map((row) => this.convertTableRow(row)), - }, + getTextLevelFromDepth(depth) { + const mapping = { + 1: elements_1.TextElementLevel.Heading1, + 2: elements_1.TextElementLevel.Heading2, + 3: elements_1.TextElementLevel.Heading3, + 4: elements_1.TextElementLevel.Heading4, + 5: elements_1.TextElementLevel.Heading5, + 6: elements_1.TextElementLevel.Heading6, }; + if (depth < 1 || depth > 6) { + return elements_1.TextElementLevel.Paragraph; + } + return mapping[depth]; } - convertTableRow(row) { - return { - type: 'table_row', - object: 'block', - table_row: { - cells: row.map((cell) => this.convertRichText(cell)), - }, - }; + /** + * Parse a heading token + */ + parseHeadingToken(token) { + const level = this.getTextLevelFromDepth(token.depth); + return new elements_1.TextElement({ + text: token.text, + level, + }); } - async convertToggle(element) { - const children = []; - for (const contentElement of element.children) { - const convertedElement = await this.convertElement(contentElement); - if (convertedElement) { - children.push(convertedElement); + parseListToken(token) { + return token.items.map((item) => { + let text = []; + const children = []; + const paragraph = item.tokens.shift(); + if (paragraph && paragraph.type === 'text') { + text = this.parseParagraphToken(paragraph); + } + // Check if the list item has nested tokens (like nested lists) + if (item.tokens) { + for (const nestedToken of item.tokens) { + const contentItem = this.parseToken(nestedToken); + children.push(...contentItem); + } } + return new elements_1.ListItemElement({ + listType: token.ordered ? 'ordered' : 'unordered', + text, + children: children.length > 0 ? children : undefined, + }); + }); + } + parseBlockQuoteToken(token) { + const text = token.text.trim(); + if (text.startsWith('[!NOTE]')) { + return new elements_1.CalloutElement({ + text: text.replace('[!NOTE]', '').trim(), + icon: '💡', + }); } - return { - type: 'toggle', - object: 'block', - toggle: { - rich_text: this.convertRichText(element.title), - children, - }, - }; + return new elements_1.QuoteElement({ + text: text, + }); } - convertLink(element) { - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [ - { - text: { - content: element.text, - link: element.url.startsWith('http') - ? { url: element.url } - : null, - }, - }, - ], - color: 'default', - }, - }; + parseCodeToken(token) { + const language = token.lang || elements_1.ElementCodeLanguage.PlainText; + if (language === 'js') { + return new elements_1.CodeElement({ + text: token.text, + language: elements_1.ElementCodeLanguage.JavaScript, + }); + } + const isSupportedLanguage = (0, elements_1.isElementCodeLanguage)(language); + if (!isSupportedLanguage) { + return new elements_1.CodeElement({ + text: token.text, + language: elements_1.ElementCodeLanguage.PlainText, + }); + } + return new elements_1.CodeElement({ + text: token.text, + language, + }); } - convertDivider() { - return { - type: 'divider', - object: 'block', - divider: {}, - }; + parseCalloutToken(token) { + if (!token.callout || typeof token.callout !== 'string') { + throw new Error('Callout token does not have a callout property'); + } + return new elements_1.CalloutElement({ + text: token.callout, + icon: '💡', + }); } - getNotionLanguageFromElementLanguage(language) { - const languageMap = { - [elements_1.ElementCodeLanguage.JavaScript]: 'javascript', - [elements_1.ElementCodeLanguage.TypeScript]: 'typescript', - [elements_1.ElementCodeLanguage.Python]: 'python', - [elements_1.ElementCodeLanguage.Java]: 'java', - [elements_1.ElementCodeLanguage.CSharp]: 'c#', - [elements_1.ElementCodeLanguage.CPlusPlus]: 'c++', - [elements_1.ElementCodeLanguage.Go]: 'go', - [elements_1.ElementCodeLanguage.Ruby]: 'ruby', - [elements_1.ElementCodeLanguage.Swift]: 'swift', - [elements_1.ElementCodeLanguage.Kotlin]: 'kotlin', - [elements_1.ElementCodeLanguage.Rust]: 'rust', - [elements_1.ElementCodeLanguage.Scala]: 'scala', - [elements_1.ElementCodeLanguage.Shell]: 'bash', // Mapping to 'bash' as Notion supports bash/shell - [elements_1.ElementCodeLanguage.SQL]: 'sql', - [elements_1.ElementCodeLanguage.HTML]: 'html', - [elements_1.ElementCodeLanguage.CSS]: 'css', - [elements_1.ElementCodeLanguage.JSON]: 'json', - [elements_1.ElementCodeLanguage.YAML]: 'yaml', - [elements_1.ElementCodeLanguage.Markdown]: 'markdown', - [elements_1.ElementCodeLanguage.Mermaid]: 'mermaid', - [elements_1.ElementCodeLanguage.PlainText]: 'plain text', // Mapping to 'plain text' as Notion supports this - }; - // Return the mapped Notion language, or 'plain text' as a fallback - return languageMap[language] || 'plain text'; + parseTableToken(token) { + const headers = token.header.map((cell) => cell.text); + const rows = token.rows.map((row) => row.map((cell) => cell.text)); + return new elements_1.TableElement({ + rows: [headers, ...rows], + }); } - convertCodeBlock(element) { - return { - type: 'code', - object: 'block', - code: { - rich_text: this.convertRichText(element.text), - language: this.getNotionLanguageFromElementLanguage(element.language), - }, - }; + parseImageToken(token) { + return new elements_1.ImageElement({ + url: token.href, + caption: token.text, + filepath: this.parsingFilePath, + }); } - async convertImage(element) { - // Check if it's a local image path - if (this.isLocalImagePath(element.url)) { - return this.convertLocalImage(element); - } - else { - return this.convertExternalImage(element); - } + parseHtmlToken(token) { + const { content } = this.htmlParser.parse({ content: token.text }); + return content; } - /** - * Convert local image using file upload service - */ - async convertLocalImage(element) { - if (!this.fileUploadService || !element.url) { - this.logger.warn('File upload service not available or no image URL provided, converting to paragraph'); - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [ - { - type: 'text', - text: { - content: `[Image: ${element.caption || element.url || 'unknown'}]`, - }, - }, - ], - color: 'default', + parseLinkToken(token) { + return new elements_1.LinkElement({ + text: token.text, + url: token.href, + filepath: this.parsingFilePath, + }); + } + parseTextToken(token) { + if (token.type === 'strong') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: true, + italic: false, + strikethrough: false, + underline: false, + code: false, }, - }; - } - try { - // Determine the base path for resolving relative image paths - // Prefer the filepath from the ImageElement, fallback to currentFilePath, then basePath - let imageBasePath; - if (element.filepath) { - imageBasePath = path.dirname(element.filepath); - } - else if (this.currentFilePath) { - imageBasePath = path.dirname(this.currentFilePath); - } - else { - imageBasePath = this.basePath; - } - this.logger.info(`Uploading local image: ${element.url}`); - const uploadResult = await this.fileUploadService.uploadFile({ - filePath: element.url, - basePath: imageBasePath, }); - return { - type: 'image', - object: 'block', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - image: { - type: 'file_upload', - file_upload: { - id: uploadResult.id, - }, - caption: element.caption - ? [{ type: 'text', text: { content: element.caption } }] - : [], - }, // eslint-disable-line @typescript-eslint/no-explicit-any -- Notion file upload block structure not in types yet - }; } - catch (error) { - this.logger.error(`Failed to upload local image ${element.url}:`, error); - // Fallback to paragraph with image reference - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [ - { - type: 'text', - text: { - content: `[Failed to upload image: ${element.caption || element.url}]`, - }, - }, - ], - color: 'default', + if (token.type === 'em') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: false, + italic: true, + strikethrough: false, + underline: false, + code: false, }, - }; + }); } - } - /** - * Convert external image using external URL (original behavior) - */ - convertExternalImage(element) { - if (element.url && - !SUPPORTED_IMAGE_URL_EXTENSIONS.some((extension) => element.url?.endsWith(extension))) { - this.logger.warn(`Unsupported image URL extension: ${element.url}`); - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [], - color: 'default', + if (token.type === 'del') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: false, + italic: false, + strikethrough: true, + underline: false, + code: false, }, - }; + }); } - return { - type: 'image', - object: 'block', - image: { - type: 'external', - external: { - url: element.url || '', + if (token.type === 'codespan') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: false, + italic: false, + strikethrough: false, + underline: false, + code: true, }, + }); + } + return new elements_1.TextElement({ + text: token.text, + }); + } + parseBlockKatexToken(token) { + return new elements_1.EquationElement({ + equation: token.text, + styles: { + italic: false, + bold: false, + strikethrough: false, + underline: false, }, - }; + }); } - convertHtml(element) { - return { - type: 'code', - object: 'block', - code: { - language: 'html', - rich_text: this.convertRichText(element.html), - }, - }; + parseRawText(text) { + const tokens = this.preParseMarkdown(text); + const elements = []; + for (const t of tokens) { + switch (t.type) { + case 'paragraph': + elements.push(...this.parseParagraphToken(t)); + break; + case 'text': + elements.push(this.parseTextToken(t)); + break; + } + } + return elements; } - convertEquation(element) { - return { - type: 'equation', - object: 'block', - equation: { expression: element.equation }, - }; + parseParagraphToken(token) { + const elements = []; + token.tokens.forEach((t) => { + switch (t.type) { + case 'text': + elements.push(this.parseTextToken(t)); + break; + case 'inlineKatex': + elements.push(this.parseBlockKatexToken(t)); + break; + case 'strong': + elements.push(this.parseTextToken(t)); + break; + case 'em': + elements.push(this.parseTextToken(t)); + break; + case 'del': + elements.push(this.parseTextToken(t)); + break; + case 'codespan': + elements.push(this.parseTextToken(t)); + break; + case 'link': + elements.push(this.parseLinkToken(t)); + break; + case 'image': + elements.push(this.parseImageToken(t)); + break; + } + }); + return elements; } - convertRichText(content) { - if (content === undefined) { - return []; - } - if (typeof content === 'string') { - // Split string into chunks of 2000 characters - const MAX_LENGTH = 2000; - const chunks = []; - for (let i = 0; i < content.length; i += MAX_LENGTH) { - chunks.push(content.slice(i, i + MAX_LENGTH)); + parseToken(token) { + const elements = []; + switch (token.type) { + case 'heading': { + elements.push(this.parseHeadingToken(token)); + break; } - return chunks.map((chunk) => ({ - type: 'text', - text: { - content: chunk, - }, - })); - } - if (Array.isArray(content)) { - return content.reduce((acc, element) => { - if (element.type === elements_1.ElementType.Text) { - acc.push({ - type: 'text', - text: { - content: element.text, - }, - annotations: { - bold: element.styles.bold, - italic: element.styles.italic, - strikethrough: element.styles.strikethrough, - underline: element.styles.underline, - code: element.styles.code, - }, - }); - } - if (element instanceof elements_1.LinkElement) { - acc.push({ - type: 'text', - text: { - content: element.text, - link: element.url.startsWith('http') - ? { url: element.url } - : null, - }, - }); + case 'paragraph': { + if (token.tokens?.length === 1 && token.tokens[0].type === 'image') { + elements.push(this.parseImageToken(token.tokens[0])); } - if (element instanceof elements_1.EquationElement) { - acc.push({ - type: 'equation', - equation: { - expression: element.equation, - }, - annotations: { - bold: element.styles.bold, - italic: element.styles.italic, - strikethrough: element.styles.strikethrough, - underline: element.styles.underline, - code: element.styles.code, - }, - }); + else { + elements.push(new elements_1.TextElement({ + text: this.parseParagraphToken(token), + level: elements_1.TextElementLevel.Paragraph, + })); } - this.logger.warn(`Unsupported element type: ${element.type}`); - return acc; - }, []); + break; + } + case 'text': { + elements.push(this.parseTextToken(token)); + break; + } + case 'list': + const listItems = this.parseListToken(token); + elements.push(...listItems); + break; + case 'blockquote': { + elements.push(this.parseBlockQuoteToken(token)); + break; + } + case 'code': + elements.push(this.parseCodeToken(token)); + break; + case 'callout': + elements.push(this.parseCalloutToken(token)); + break; + case 'table': { + elements.push(this.parseTableToken(token)); + break; + } + case 'hr': + elements.push(new elements_1.DividerElement()); + break; + case 'image': + elements.push(this.parseImageToken(token)); + break; + case 'html': + elements.push(...this.parseHtmlToken(token)); + break; + case 'link': + elements.push(this.parseLinkToken(token)); + break; + case 'strong': + case 'em': + case 'del': + elements.push(this.parseTextToken(token)); + break; + case 'blockKatex': + elements.push(this.parseBlockKatexToken(token)); + break; + case 'inlineKatex': + elements.push(this.parseBlockKatexToken(token)); + break; + default: + break; } - throw new Error(`Unsupported content type: ${typeof content}`); + return elements; } - convertTableOfContents() { - return { - type: 'table_of_contents', - object: 'block', - table_of_contents: {}, + parse({ content, filepath, }) { + // Set the filepath context for use during this synchronous parse operation + this.parsingFilePath = filepath; + const tokens = this.preParseMarkdown(content); + const elements = []; + for (const token of tokens) { + elements.push(...this.parseToken(token)); + } + const result = { + content: elements, }; - } - convertToElement() { - throw new Error('Method not implemented.'); + const fileMetadata = this.getMetadata(content); + if (fileMetadata.id) { + result.id = 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; + } + // Clear the filepath context after parsing + this.parsingFilePath = undefined; + return result; } } -exports.NotionConverterRepository = NotionConverterRepository; +exports.MarkdownParser = MarkdownParser; + + +/***/ }), + +/***/ 1521: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); /***/ }), -/***/ 3942: +/***/ 2718: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; 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); -class NotionDestinationRepository { - client; - logger; - notionConverter; - constructor({ apiKey, logger, notionConverter, }) { - this.client = new client_1.Client({ - auth: apiKey, - logLevel: client_1.LogLevel.ERROR, - }); - this.logger = logger; - this.notionConverter = notionConverter; - } - /** - * Delete all child blocks from a parent page - */ - async deleteChildBlocks({ parentPageId, }) { +exports.FileSystemSourceRepository = void 0; +const fs_1 = __nccwpck_require__(9896); +const path_1 = __nccwpck_require__(6928); +const synchronization_1 = __nccwpck_require__(1230); +class FileSystemSourceRepository { + isFile(path) { try { - // Get all blocks in the parent page - const blocks = await this.getBlocksFromPage({ - notionPageId: parentPageId, - }); - // Delete each block - for (const block of blocks) { - await this.client.blocks.delete({ block_id: block.id }); - } + const stats = (0, fs_1.statSync)(path); + return stats.isFile(); } - catch (error) { - // Deletion failed - throw the error to be handled upstream - throw error instanceof Error ? error : new Error(String(error)); + catch { + return false; } } - 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'); + isDirectory(path) { + try { + const stats = (0, fs_1.statSync)(path); + return stats.isDirectory(); + } + catch { + return false; } - // Return the last match (closest to the end of the URL path) - return matches[matches.length - 1]; } - async destinationIsAccessible({ parentObjectId, }) { + isReadableRecursiveSync(path) { try { - await this.getPage({ pageId: parentObjectId }); + // Check if the path is readable + (0, fs_1.accessSync)(path, fs_1.constants.R_OK); + // Get directory contents + const entries = (0, fs_1.readdirSync)(path, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = (0, path_1.join)(path, entry.name); + if (entry.isDirectory()) { + // Recursively check subdirectory readability + if (!this.isReadableRecursiveSync(fullPath)) + return false; + } + else { + // Check if the file is readable + try { + (0, fs_1.accessSync)(fullPath, fs_1.constants.R_OK); + } + catch { + return false; + } + } + } return true; // eslint-disable-next-line @typescript-eslint/no-unused-vars } - catch (err) { - try { - await this.getDatabaseById({ databaseId: parentObjectId }); - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } - catch (_err) { - return false; - } - } - } - async getPageById({ notionPageId, }) { - const pageObjectResponse = await this.client.pages.retrieve({ - page_id: notionPageId, - }); - if (!(0, client_1.isFullPage)(pageObjectResponse)) { - throw new Error('Not able to retrieve Notion Page'); + catch (error) { + return false; } - const blocks = await this.getBlocksFromPage({ notionPageId }); - return new NotionPage_1.NotionPage({ - pageId: pageObjectResponse.id, - children: blocks, - createdAt: new Date(pageObjectResponse.created_time), - updatedAt: new Date(pageObjectResponse.last_edited_time), - isLocked: pageObjectResponse.is_locked ?? false, - }); } - async createPage({ parentObjectId, parentObjectType, pageElement, filePath, }) { - // Set the current file path for image resolution - if (filePath) { - this.notionConverter.setCurrentFilePath(filePath); - } - 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, - }))); + isReadableFile(path) { + try { + (0, fs_1.accessSync)(path, fs_1.constants.R_OK); + return true; } - 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, - properties: notionPage.properties, - icon: notionPage.icon, - children: [], // Create page without children initially - }); - // If there are children blocks, append them in chunks - if (notionPage.children && notionPage.children.length > 0) { - const children = notionPage.children; - // Split children into chunks of 100 blocks - for (let i = 0; i < children.length; i += NOTION_BLOCK_LIMIT) { - const chunk = children.slice(i, i + NOTION_BLOCK_LIMIT); - await this.client.blocks.children.append({ - block_id: notionPageId, - children: chunk, - }); - } + catch { + return false; } - return this.getPageById({ notionPageId }); - } - async updateBlock({ blockId, block, }) { - return this.client.blocks.update({ - block_id: blockId, - ...block, - }); - } - async getPage({ pageId }) { - const page = await this.client.pages.retrieve({ page_id: pageId }); - return page; - } - async getChildBlocksFromBlock({ blockId, }) { - const response = await this.client.blocks.children.list({ - block_id: blockId, - }); - return response.results; - } - async getBlocksFromPage({ notionPageId, }) { - const blocks = await this.client.blocks.children.list({ - block_id: notionPageId, - }); - return blocks.results; } - async updatePage({ pageId, pageElement, filePath, }) { - const notionPageId = pageId; - // Set the current file path for image resolution - if (filePath) { - this.notionConverter.setCurrentFilePath(filePath); - } - const notionPage = await this.notionConverter.convertFromElement(pageElement); - const updateBody = { - page_id: notionPageId, - properties: {}, - }; - if (notionPage.icon) { - updateBody.icon = notionPage.icon; - } - if (notionPage?.properties?.Name) { - updateBody.properties['Title'] = notionPage.properties - .Title; - } - await this.client.pages.update({ - page_id: notionPageId, - icon: notionPage.icon, - properties: updateBody.properties, - }); - const existingBlocks = await this.getChildBlocksFromBlock({ - blockId: notionPageId, - }); - const pageBlocks = existingBlocks; - if (notionPage.children && notionPage.children?.length > 0) { - const blocks = notionPage.children; - const promises = existingBlocks - .filter((existingBlock, index) => { - // @ts-expect-error - We know that the blocks are not equal - return !(0, utils_1.isBlockEquals)(blocks[index], existingBlock); - }) - .map(async (existingBlock, index) => this.client.blocks - .update({ - block_id: existingBlock.id, - ...blocks[index], - }) - .then((block) => { - pageBlocks[index] = block; - })); - await Promise.all(promises); + // eslint-disable-next-line @typescript-eslint/require-await + async sourceIsAccessible({ path }) { + if (this.isFile(path)) { + return this.isReadableFile(path); } - // Now it's time to compare the existing blocks with the new blocks - // and update the existing blocks with the new ones - return this.getPageById({ notionPageId }); - } - // Used for root level index.md where the page is already present - async appendToPage({ pageId, pageElement, }) { - const notionPage = await this.notionConverter.convertFromElement(pageElement); - // Update page properties (title/icon) if specified in metadata - await this.updatePageProperties({ pageId, pageElement }); - if (notionPage.children && notionPage.children.length > 0) { - // Append blocks to the existing page - try { - await this.client.blocks.children.append({ - block_id: pageId, - children: notionPage.children, - }); - } - catch (error) { - if ((0, error_1.isNotionNestingValidationError)(error)) { - throw new error_1.NotionNestingValidationError({ message: 'Nesting error' }); - } - this.logger.debug(`Failed to append block to page ${pageId}:`, { - error, - block: notionPage.children, - }); - throw error; - } + else if (this.isDirectory(path)) { + return this.isReadableRecursiveSync(path); } + return false; } - async updatePageProperties({ pageId, pageElement, }) { - const notionPage = await this.notionConverter.convertFromElement(pageElement); - // Only update if there are properties to update - if (notionPage.properties || notionPage.icon) { - // Update page properties and icon separately to avoid type conflicts - const updatePayload = { - page_id: pageId, - }; - if (notionPage.properties) { - updatePayload.properties = notionPage.properties; - } - if (notionPage.icon) { - updatePayload.icon = notionPage.icon; + // eslint-disable-next-line @typescript-eslint/require-await + async getFilePathList({ path }) { + // If it's a single file, return it as a single-item array + if (this.isFile(path)) { + if (!path.endsWith('.md')) { + throw new Error(`File ${path} is not a markdown file. Only .md files are supported.`); } - await this.client.pages.update(updatePayload); - } - } - async search({ filter, }) { - return this.client.search({ - filter, - }); - } - async setPageLockedStatus({ pageId, lockStatus, }) { - const isLocked = lockStatus === 'locked'; - await this.client.pages.update({ - page_id: pageId, - is_locked: isLocked, - }); - } - async getPageLockedStatus({ pageId, }) { - const page = await this.client.pages.retrieve({ page_id: pageId }); - const isLocked = page.properties?.is_locked; - if (isLocked === undefined) { - return 'unlocked'; + return [path]; } - 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'; + // If it's a directory, collect all markdown files recursively + if (!this.isDirectory(path)) { + throw new Error(`Path ${path} is neither a file nor a directory.`); } - catch { + const markdownFiles = []; + const collectMarkdownFiles = (dirPath) => { try { - await this.client.databases.retrieve({ database_id: id }); - return 'database'; + const entries = (0, fs_1.readdirSync)(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = (0, path_1.join)(dirPath, entry.name); + if (entry.isDirectory()) { + // Recursively process subdirectories + collectMarkdownFiles(fullPath); + } + else if (entry.isFile() && fullPath.endsWith('.md')) { + // Store markdown file path + markdownFiles.push(fullPath); + } + } } - catch { - return 'unknown'; + catch (error) { + throw new Error(`Error reading directory ${dirPath}`, { cause: error }); } - } - } - 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; + }; + collectMarkdownFiles(path); + return markdownFiles; } - async getDatasourceByDatasourceId({ datasourceId, }) { - return this.client.dataSources.retrieve({ - data_source_id: datasourceId, - }); + getLastUpdatedDate(filePath) { + const stats = (0, fs_1.statSync)(filePath); + return stats.mtime; // mtime (modification time) represents last updated date } - 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, - }, - }, + // eslint-disable-next-line @typescript-eslint/require-await + async getFile({ path }) { + // Determine the display name for the Notion page + const base = (0, path_1.basename)(path); + let name = base; + if (base.toLowerCase().endsWith('.md')) { + // Remove .md extension for all other files + name = base.slice(0, -3); + } + return new synchronization_1.File({ + name, + content: (0, fs_1.readFileSync)(path, 'utf-8'), + extension: (0, path_1.extname)(path).slice(1), + lastUpdated: this.getLastUpdatedDate(path), + path, }); - return items.results.map((item) => item.id); } - async deleteObjectById({ objectId }) { - await this.client.blocks.delete({ block_id: objectId }); + // eslint-disable-next-line @typescript-eslint/require-await + async updateFile(file) { + return (0, fs_1.writeFileSync)(file.path, file.content, 'utf-8'); } } -exports.NotionDestinationRepository = NotionDestinationRepository; - - -/***/ }), - -/***/ 7100: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isBlockEquals = exports.normalizeBlock = void 0; -const normalizeBlock = (block) => { - let normalizedContent = ''; - if (block === undefined) { - return normalizedContent; - } - if ('paragraph' in block) { - normalizedContent = block.paragraph.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('heading_1' in block) { - normalizedContent = block.heading_1.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('heading_2' in block) { - normalizedContent = block.heading_2.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('heading_3' in block) { - normalizedContent = block.heading_3.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('bulleted_list_item' in block) { - normalizedContent = block.bulleted_list_item.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('numbered_list_item' in block) { - normalizedContent = block.numbered_list_item.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('to_do' in block) { - normalizedContent = block.to_do.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('toggle' in block) { - normalizedContent = block.toggle.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('callout' in block) { - normalizedContent = block.callout.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - // Add other block types as needed - return normalizedContent; -}; -exports.normalizeBlock = normalizeBlock; -const isBlockEquals = (blockRequest, blockResponse) => { - const newNormalizedContent = (0, exports.normalizeBlock)(blockRequest); - const existingNormalizedContent = (0, exports.normalizeBlock)(blockResponse); - return newNormalizedContent === existingNormalizedContent; -}; -exports.isBlockEquals = isBlockEquals; +exports.FileSystemSourceRepository = FileSystemSourceRepository; /***/ }), @@ -83347,7 +83454,7 @@ module.exports = index; /***/ }), -/***/ 1638: +/***/ 9257: /***/ ((module) => { "use strict"; diff --git a/src/MkNotes.test.ts b/src/MkNotes.test.ts index 9a67690..212f3fd 100644 --- a/src/MkNotes.test.ts +++ b/src/MkNotes.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import { getFakeInfrastructureInstances } from '../__tests__/__fakes__/fakeInfrastructureInstances'; import { FakeInfrastructureInstances } from '../__tests__/__fakes__/fakeInfrastructureInstances'; -import { fakeLogger } from '../__tests__/__fakes__/fakeLogger'; +import { fakeLogger } from '../__tests__/__fakes__/logger/fake-logger'; import { MkNotes } from './MkNotes'; import { getInfrastructureInstances } from './infrastructure'; @@ -62,9 +62,9 @@ describe('MkNotes', () => { describe('synchronizeMarkdownToNotionFromFileSystem', () => { it('should synchronize markdown file to Notion', async () => { - const createPageSpy = jest.spyOn( + const updatePageSpy = jest.spyOn( infrastructureInstances.notionDestination, - 'createPage' + 'updatePage' ); const notionPageUrl = @@ -73,9 +73,14 @@ describe('MkNotes', () => { await mkNotes.synchronizeMarkdownToNotionFromFileSystem({ inputPath: 'fake/input/path.md', parentNotionPageId: notionPageUrl, + cleanSync: false, + lockPage: false, + saveId: false, + forceNew: false, }); - expect(createPageSpy).toHaveBeenCalled(); + // Without cleanSync, the root file updates the parent page + expect(updatePageSpy).toHaveBeenCalled(); }); }); diff --git a/src/MkNotes.ts b/src/MkNotes.ts index b1d014c..9ca410b 100644 --- a/src/MkNotes.ts +++ b/src/MkNotes.ts @@ -5,6 +5,7 @@ import { PreviewFormat, PreviewSynchronization, SynchronizeMarkdownToNotion, + SynchronizeOptions, } from '@/domains'; import { getInfrastructureInstances, @@ -13,6 +14,12 @@ import { import { LogLevel } from './domains/logger/types'; +export interface SyncOptions { + cleanSync: boolean; + lockPage: boolean; + saveId: boolean; +} + /** * MkNotes client */ @@ -83,12 +90,12 @@ export class MkNotes { parentNotionPageId, cleanSync = false, lockPage = false, + saveId = false, + forceNew = false, }: { inputPath: string; parentNotionPageId: string; - cleanSync?: boolean; - lockPage?: boolean; - }): Promise { + } & SynchronizeOptions): Promise { const synchronizeMarkdownToNotion = new SynchronizeMarkdownToNotion({ logger: this.logger, destinationRepository: this.infrastructureInstances.notionDestination, @@ -101,6 +108,8 @@ export class MkNotes { notionParentPageUrl: parentNotionPageId, cleanSync, lockPage, + saveId, + forceNew, }); } } diff --git a/src/bin/cli/commands/sync.ts b/src/bin/cli/commands/sync.ts index 00bde5f..c450c75 100644 --- a/src/bin/cli/commands/sync.ts +++ b/src/bin/cli/commands/sync.ts @@ -34,6 +34,10 @@ command.option( command.option('-l, --lock', 'Lock the Notion page after syncing'); +command.option('-s, --save-id', 'Save the page ID to the source repository'); + +command.option('-f, --force-new', 'Force a new page to be created'); + command.option('-v, --verbosity ', 'Verbosity level', 'error'); interface SyncOptions { input: string; @@ -41,7 +45,9 @@ interface SyncOptions { notionApiKey: string; clean?: boolean; lock?: boolean; + saveId?: boolean; verbosity?: string; + forceNew?: boolean; } command.action(async (opts: SyncOptions) => { @@ -51,6 +57,8 @@ command.action(async (opts: SyncOptions) => { notionApiKey, clean = false, lock = false, + saveId = false, + forceNew = false, verbosity = 'error', } = opts; @@ -68,6 +76,8 @@ command.action(async (opts: SyncOptions) => { parentNotionPageId: notionParentPageUrl, cleanSync: clean, lockPage: lock, + saveId: saveId, + forceNew: forceNew, }); // eslint-disable-next-line no-console diff --git a/src/bin/github-actions/preview.ts b/src/bin/github-actions/preview.ts index 08fdda4..1bfd7d3 100644 --- a/src/bin/github-actions/preview.ts +++ b/src/bin/github-actions/preview.ts @@ -1,6 +1,6 @@ import { getInput, info, setFailed, setOutput } from '@actions/core'; -import { isValidFormat } from '@/domains/features/previewSynchronization'; +import { isValidFormat } from '@/domains/synchronization/features/preview-synchronization.feature'; import { MkNotes } from '@/MkNotes'; enum Inputs { diff --git a/src/bin/github-actions/sync.ts b/src/bin/github-actions/sync.ts index 6a72c75..f232a5c 100644 --- a/src/bin/github-actions/sync.ts +++ b/src/bin/github-actions/sync.ts @@ -10,6 +10,8 @@ enum Inputs { NotionApiKey = 'notion-api-key', // Notion API key Destination = 'destination', // Notion page URL Lock = 'lock', // Lock page + SaveId = 'save-id', // Save ID + ForceNew = 'force-new', // Force new } export const sync = async (earlyExit: boolean = false) => { @@ -19,6 +21,8 @@ export const sync = async (earlyExit: boolean = false) => { const notionApiKey = getInput(Inputs.NotionApiKey, { required: true }); const clean = getInputAsBool(Inputs.Clean); const lock = getInputAsBool(Inputs.Lock) ?? false; + const saveId = getInputAsBool(Inputs.SaveId); + const forceNew = getInputAsBool(Inputs.ForceNew); const mkNotes = new MkNotes({ notionApiKey, @@ -29,6 +33,8 @@ export const sync = async (earlyExit: boolean = false) => { parentNotionPageId: destination, cleanSync: clean, lockPage: lock, + saveId: saveId, + forceNew: forceNew, }); // node will stay alive if any promises are not resolved, diff --git a/src/domains/elements/Element/DividerElement.class.ts b/src/domains/elements/Element/DividerElement.class.ts deleted file mode 100644 index 95bf443..0000000 --- a/src/domains/elements/Element/DividerElement.class.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 11e8219..0000000 --- a/src/domains/elements/Element/Element.class.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ElementType } from './types'; - -export class Element { - public type: ElementType; - - constructor(type: ElementType) { - this.type = type; - } -} diff --git a/src/domains/elements/Element/TableElement.class.ts b/src/domains/elements/Element/TableElement.class.ts deleted file mode 100644 index 679d868..0000000 --- a/src/domains/elements/Element/TableElement.class.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index fa4568a..0000000 --- a/src/domains/elements/Element/TableOfContentElement.class.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/ToggleElement.class.ts b/src/domains/elements/Element/ToggleElement.class.ts deleted file mode 100644 index e0cea20..0000000 --- a/src/domains/elements/Element/ToggleElement.class.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/converter.repository.ts b/src/domains/elements/converter.repository.ts deleted file mode 100644 index 1793ab3..0000000 --- a/src/domains/elements/converter.repository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Element } from './Element'; - -export interface ElementConverterRepository { - convertToElement(u: S): E; - convertFromElement?: (e: E) => Promise; - setCurrentFilePath?(filePath: string): void; -} diff --git a/src/domains/elements/Element/CalloutElement.class.ts b/src/domains/elements/entities/Element/CalloutElement.class.ts similarity index 87% rename from src/domains/elements/Element/CalloutElement.class.ts rename to src/domains/elements/entities/Element/CalloutElement.class.ts index 1e1810d..68507b4 100644 --- a/src/domains/elements/Element/CalloutElement.class.ts +++ b/src/domains/elements/entities/Element/CalloutElement.class.ts @@ -1,4 +1,4 @@ -import { SupportedEmoji } from '../types'; +import { SupportedEmoji } from '../../types'; import { Element } from './Element.class'; import { ElementType } from './types'; @@ -23,8 +23,17 @@ export class CalloutElement extends Element { return specialCalloutRegex.test(text.trim()); } - constructor({ icon, text }: { icon?: SupportedEmoji; text: string }) { - super(ElementType.Callout); + constructor({ + id, + icon, + text, + }: { + id?: string; + icon?: SupportedEmoji; + text: string; + }) { + super({ id, type: ElementType.Callout }); + this.icon = icon; this.text = text; @@ -82,4 +91,8 @@ export class CalloutElement extends Element { return this.icon; } + + public toContentString(): string { + return `[!${this.calloutType}](${this.text})`; + } } diff --git a/src/domains/elements/Element/CodeElement.class.ts b/src/domains/elements/entities/Element/CodeElement.class.ts similarity index 85% rename from src/domains/elements/Element/CodeElement.class.ts rename to src/domains/elements/entities/Element/CodeElement.class.ts index 88a0151..a9a10c4 100644 --- a/src/domains/elements/Element/CodeElement.class.ts +++ b/src/domains/elements/entities/Element/CodeElement.class.ts @@ -37,14 +37,20 @@ export class CodeElement extends Element { public text: string; constructor({ + id, language, text, }: { + id?: string; language: ElementCodeLanguage; text: string; }) { - super(ElementType.Code); + super({ id, type: ElementType.Code }); this.language = language; this.text = text; } + + public toContentString(): string { + return `\`\`\`${this.language}\n${this.text}\n\`\`\``; + } } diff --git a/src/domains/elements/entities/Element/DividerElement.class.ts b/src/domains/elements/entities/Element/DividerElement.class.ts new file mode 100644 index 0000000..eb2d0a6 --- /dev/null +++ b/src/domains/elements/entities/Element/DividerElement.class.ts @@ -0,0 +1,12 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class DividerElement extends Element { + constructor({ id }: { id?: string } = { id: undefined }) { + super({ id, type: ElementType.Divider }); + } + + public toContentString(): string { + return '------'; + } +} diff --git a/src/domains/elements/entities/Element/Element.class.ts b/src/domains/elements/entities/Element/Element.class.ts new file mode 100644 index 0000000..105048c --- /dev/null +++ b/src/domains/elements/entities/Element/Element.class.ts @@ -0,0 +1,15 @@ +import { ElementType } from './types'; + +export class Element { + public id?: string; + public type: ElementType; + + constructor({ id, type }: { id?: string; type: ElementType }) { + this.id = id; + this.type = type; + } + + public toContentString(): string { + throw new Error('toContentString must be implemented by the subclass'); + } +} diff --git a/src/domains/elements/Element/EquationElement.class.ts b/src/domains/elements/entities/Element/EquationElement.class.ts similarity index 69% rename from src/domains/elements/Element/EquationElement.class.ts rename to src/domains/elements/entities/Element/EquationElement.class.ts index 42b1272..fb3c516 100644 --- a/src/domains/elements/Element/EquationElement.class.ts +++ b/src/domains/elements/entities/Element/EquationElement.class.ts @@ -13,9 +13,11 @@ export class EquationElement extends Element { }; constructor({ + id, equation, styles, }: { + id?: string; equation: string; styles?: { italic?: boolean; @@ -25,7 +27,7 @@ export class EquationElement extends Element { code?: boolean; }; }) { - super(ElementType.Equation); + super({ id, type: ElementType.Equation }); this.equation = equation; this.styles.bold = styles?.bold || false; this.styles.italic = styles?.italic || false; @@ -33,4 +35,19 @@ export class EquationElement extends Element { this.styles.underline = styles?.underline || false; this.styles.code = styles?.code || false; } + + public toContentString(): string { + let { equation } = this; + + if (this.styles.italic) { + equation = `_${equation}_`; + } + if (this.styles.strikethrough) { + equation = `~~${equation}~~`; + } + if (this.styles.underline) { + equation = `__${equation}__`; + } + return equation; + } } diff --git a/src/domains/elements/Element/FileElement.class.ts b/src/domains/elements/entities/Element/FileElement.class.ts similarity index 82% rename from src/domains/elements/Element/FileElement.class.ts rename to src/domains/elements/entities/Element/FileElement.class.ts index 53fbf8c..9a4f8d9 100644 --- a/src/domains/elements/Element/FileElement.class.ts +++ b/src/domains/elements/entities/Element/FileElement.class.ts @@ -12,23 +12,29 @@ export class FileElement extends Element { public extension?: string; constructor({ + id, content, name, creationDate, lastUpdatedDate, extension, }: { + id?: string; content: string; name?: string; creationDate?: Date; lastUpdatedDate?: Date; extension?: string; }) { - super(ElementType.File); + super({ id, type: ElementType.File }); this.content = content; this.name = name; this.creationDate = creationDate; this.lastUpdatedDate = lastUpdatedDate; this.extension = extension; } + + public toContentString(): string { + return `[${this.name}](${this.content})`; + } } diff --git a/src/domains/elements/Element/HtmlElement.class.ts b/src/domains/elements/entities/Element/HtmlElement.class.ts similarity index 51% rename from src/domains/elements/Element/HtmlElement.class.ts rename to src/domains/elements/entities/Element/HtmlElement.class.ts index 9590a25..39605b5 100644 --- a/src/domains/elements/Element/HtmlElement.class.ts +++ b/src/domains/elements/entities/Element/HtmlElement.class.ts @@ -4,8 +4,12 @@ import { ElementType } from './types'; export class HtmlElement extends Element { public html: string; - constructor({ html }: { html: string }) { - super(ElementType.Html); + constructor({ id, html }: { id?: string; html: string }) { + super({ id, type: ElementType.Html }); this.html = html; } + + public toContentString(): string { + return this.html; + } } diff --git a/src/domains/elements/Element/ImageElement.class.ts b/src/domains/elements/entities/Element/ImageElement.class.ts similarity index 85% rename from src/domains/elements/Element/ImageElement.class.ts rename to src/domains/elements/entities/Element/ImageElement.class.ts index 5ce8eb1..3043d39 100644 --- a/src/domains/elements/Element/ImageElement.class.ts +++ b/src/domains/elements/entities/Element/ImageElement.class.ts @@ -12,6 +12,7 @@ export class ImageElement extends Element { public filepath?: string; constructor({ + id, base64, url, name, @@ -21,6 +22,7 @@ export class ImageElement extends Element { caption, filepath, }: { + id?: string; base64?: string; url?: string; name?: string; @@ -30,7 +32,7 @@ export class ImageElement extends Element { caption?: string; filepath?: string; }) { - super(ElementType.Image); + super({ id, type: ElementType.Image }); this.name = name; this.creationDate = creationDate; this.lastUpdatedDate = lastUpdatedDate; @@ -40,4 +42,8 @@ export class ImageElement extends Element { this.caption = caption; this.filepath = filepath; } + + public toContentString(): string { + return `![${this.name}](${this.url})`; + } } diff --git a/src/domains/elements/Element/LinkElement.class.ts b/src/domains/elements/entities/Element/LinkElement.class.ts similarity index 61% rename from src/domains/elements/Element/LinkElement.class.ts rename to src/domains/elements/entities/Element/LinkElement.class.ts index 7f0567e..3616a0e 100644 --- a/src/domains/elements/Element/LinkElement.class.ts +++ b/src/domains/elements/entities/Element/LinkElement.class.ts @@ -5,19 +5,29 @@ export class LinkElement extends Element { public url: string; public text: string; public caption?: string; + public filepath?: string; constructor({ + id, url, text, caption, + filepath, }: { + id?: string; url: string; text: string; caption?: string; + filepath?: string; }) { - super(ElementType.Link); + super({ id, type: ElementType.Link }); this.url = url; this.text = text; this.caption = caption; + this.filepath = filepath; + } + + public toContentString(): string { + return `[${this.text}](${this.url})`; } } diff --git a/src/domains/elements/Element/ListItemElement.class.ts b/src/domains/elements/entities/Element/ListItemElement.class.ts similarity index 50% rename from src/domains/elements/Element/ListItemElement.class.ts rename to src/domains/elements/entities/Element/ListItemElement.class.ts index 1e64f07..5ff24d2 100644 --- a/src/domains/elements/Element/ListItemElement.class.ts +++ b/src/domains/elements/entities/Element/ListItemElement.class.ts @@ -7,17 +7,33 @@ export class ListItemElement extends Element { public text: RichTextElement; public children?: Element[]; constructor({ + id, listType, text, children, }: { + id?: string; listType: 'ordered' | 'unordered'; text: RichTextElement; children?: Element[]; }) { - super(ElementType.ListItem); + super({ id, type: ElementType.ListItem }); this.listType = listType; this.text = text; this.children = children; } + + public toContentString(): string { + let content = ''; + if (this.listType === 'ordered') { + content = `1. ${this.text.map((element) => element.toContentString()).join('')}`; + } else { + content = `- ${this.text.map((element) => element.toContentString()).join('')}`; + } + + if (this.children) { + content += `\n${this.children.map((element) => element.toContentString()).join('')}`; + } + return content; + } } diff --git a/src/domains/elements/Element/PageElement.class.ts b/src/domains/elements/entities/Element/PageElement.class.ts similarity index 79% rename from src/domains/elements/Element/PageElement.class.ts rename to src/domains/elements/entities/Element/PageElement.class.ts index ff965da..897edc9 100644 --- a/src/domains/elements/Element/PageElement.class.ts +++ b/src/domains/elements/entities/Element/PageElement.class.ts @@ -1,4 +1,6 @@ -import { SupportedEmoji } from '../types'; +import { File } from '@/domains/synchronization'; + +import { SupportedEmoji } from '../../types'; import { Element } from './Element.class'; import { ElementType } from './types'; @@ -12,40 +14,42 @@ export type PageElementPropertyValue = | null | undefined; -export type PageElementProperties = { +export interface 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[]; + public source?: File; constructor({ - mkNotesInternalId, + id, title, icon, content = [], properties, + source, }: { - mkNotesInternalId?: string; + id?: string; title: string; icon?: SupportedEmoji; content: Element[]; properties?: PageElementProperties[]; + source?: File; }) { - super(ElementType.Page); - this.mkNotesInternalId = mkNotesInternalId; + super({ id, type: ElementType.Page }); this.title = title; this.icon = icon; this.content = content; this.properties = properties; + this.source = source; } public getIcon(): SupportedEmoji | undefined { diff --git a/src/domains/elements/Element/QuoteElement.class.ts b/src/domains/elements/entities/Element/QuoteElement.class.ts similarity index 50% rename from src/domains/elements/Element/QuoteElement.class.ts rename to src/domains/elements/entities/Element/QuoteElement.class.ts index 2b5dd81..e730f71 100644 --- a/src/domains/elements/Element/QuoteElement.class.ts +++ b/src/domains/elements/entities/Element/QuoteElement.class.ts @@ -4,8 +4,12 @@ import { ElementType } from './types'; export class QuoteElement extends Element { public text: string; - constructor({ text }: { text: string }) { - super(ElementType.Quote); + constructor({ id, text }: { id?: string; text: string }) { + super({ id, type: ElementType.Quote }); this.text = text; } + + public toContentString(): string { + return `> ${this.text}`; + } } diff --git a/src/domains/elements/entities/Element/TableElement.class.ts b/src/domains/elements/entities/Element/TableElement.class.ts new file mode 100644 index 0000000..efe683d --- /dev/null +++ b/src/domains/elements/entities/Element/TableElement.class.ts @@ -0,0 +1,16 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class TableElement extends Element { + public rows: string[][]; + + constructor({ id, rows }: { id?: string; rows: string[][] }) { + super({ id, type: ElementType.Table }); + + this.rows = rows; + } + + public toContentString(): string { + return this.rows.map((row) => row.join(' | ')).join('\n'); + } +} diff --git a/src/domains/elements/entities/Element/TableOfContentElement.class.ts b/src/domains/elements/entities/Element/TableOfContentElement.class.ts new file mode 100644 index 0000000..9e79276 --- /dev/null +++ b/src/domains/elements/entities/Element/TableOfContentElement.class.ts @@ -0,0 +1,12 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class TableOfContentsElement extends Element { + constructor({ id }: { id?: string } = { id: undefined }) { + super({ id, type: ElementType.TableOfContents }); + } + + public toContentString(): string { + return ''; + } +} diff --git a/src/domains/elements/Element/TextElement.class.ts b/src/domains/elements/entities/Element/TextElement.class.ts similarity index 78% rename from src/domains/elements/Element/TextElement.class.ts rename to src/domains/elements/entities/Element/TextElement.class.ts index af1c332..413f05e 100644 --- a/src/domains/elements/Element/TextElement.class.ts +++ b/src/domains/elements/entities/Element/TextElement.class.ts @@ -43,10 +43,12 @@ export class TextElement extends Element { }; constructor({ + id, text, level = TextElementLevel.Paragraph, styles, }: { + id?: string; text: string | RichTextElement; level?: TextElementLevel; styles?: { @@ -57,7 +59,7 @@ export class TextElement extends Element { code?: boolean; }; }) { - super(ElementType.Text); + super({ id, type: ElementType.Text }); this.text = text; this.level = level; this.styles.bold = styles?.bold || false; @@ -66,4 +68,22 @@ export class TextElement extends Element { this.styles.underline = styles?.underline || false; this.styles.code = styles?.code || false; } + + public toContentString(): string { + let { text } = this; + if (typeof text === 'string') { + return text; + } + text = text.map((element) => element.toContentString()).join(''); + if (this.styles.italic) { + text = `_${text}_`; + } + if (this.styles.strikethrough) { + text = `~~${text}~~`; + } + if (this.styles.underline) { + text = `__${text}__`; + } + return text; + } } diff --git a/src/domains/elements/Element/TextElement.types.ts b/src/domains/elements/entities/Element/TextElement.types.ts similarity index 100% rename from src/domains/elements/Element/TextElement.types.ts rename to src/domains/elements/entities/Element/TextElement.types.ts diff --git a/src/domains/elements/entities/Element/ToggleElement.class.ts b/src/domains/elements/entities/Element/ToggleElement.class.ts new file mode 100644 index 0000000..224cd3f --- /dev/null +++ b/src/domains/elements/entities/Element/ToggleElement.class.ts @@ -0,0 +1,26 @@ +import { Element } from './Element.class'; +import { ElementType } from './types'; + +export class ToggleElement extends Element { + public title: string; + public children: Element[]; + + constructor({ + id, + title, + children, + }: { + id?: string; + title: string; + children: Element[]; + }) { + super({ id, type: ElementType.Toggle }); + this.title = title; + this.children = children; + } + + public toContentString(): string { + const { title } = this; + return `[${title}](${this.children.map((element) => element.toContentString()).join('')})`; + } +} diff --git a/src/domains/elements/Element/index.ts b/src/domains/elements/entities/Element/index.ts similarity index 100% rename from src/domains/elements/Element/index.ts rename to src/domains/elements/entities/Element/index.ts diff --git a/src/domains/elements/Element/types.ts b/src/domains/elements/entities/Element/types.ts similarity index 100% rename from src/domains/elements/Element/types.ts rename to src/domains/elements/entities/Element/types.ts diff --git a/src/domains/elements/index.ts b/src/domains/elements/index.ts index 0e1a95d..9ae58fb 100644 --- a/src/domains/elements/index.ts +++ b/src/domains/elements/index.ts @@ -1,4 +1,4 @@ -export * from './converter.repository'; -export * from './Element'; -export * from './parser.repository'; +export * from './entities/Element'; +export * from './repositories/converter.repository'; +export * from './repositories/parser.repository'; export * from './types'; diff --git a/src/domains/elements/repositories/converter.repository.ts b/src/domains/elements/repositories/converter.repository.ts new file mode 100644 index 0000000..b2bb955 --- /dev/null +++ b/src/domains/elements/repositories/converter.repository.ts @@ -0,0 +1,6 @@ +import { Element } from '../entities/Element'; + +export interface ElementConverterRepository { + convertToElement(u: S): E; + convertFromElement(e: E): Promise | S; +} diff --git a/src/domains/elements/parser.repository.ts b/src/domains/elements/repositories/parser.repository.ts similarity index 65% rename from src/domains/elements/parser.repository.ts rename to src/domains/elements/repositories/parser.repository.ts index 9d42edf..860d28b 100644 --- a/src/domains/elements/parser.repository.ts +++ b/src/domains/elements/repositories/parser.repository.ts @@ -1,10 +1,10 @@ import { Logger } from 'winston'; -import { Element, PageElementProperties } from './Element'; -import { SupportedEmoji } from './types'; +import { Element, PageElementProperties } from '../entities/Element'; +import { SupportedEmoji } from '../types'; export interface ParseResult { - mkNotesInternalId?: string; + id?: string; title?: string; properties?: PageElementProperties[]; content: Element[]; @@ -19,9 +19,7 @@ export class ParserRepository { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - parse(args: { content: string }): ParseResult { + parse(args: { content: string; filepath?: string }): ParseResult { throw new Error('Method not implemented.'); } - - setCurrentFilePath?(filePath: string): void; } diff --git a/src/domains/features/index.ts b/src/domains/features/index.ts index 8b6899f..ee725d0 100644 --- a/src/domains/features/index.ts +++ b/src/domains/features/index.ts @@ -1,2 +1,2 @@ -export * from './previewSynchronization'; -export * from './synchronizeMarkdownToNotion'; +export * from '../synchronization/features/preview-synchronization.feature'; +export * from '../synchronization/features/synchronize-markdown-to-notion.feature'; diff --git a/src/domains/notion/constants.ts b/src/domains/notion/constants.ts deleted file mode 100644 index 122e2ae..0000000 --- a/src/domains/notion/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const MK_NOTES_INTERNAL_ID_PROPERTY_NAME = 'mk-notes-id'; diff --git a/src/domains/notion/NotionPage.ts b/src/domains/notion/entities/NotionPage.ts similarity index 98% rename from src/domains/notion/NotionPage.ts rename to src/domains/notion/entities/NotionPage.ts index 6eb42aa..827a8fc 100644 --- a/src/domains/notion/NotionPage.ts +++ b/src/domains/notion/entities/NotionPage.ts @@ -16,7 +16,7 @@ export class NotionPage implements Page { public readonly icon?: Icon; public readonly title?: string; public readonly properties?: PageProperties; - public readonly children: ( + public children: ( | BlockObjectResponse | PartialBlockObjectResponse | BlockObjectRequest @@ -67,6 +67,7 @@ export class NotionPage implements Page { isLocked: false, }); } + toCreatePageBodyParameters(): PartialCreatePageBodyParameters { return { children: this.children as BlockObjectRequest[], diff --git a/src/domains/notion/repositories/notion-client.repository.ts b/src/domains/notion/repositories/notion-client.repository.ts new file mode 100644 index 0000000..3bc114e --- /dev/null +++ b/src/domains/notion/repositories/notion-client.repository.ts @@ -0,0 +1,114 @@ +import { + BlockObjectResponse, + DatabaseObjectResponse, + DataSourceObjectResponse, + SearchResponse, +} from '@notionhq/client/build/src/api-endpoints'; + +import { BlockObjectRequest, Icon, PageProperties, Parent } from '../types'; + +export interface CreatePageInput { + parent: Parent; + properties: PageProperties; + icon?: Icon; + children?: BlockObjectRequest[]; +} +import { NotionPage } from '../entities/NotionPage'; + +export interface NotionClientRepository { + /** + * ------------------------------------------------------------ + * GENERAL METHODS + * ------------------------------------------------------------ + */ + search({ + filter, + }: { + filter: { property: 'object'; value: 'page' | 'data_source' }; + }): Promise; + + /** + * ------------------------------------------------------------ + * DATABASES METHODS + * ------------------------------------------------------------ + */ + getDatabaseById({ + databaseId, + }: { + databaseId: string; + }): Promise; + + /** + * ------------------------------------------------------------ + * DATA SOURCES METHODS + * ------------------------------------------------------------ + */ + getDataSourceById({ + dataSourceId, + }: { + dataSourceId: string; + }): Promise; + + getDataSourceIdFromDatabaseId({ + databaseId, + }: { + databaseId: string; + }): Promise; + + /** + * ------------------------------------------------------------ + * PAGES METHODS + * ------------------------------------------------------------ + */ + createPage({ + parent, + properties, + icon, + children, + }: CreatePageInput): Promise; + updatePage({ + pageId, + icon, + properties, + archived, + isLocked, + }: { + pageId: string; + icon?: Icon; + properties?: PageProperties; + archived?: boolean; + isLocked?: boolean; + }): Promise; + deletePage({ pageId }: { pageId: string }): Promise; + getPage({ pageId }: { pageId: string }): Promise; + getPageBlocks({ pageId }: { pageId: string }): Promise; + /** + * ------------------------------------------------------------ + * BLOCKS METHODS + * ------------------------------------------------------------ + */ + appendChildToBlock({ + blockId, + children, + afterBlockId, + }: { + blockId: string; + children: BlockObjectRequest[]; + afterBlockId?: string; + }): Promise; + deleteBlock({ blockId }: { blockId: string }): Promise; + deleteBlocks({ blockIds }: { blockIds: string[] }): Promise; + getBlock({ blockId }: { blockId: string }): Promise; + updateBlock({ + blockId, + block, + }: { + blockId: string; + block: BlockObjectRequest; + }): Promise; + getBlockChildren({ + blockId, + }: { + blockId: string; + }): Promise; +} diff --git a/src/domains/sitemap/SiteMap.ts b/src/domains/sitemap/entities/SiteMap.ts similarity index 100% rename from src/domains/sitemap/SiteMap.ts rename to src/domains/sitemap/entities/SiteMap.ts diff --git a/src/domains/sitemap/TreeNode.ts b/src/domains/sitemap/entities/TreeNode.ts similarity index 100% rename from src/domains/sitemap/TreeNode.ts rename to src/domains/sitemap/entities/TreeNode.ts diff --git a/src/domains/sitemap/SiteMap.test.ts b/src/domains/sitemap/entities/__tests__/SiteMap.test.ts similarity index 99% rename from src/domains/sitemap/SiteMap.test.ts rename to src/domains/sitemap/entities/__tests__/SiteMap.test.ts index ce401b6..7c1edfd 100644 --- a/src/domains/sitemap/SiteMap.test.ts +++ b/src/domains/sitemap/entities/__tests__/SiteMap.test.ts @@ -1,4 +1,4 @@ -import { SiteMap } from './SiteMap'; +import { SiteMap } from '../SiteMap'; describe('SiteMap', () => { describe('buildFromFilePaths', () => { diff --git a/src/domains/sitemap/TreeNode.test.ts b/src/domains/sitemap/entities/__tests__/TreeNode.test.ts similarity index 98% rename from src/domains/sitemap/TreeNode.test.ts rename to src/domains/sitemap/entities/__tests__/TreeNode.test.ts index 11cea95..cefe57a 100644 --- a/src/domains/sitemap/TreeNode.test.ts +++ b/src/domains/sitemap/entities/__tests__/TreeNode.test.ts @@ -1,4 +1,4 @@ -import { TreeNode } from './TreeNode'; +import { TreeNode } from '../TreeNode'; describe('TreeNode', () => { describe('constructor', () => { diff --git a/src/domains/sitemap/index.ts b/src/domains/sitemap/index.ts index d18b031..c3b38cc 100644 --- a/src/domains/sitemap/index.ts +++ b/src/domains/sitemap/index.ts @@ -1,3 +1,3 @@ +export { SiteMap } from './entities/SiteMap'; +export { TreeNode } from './entities/TreeNode'; export * as serializers from './serializers'; -export { SiteMap } from './SiteMap'; -export { TreeNode } from './TreeNode'; diff --git a/src/domains/sitemap/serializers/json.serializer.test.ts b/src/domains/sitemap/serializers/__tests__/json.serializer.test.ts similarity index 94% rename from src/domains/sitemap/serializers/json.serializer.test.ts rename to src/domains/sitemap/serializers/__tests__/json.serializer.test.ts index 0f3b035..f58e29e 100644 --- a/src/domains/sitemap/serializers/json.serializer.test.ts +++ b/src/domains/sitemap/serializers/__tests__/json.serializer.test.ts @@ -1,5 +1,5 @@ -import { serializeInJson } from './json.serializer'; -import { SiteMap } from '../SiteMap'; +import { serializeInJson } from '../json.serializer'; +import { SiteMap } from '../../entities/SiteMap'; describe('JSON Serializer', () => { it('should serialize an empty SiteMap', () => { diff --git a/src/domains/sitemap/serializers/plainText.serializer.test.ts b/src/domains/sitemap/serializers/__tests__/plainText.serializer.test.ts similarity index 95% rename from src/domains/sitemap/serializers/plainText.serializer.test.ts rename to src/domains/sitemap/serializers/__tests__/plainText.serializer.test.ts index 4ec909f..61aa933 100644 --- a/src/domains/sitemap/serializers/plainText.serializer.test.ts +++ b/src/domains/sitemap/serializers/__tests__/plainText.serializer.test.ts @@ -1,5 +1,5 @@ -import { serializeInPlainText } from './plainText.serializer'; -import { SiteMap } from '../SiteMap'; +import { serializeInPlainText } from '../plainText.serializer'; +import { SiteMap } from '../../entities/SiteMap'; describe('Plain Text Serializer', () => { it('should serialize an empty SiteMap', () => { diff --git a/src/domains/sitemap/serializers/json.serializer.ts b/src/domains/sitemap/serializers/json.serializer.ts index 2af163f..742be7d 100644 --- a/src/domains/sitemap/serializers/json.serializer.ts +++ b/src/domains/sitemap/serializers/json.serializer.ts @@ -1,4 +1,4 @@ -import { type TreeNode } from '../TreeNode'; +import { type TreeNode } from '../entities/TreeNode'; import { type SitemapSerializer } from './types'; interface NodePreview { diff --git a/src/domains/sitemap/serializers/plainText.serializer.ts b/src/domains/sitemap/serializers/plainText.serializer.ts index df45e39..39abdc6 100644 --- a/src/domains/sitemap/serializers/plainText.serializer.ts +++ b/src/domains/sitemap/serializers/plainText.serializer.ts @@ -1,4 +1,4 @@ -import { type TreeNode } from '../TreeNode'; +import { type TreeNode } from '../entities/TreeNode'; import { type SitemapSerializer } from './types'; export const serializeInPlainText: SitemapSerializer = (siteMap) => { diff --git a/src/domains/features/previewSynchronization.test.ts b/src/domains/synchronization/features/__tests__/preview-synchronization.feature.test.ts similarity index 88% rename from src/domains/features/previewSynchronization.test.ts rename to src/domains/synchronization/features/__tests__/preview-synchronization.feature.test.ts index 5835616..5a798c2 100644 --- a/src/domains/features/previewSynchronization.test.ts +++ b/src/domains/synchronization/features/__tests__/preview-synchronization.feature.test.ts @@ -1,11 +1,11 @@ -import { FakeFile } from '../../../__tests__/__fakes__/fakeFile'; -import { FakeSourceRepository } from '../../../__tests__/__fakes__/fakeSource.repository'; -import { SiteMap } from '../sitemap'; -import { PreviewSynchronization } from './previewSynchronization'; +import { FileFixture } from '../../../../../__tests__/__fixtures__/file.fixture'; +import { FakeSourceRepository } from '../../../../../__tests__/__fakes__/synchronization/fake-source.repository'; +import { SiteMap } from '../../../sitemap'; +import { PreviewSynchronization } from '../preview-synchronization.feature'; describe('PreviewSynchronization', () => { let previewSync: PreviewSynchronization; - let sourceRepository: FakeSourceRepository; + let sourceRepository: FakeSourceRepository; beforeEach(() => { sourceRepository = new FakeSourceRepository(); diff --git a/src/domains/features/synchronizeMarkdownToNotion.test.ts b/src/domains/synchronization/features/__tests__/synchronize-markdown-to-notion.feature.test.ts similarity index 78% rename from src/domains/features/synchronizeMarkdownToNotion.test.ts rename to src/domains/synchronization/features/__tests__/synchronize-markdown-to-notion.feature.test.ts index c5ffe56..45b3e6b 100644 --- a/src/domains/features/synchronizeMarkdownToNotion.test.ts +++ b/src/domains/synchronization/features/__tests__/synchronize-markdown-to-notion.feature.test.ts @@ -1,15 +1,15 @@ -import { FakeFileConverter } from '../../../__tests__/__fakes__/fakeConverter.repository'; -import { FakeDestinationRepository } from '../../../__tests__/__fakes__/fakeDestination.repository'; -import { FakeFile } from '../../../__tests__/__fakes__/fakeFile'; -import { fakeLogger } from '../../../__tests__/__fakes__/fakeLogger'; -import { FakeNotionPage } from '../../../__tests__/__fakes__/fakePage'; -import { FakeSourceRepository } from '../../../__tests__/__fakes__/fakeSource.repository'; -import { PageElement, TextElement } from '../elements'; -import { SynchronizeMarkdownToNotion } from './synchronizeMarkdownToNotion'; +import { FakeFileConverter } from '../../../../../__tests__/__fakes__/elements/fake-converter.repository'; +import { FakeDestinationRepository } from '../../../../../__tests__/__fakes__/synchronization/fake-destination.repository'; +import { FileFixture } from '../../../../../__tests__/__fixtures__/file.fixture'; +import { fakeLogger } from '../../../../../__tests__/__fakes__/logger/fake-logger'; +import { FakeNotionPage } from '../../../../../__tests__/__fixtures__/page.fixture'; +import { FakeSourceRepository } from '../../../../../__tests__/__fakes__/synchronization/fake-source.repository'; +import { PageElement, TextElement } from '../../../../domains/elements'; +import { SynchronizeMarkdownToNotion } from '../synchronize-markdown-to-notion.feature'; describe('SynchronizeMarkdownToNotion', () => { let synchronizer: SynchronizeMarkdownToNotion; - let sourceRepository: FakeSourceRepository; + let sourceRepository: FakeSourceRepository; let destinationRepository: FakeDestinationRepository; let elementConverter: FakeFileConverter; @@ -38,6 +38,8 @@ describe('SynchronizeMarkdownToNotion', () => { path: 'test/path', cleanSync: false, lockPage: false, + saveId: false, + forceNew: false, }; beforeEach(() => { @@ -52,7 +54,7 @@ describe('SynchronizeMarkdownToNotion', () => { .mockResolvedValue(['file1.md']); jest .spyOn(sourceRepository, 'getFile') - .mockResolvedValue(new FakeFile({ content: '# Test' })); + .mockResolvedValue(new FileFixture({ content: '# Test' })); jest .spyOn(destinationRepository, 'createPage') .mockResolvedValue(new FakeNotionPage({ pageId: 'new-page-id' })); @@ -94,24 +96,24 @@ describe('SynchronizeMarkdownToNotion', () => { }); it('should synchronize the files correctly', async () => { - const pageElement = new PageElement({ - title: 'Test', - content: [new TextElement({ text: '# Test' })], - }); + // Return a new PageElement instance for each call to avoid shared state jest .spyOn(elementConverter, 'convertToElement') - .mockReturnValue(pageElement); + .mockImplementation(() => new PageElement({ + title: 'Test', + content: [new TextElement({ text: '# Test' })], + })); + const updatePageSpy = jest.spyOn(destinationRepository, 'updatePage'); await synchronizer.execute(defaultArgs); expect(sourceRepository.getFilePathList).toHaveBeenCalled(); expect(sourceRepository.getFile).toHaveBeenCalled(); expect(elementConverter.convertToElement).toHaveBeenCalled(); - expect(destinationRepository.createPage).toHaveBeenCalledWith({ + // Without cleanSync, the root file updates the parent page instead of creating a new one + expect(updatePageSpy).toHaveBeenCalledWith({ + pageId: '12345678901234567890123456789012', pageElement: expect.any(PageElement), - parentObjectId: '12345678901234567890123456789012', - parentObjectType: 'page', - filePath: 'file1.md', }); }); @@ -120,17 +122,22 @@ describe('SynchronizeMarkdownToNotion', () => { .spyOn(sourceRepository, 'getFilePathList') .mockResolvedValue(['parent/file1.md', 'parent/child/file2.md']); - const pageElement = new PageElement({ - title: 'Test', - content: [new TextElement({ text: '# Test' })], - }); + // Return a new PageElement instance for each call to avoid shared state jest .spyOn(elementConverter, 'convertToElement') - .mockReturnValue(pageElement); + .mockImplementation(() => new PageElement({ + title: 'Test', + content: [new TextElement({ text: '# Test' })], + })); + const updatePageSpy = jest.spyOn(destinationRepository, 'updatePage'); + const createPageSpy = jest.spyOn(destinationRepository, 'createPage'); await synchronizer.execute(defaultArgs); - expect(destinationRepository.createPage).toHaveBeenCalledTimes(2); + // Root file uses updatePage to update the parent page + expect(updatePageSpy).toHaveBeenCalledTimes(1); + // Child files use createPage - includes both files in the hierarchy + expect(createPageSpy).toHaveBeenCalledTimes(2); }); it('should prevent content duplication when root index.md exists with child directory content files', async () => { @@ -165,13 +172,13 @@ describe('SynchronizeMarkdownToNotion', () => { .mockImplementation(async (args: any) => { const path = args.path; if (path === 'index.md') { - return new FakeFile({ content: 'Welcome to the documentation' }); + return new FileFixture({ content: 'Welcome to the documentation' }); } else if (path === '01_Section/00_Root.md') { - return new FakeFile({ content: 'This is the main section' }); + return new FileFixture({ content: 'This is the main section' }); } else if (path === '01_Section/01_Subsection.md') { - return new FakeFile({ content: 'Subsection content' }); + return new FileFixture({ content: 'Subsection content' }); } - return new FakeFile({ content: 'Default content' }); + return new FileFixture({ content: 'Default content' }); }); // Mock convertToElement to return appropriate content @@ -189,14 +196,14 @@ describe('SynchronizeMarkdownToNotion', () => { return new PageElement({ title: 'Default', content: [] }); }); - const appendToPageSpy = jest.spyOn(destinationRepository, 'appendToPage'); + const updatePageSpy = jest.spyOn(destinationRepository, 'updatePage'); const createPageSpy = jest.spyOn(destinationRepository, 'createPage'); await synchronizer.execute(defaultArgs); - // Verify that appendToPage is called exactly once (only for root index.md) - expect(appendToPageSpy).toHaveBeenCalledTimes(1); - expect(appendToPageSpy).toHaveBeenCalledWith({ + // Verify that updatePage is called exactly once (for root index.md updating the parent page) + expect(updatePageSpy).toHaveBeenCalledTimes(1); + expect(updatePageSpy).toHaveBeenCalledWith({ pageId: '12345678901234567890123456789012', pageElement: rootContent, }); @@ -211,7 +218,6 @@ describe('SynchronizeMarkdownToNotion', () => { }), parentObjectId: '12345678901234567890123456789012', parentObjectType: 'page', - filePath: '01_Section/00_Root.md', }); // Verify Subsection page creation @@ -221,7 +227,6 @@ describe('SynchronizeMarkdownToNotion', () => { }), parentObjectId: 'new-page-id', // This should be the Section page ID parentObjectType: 'page', - filePath: '01_Section/01_Subsection.md', }); }); @@ -238,7 +243,7 @@ describe('SynchronizeMarkdownToNotion', () => { jest .spyOn(sourceRepository, 'getFile') - .mockResolvedValue(new FakeFile({ content: 'Welcome to the documentation' })); + .mockResolvedValue(new FileFixture({ content: 'Welcome to the documentation' })); jest .spyOn(elementConverter, 'convertToElement') @@ -271,7 +276,7 @@ describe('SynchronizeMarkdownToNotion', () => { jest .spyOn(sourceRepository, 'getFile') - .mockResolvedValue(new FakeFile({ content: 'Welcome to the documentation' })); + .mockResolvedValue(new FileFixture({ content: 'Welcome to the documentation' })); jest .spyOn(elementConverter, 'convertToElement') @@ -307,11 +312,11 @@ describe('SynchronizeMarkdownToNotion', () => { .mockImplementation(async (args: any) => { const path = args.path; if (path === '01_Section/00_Root.md') { - return new FakeFile({ content: 'This is the main section' }); + return new FileFixture({ content: 'This is the main section' }); } else if (path === '01_Section/01_Subsection.md') { - return new FakeFile({ content: 'Subsection content' }); + return new FileFixture({ content: 'Subsection content' }); } - return new FakeFile({ content: 'Default content' }); + return new FileFixture({ content: 'Default content' }); }); // Mock convertToElement to return appropriate content @@ -334,16 +339,15 @@ describe('SynchronizeMarkdownToNotion', () => { lockPage: true, }); - // Verify that setPageLockedStatus is called for each created page - expect(setPageLockedStatusSpy).toHaveBeenCalledTimes(2); - - // Verify Section page is locked + // Verify that setPageLockedStatus is called for the parent page and child pages + // Parent page gets locked after updatePage in synchronizeRootNode + // Child page gets locked after createPage in synchronizeChildNode expect(setPageLockedStatusSpy).toHaveBeenCalledWith({ - pageId: 'new-page-id', + pageId: '12345678901234567890123456789012', lockStatus: 'locked', }); - // Verify Subsection page is locked + // Verify child page is locked expect(setPageLockedStatusSpy).toHaveBeenCalledWith({ pageId: 'new-page-id', lockStatus: 'locked', @@ -381,13 +385,13 @@ describe('SynchronizeMarkdownToNotion', () => { .mockImplementation(async (args: any) => { const path = args.path; if (path === 'index.md') { - return new FakeFile({ content: 'Welcome to the documentation' }); + return new FileFixture({ content: 'Welcome to the documentation' }); } else if (path === '01_Section/00_Root.md') { - return new FakeFile({ content: 'This is the main section' }); + return new FileFixture({ content: 'This is the main section' }); } else if (path === '01_Section/01_Subsection.md') { - return new FakeFile({ content: 'Subsection content' }); + return new FileFixture({ content: 'Subsection content' }); } - return new FakeFile({ content: 'Default content' }); + return new FileFixture({ content: 'Default content' }); }); // Mock convertToElement to return appropriate content @@ -441,7 +445,7 @@ describe('SynchronizeMarkdownToNotion', () => { jest .spyOn(sourceRepository, 'getFile') - .mockResolvedValue(new FakeFile({ content: 'Welcome to the documentation' })); + .mockResolvedValue(new FileFixture({ content: 'Welcome to the documentation' })); jest .spyOn(elementConverter, 'convertToElement') diff --git a/src/domains/features/previewSynchronization.ts b/src/domains/synchronization/features/preview-synchronization.feature.ts similarity index 96% rename from src/domains/features/previewSynchronization.ts rename to src/domains/synchronization/features/preview-synchronization.feature.ts index 7ee7aa4..664f527 100644 --- a/src/domains/features/previewSynchronization.ts +++ b/src/domains/synchronization/features/preview-synchronization.feature.ts @@ -29,7 +29,7 @@ export class PreviewSynchronization { args: T, { format }: { format?: PreviewFormat; output?: string } = {} ): Promise { - // Check if the GitHub repository is accessible + // Check if the source repository is accessible try { await this.sourceRepository.sourceIsAccessible(args); } catch (err) { diff --git a/src/domains/features/synchronizeMarkdownToNotion.ts b/src/domains/synchronization/features/synchronize-markdown-to-notion.feature.ts similarity index 56% rename from src/domains/features/synchronizeMarkdownToNotion.ts rename to src/domains/synchronization/features/synchronize-markdown-to-notion.feature.ts index 2df2df7..a1d1cae 100644 --- a/src/domains/features/synchronizeMarkdownToNotion.ts +++ b/src/domains/synchronization/features/synchronize-markdown-to-notion.feature.ts @@ -29,8 +29,18 @@ export interface SynchronizeOptions { /** When true, lock the Notion page after syncing */ lockPage: boolean; + + /** When true, save the page ID to the source repository */ + saveId: boolean; + + /** When true, force a new page to be created */ + forceNew: boolean; } +export type SynchronizationResult = { + page: PageElement; + treeNodeId: string; +}; export class SynchronizeMarkdownToNotion { private sourceRepository: SourceRepository; private destinationRepository: DestinationRepository; @@ -49,7 +59,14 @@ export class SynchronizeMarkdownToNotion { notionParentPageUrl: string; } & SynchronizeOptions ): Promise { - const { notionParentPageUrl, cleanSync, lockPage, ...others } = args; + const { + notionParentPageUrl, + cleanSync, + lockPage, + saveId, + forceNew, + ...others + } = args; const notionObjectId = this.destinationRepository.getObjectIdFromObjectUrl({ objectUrl: notionParentPageUrl, @@ -92,15 +109,21 @@ export class SynchronizeMarkdownToNotion { const siteMap = SiteMap.buildFromFilePaths(filePaths); // Traverse the SiteMap and synchronize files - await this.synchronizeTreeNode({ + const pages = await this.synchronizeTreeNode({ node: siteMap.root, parentObjectId: notionObjectId, parentObjectType, lockPage, cleanSync, + forceNew, }); this.logger.info('Synchronization process completed successfully'); + + if (saveId) { + await this.executeSaveIdOperation(pages); + this.logger.info('Page IDs saved to source repository'); + } } catch (error) { if (error instanceof Error) { this.logger.error(`Synchronization process failed`, { @@ -111,24 +134,44 @@ export class SynchronizeMarkdownToNotion { } } + /** + * ------------- + * PRIVATE METHODS + * ------------- + */ + + /** + * Executes the save ID operation + */ + private async executeSaveIdOperation( + syncResult: SynchronizationResult[] + ): Promise { + const promises = syncResult.map(async (element) => { + const file = await this.elementConverter.convertFromElement(element.page); + await this.sourceRepository.updateFile(file); + }); + await Promise.all(promises); + } + /** * Fetches a file and converts it to a PageElement */ private async fetchAndConvertToPageElement( - filePath: string + filePath: string, + { forceNew }: { forceNew?: boolean } = {} ): 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'); } + if (forceNew) { + element.id = undefined; + } + return element; } @@ -148,6 +191,62 @@ export class SynchronizeMarkdownToNotion { } } + /** + * Main orchestrator for synchronizing a tree node and its children + */ + private async synchronizeTreeNode({ + node, + parentObjectId, + parentObjectType, + lockPage, + cleanSync, + forceNew, + }: { + node: TreeNode; + parentObjectId: string; + parentObjectType: ObjectType; + lockPage: boolean; + cleanSync: boolean; + forceNew: boolean; + }): Promise { + this.validateParentObjectType(parentObjectType); + + const nodeToSync = this.getNodeToSynchronize(node, parentObjectType); + + const results: SynchronizationResult[] = []; + + const { page: rootPageElement, treeNodeId: rootTreeNodeId } = + await this.synchronizeRootNode({ + node: nodeToSync, + parentObjectId, + parentObjectType, + lockPage, + cleanSync, + forceNew, + }); + + results.push({ page: rootPageElement, treeNodeId: rootTreeNodeId }); + + for (const childNode of node.children) { + try { + const childResults = await this.synchronizeChildNode({ + childNode, + parentPageId: rootPageElement.id!, + lockPage, + forceNew, + }); + results.push(...childResults); + } catch (error) { + this.logger.error(`Failed to synchronize file: ${childNode.filepath}`, { + error, + }); + throw error; + } + } + + return results; + } + /** * Synchronizes the root node to the parent object (page or database) * Returns the page ID to use as parent for child nodes @@ -158,18 +257,40 @@ export class SynchronizeMarkdownToNotion { parentObjectType, lockPage, cleanSync, + forceNew, }: { node: TreeNode; parentObjectId: string; parentObjectType: ObjectType; lockPage: boolean; cleanSync: boolean; - }): Promise { + forceNew: boolean; + }): Promise { this.logger.info( `Adding content from ${node.filepath} to parent ${parentObjectType}` ); - const pageElement = await this.fetchAndConvertToPageElement(node.filepath); + const pageElement = await this.fetchAndConvertToPageElement(node.filepath, { + forceNew, + }); + + if (pageElement.id !== undefined) { + const existingPage = await this.destinationRepository.getPage({ + pageId: pageElement.id, + }); + + if (existingPage) { + await this.destinationRepository.updatePage({ + pageElement, + pageId: pageElement.id, + }); + + return { + page: pageElement, + treeNodeId: node.id, + }; + } + } if (parentObjectType === 'unknown') { throw new Error('Parent object type is unknown'); @@ -190,24 +311,35 @@ export class SynchronizeMarkdownToNotion { { error } ); } + + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + }); + + pageElement.id = newPage.pageId; + + return { page: pageElement, treeNodeId: node.id }; } - await this.destinationRepository.appendToPage({ + const updatedPage = await this.destinationRepository.updatePage({ pageId: parentObjectId, pageElement, }); - this.logger.info(`Added content from ${node.filepath} to parent page`); + pageElement.id = updatedPage.pageId; + + this.logger.info(`Updated parent page ${parentObjectId}`); await this.lockPageIfNeeded(parentObjectId, lockPage); - return parentObjectId; + return { page: pageElement, treeNodeId: node.id }; } if (cleanSync) { - await this.cleanSyncDatabase({ - databaseId: parentObjectId, - pageElement, + await this.destinationRepository.deleteChildBlocks({ + parentPageId: parentObjectId, }); } @@ -216,61 +348,15 @@ export class SynchronizeMarkdownToNotion { pageElement, parentObjectId, parentObjectType, - filePath: node.filepath, }); if (!newPage.pageId) { throw new Error('New page ID is undefined'); } - return newPage.pageId; - } + pageElement.id = 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, - }) - ) - ); + return { page: pageElement, treeNodeId: node.id }; } /** * Synchronizes a child node and its descendants recursively @@ -279,121 +365,107 @@ export class SynchronizeMarkdownToNotion { childNode, parentPageId, lockPage, + forceNew, }: { childNode: TreeNode; parentPageId: string; lockPage: boolean; - }): Promise { + forceNew: boolean; + }): Promise { + const syncResult: SynchronizationResult[] = []; const filePath = childNode.filepath; this.logger.info(`Processing file: ${filePath}`); - const pageElement = await this.fetchAndConvertToPageElement(filePath); + const pageElement = await this.fetchAndConvertToPageElement(filePath, { + forceNew, + }); - // Add standard elements at the beginning (in reverse order) - pageElement.addElementToBeginning(new TableOfContentsElement()); - pageElement.addElementToBeginning(new DividerElement()); + if (pageElement.id !== undefined) { + await this.destinationRepository.updatePage({ + pageId: pageElement.id, + pageElement, + }); + } else { + // Add standard elements at the beginning (in reverse order) + pageElement.addElementToBeginning(new TableOfContentsElement()); + pageElement.addElementToBeginning(new DividerElement()); - if (childNode.children.length > 0) { - pageElement.addElementToEnd(new DividerElement()); - } + if (childNode.children.length > 0) { + pageElement.addElementToEnd(new DividerElement()); + } - const newPage = await this.destinationRepository.createPage({ - pageElement, - parentObjectId: parentPageId, - parentObjectType: 'page', - filePath, - }); + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId: parentPageId, + parentObjectType: 'page', + }); - this.logger.info(`Created Notion page for file: ${filePath}`); + this.logger.info(`Created Notion page for file: ${filePath}`); - if (!newPage.pageId) { - throw new Error('Page ID is undefined'); + if (!newPage.pageId) { + throw new Error('Page ID is undefined'); + } + + pageElement.id = newPage.pageId; } + syncResult.push({ + page: pageElement, + treeNodeId: childNode.id, + }); + // Recursively process children for (const grandChild of childNode.children) { - await this.synchronizeChildNode({ + const grandChildSyncResult = await this.synchronizeChildNode({ childNode: grandChild, - parentPageId: newPage.pageId, + parentPageId: pageElement.id, lockPage, + forceNew, }); + syncResult.push(...grandChildSyncResult); } - await this.lockPageIfNeeded(newPage.pageId, lockPage); + await this.lockPageIfNeeded(pageElement.id, lockPage); + + return syncResult; + } + + private getIsRootNode(node: TreeNode): boolean { + return node.parent === null && !['', undefined].includes(node.filepath); } /** - * Main orchestrator for synchronizing a tree node and its children + * Validates that the parent object type is supported for synchronization */ - private async synchronizeTreeNode({ - node, - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }: { - node: TreeNode; - parentObjectId: string; - parentObjectType: ObjectType; - lockPage: boolean; - cleanSync: 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, - cleanSync, - }); - } else { - parentPageId = await this.synchronizeRootNode({ - node: node.children[0], - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - } - break; - case 'page': - if (this.getIsRootNode(node)) { - parentPageId = await this.synchronizeRootNode({ - node, - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - } - break; - default: - throw new Error('Invalid parent object type'); + private validateParentObjectType(parentObjectType: ObjectType): void { + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); } - 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; - } + if (!['database', 'page'].includes(parentObjectType)) { + throw new Error(`Invalid parent object type: ${parentObjectType}`); } } - private getIsRootNode(node: TreeNode): boolean { - return node.parent === null && !['', undefined].includes(node.filepath); + /** + * Determines the effective node to synchronize as root based on parent type + * For database parents with non-root nodes, uses the first child + * For page parents, returns null if the node is not a root node + */ + private getNodeToSynchronize( + node: TreeNode, + parentObjectType: ObjectType + ): TreeNode { + const isRootNode = this.getIsRootNode(node); + + if (parentObjectType === 'database' && !isRootNode) { + return node.children[0]; + } + + if (parentObjectType === 'page' && !isRootNode) { + return node.children[0]; + } + + return node; } } diff --git a/src/domains/synchronization/index.ts b/src/domains/synchronization/index.ts index e84ffbc..9570958 100644 --- a/src/domains/synchronization/index.ts +++ b/src/domains/synchronization/index.ts @@ -1,2 +1,2 @@ -export * from './destination.repository'; -export * from './source.repository'; +export * from './repositories/destination.repository'; +export * from './repositories/source.repository'; diff --git a/src/domains/synchronization/destination.repository.ts b/src/domains/synchronization/repositories/destination.repository.ts similarity index 77% rename from src/domains/synchronization/destination.repository.ts rename to src/domains/synchronization/repositories/destination.repository.ts index e7a34a0..5faf993 100644 --- a/src/domains/synchronization/destination.repository.ts +++ b/src/domains/synchronization/repositories/destination.repository.ts @@ -11,25 +11,22 @@ export type PageLockedStatus = 'locked' | 'unlocked'; export type ObjectType = 'page' | 'database' | 'unknown'; export interface DestinationRepository { + getPage: ({ pageId }: { pageId: string }) => Promise; createPage: ({ pageElement, parentObjectId, parentObjectType, - filePath, }: { pageElement: PageElement; parentObjectId: string; parentObjectType: ObjectType; - filePath?: string; }) => Promise; updatePage: ({ pageId, pageElement, - filePath, }: { pageId: string; pageElement: PageElement; - filePath?: string; }) => Promise; destinationIsAccessible: ({ parentObjectId, @@ -42,7 +39,6 @@ export interface DestinationRepository { }: { parentPageId: string; }) => Promise; - deleteObjectById: ({ objectId }: { objectId: string }) => Promise; appendToPage: ({ pageId, pageElement, @@ -70,16 +66,4 @@ 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/domains/synchronization/repositories/source.repository.ts b/src/domains/synchronization/repositories/source.repository.ts new file mode 100644 index 0000000..545bba4 --- /dev/null +++ b/src/domains/synchronization/repositories/source.repository.ts @@ -0,0 +1,40 @@ +import { SupportedEmoji } from '@/domains/elements/types'; + +export type FileContent = string; + +export class File { + name: string; + icon?: SupportedEmoji; + content: FileContent; + path: string; + lastUpdated: Date; + extension: string; + + constructor({ + name, + content, + path, + lastUpdated, + extension, + }: { + name: string; + icon?: SupportedEmoji; + content: FileContent; + path: string; + lastUpdated: Date; + extension: string; + }) { + this.name = name; + this.content = content; + this.path = path; + this.lastUpdated = lastUpdated; + this.extension = extension; + } +} + +export interface SourceRepository { + getFilePathList: (args: T) => Promise; + getFile: (args: T) => Promise; + updateFile: (args: File) => Promise; + sourceIsAccessible: (args: T) => Promise; +} diff --git a/src/domains/synchronization/source.repository.ts b/src/domains/synchronization/source.repository.ts deleted file mode 100644 index 05f8c12..0000000 --- a/src/domains/synchronization/source.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SupportedEmoji } from '@/domains/elements/types'; - -export type FileContent = string; - -export interface File { - name: string; - icon?: SupportedEmoji; - content: FileContent; - lastUpdated: Date; - extension: string; -} - -export interface SourceRepository { - getFilePathList: (args: T) => Promise; - getFile: (args: T) => Promise; - sourceIsAccessible: (args: T) => Promise; -} diff --git a/src/index.ts b/src/index.ts index f5b0774..5f439fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import { MkNotes } from './MkNotes'; export type * from '@/domains'; -// eslint-disable-next-line import/no-default-export export default MkNotes; diff --git a/src/infrastructure/converters/file/__tests__/file.converter.test.ts b/src/infrastructure/converters/file/__tests__/file.converter.test.ts new file mode 100644 index 0000000..a4da134 --- /dev/null +++ b/src/infrastructure/converters/file/__tests__/file.converter.test.ts @@ -0,0 +1,139 @@ +import { FileConverter } from '../file.converter'; +import { PageElement, Element, TextElement, DividerElement, TableOfContentsElement } from '@/domains/elements'; +import { SupportedEmoji } from '@/domains/elements/types'; +import { FakeParserRepository, FakeMarkdownParser } from '../../../../../__tests__/__fakes__/elements/fake-parser.repository'; +import { fakeLogger } from '../../../../../__tests__/__fakes__/logger/fake-logger'; +import { FileFixture } from '../../../../../__tests__/__fixtures__/file.fixture'; + +describe('FileConverter', () => { + let fileConverter: FileConverter; + let htmlParser: FakeParserRepository; + let markdownParser: FakeMarkdownParser; + + beforeEach(() => { + htmlParser = new FakeParserRepository({ logger: fakeLogger }); + markdownParser = new FakeMarkdownParser({ logger: fakeLogger, htmlParser }); + + fileConverter = new FileConverter({ + htmlParser, + markdownParser, + logger: fakeLogger, + }); + }); + + describe('convertToElement', () => { + it('should convert a markdown file to a PageElement', () => { + const file = new FileFixture({ + name: 'test.md', + extension: 'md', + content: '# Test Content', + lastUpdated: new Date(), + }); + + const mockParseResult = { + title: 'Parsed Title', + content: [new TextElement({text: 'Test Content'})] as Element[], + icon: '📝' as SupportedEmoji, + }; + + jest.spyOn(markdownParser, 'parse').mockReturnValue(mockParseResult); + + const result = fileConverter.convertToElement(file); + + expect(result).toBeInstanceOf(PageElement); + expect(markdownParser.parse).toHaveBeenCalledWith({ filepath: file.path, content: file.content }); + expect(result).toMatchObject({ + title: mockParseResult.title, + content: mockParseResult.content, + icon: mockParseResult.icon, + source: file, + }); + }); + + it('should convert an HTML file to a PageElement', () => { + const file = new FileFixture({ + name: 'test.html', + extension: 'html', + content: '

Test Content

', + lastUpdated: new Date(), + }); + + const mockParseResult = { + title: 'Parsed Title', + content: [new TextElement({text: 'Test Content'})] as Element[], + icon: '🌐' as SupportedEmoji, + }; + + jest.spyOn(htmlParser, 'parse').mockReturnValue(mockParseResult); + + const result = fileConverter.convertToElement(file); + + expect(result).toBeInstanceOf(PageElement); + expect(htmlParser.parse).toHaveBeenCalledWith({ filepath: file.path, content: file.content }); + expect(result).toMatchObject({ + title: mockParseResult.title, + content: mockParseResult.content, + icon: mockParseResult.icon, + source: file, + }); + }); + + it('should throw an error for an unsupported file extension', () => { + const file = new FileFixture({ + name: 'test.txt', + extension: 'txt', + content: 'Test Content', + lastUpdated: new Date(), + }); + + expect(() => fileConverter.convertToElement(file)).toThrow('File extension not supported'); + }); + + it('should use the file title as the page title', () => { + const file = new FileFixture({ + name: 'Custom Title.md', + extension: 'md', + content: '# Different Title', + lastUpdated: new Date(), + }); + + const mockParseResult = { + title: 'Parsed Title', + content: [new TextElement({text: 'Test Content'})] as Element[], + icon: '📝' as SupportedEmoji, + }; + + jest.spyOn(markdownParser, 'parse').mockReturnValue(mockParseResult); + + const result = fileConverter.convertToElement(file); + + expect(result.title).toBe('Parsed Title'); + }); + + it('should preserve the parser metadata in the PageElement', () => { + const file = new FileFixture({ + name: 'test.md', + extension: 'md', + content: '# Test Content', + lastUpdated: new Date(), + }); + + const mockParseResult = { + title: 'Parsed Title', + content: [ + new TextElement({text: 'Test Content'}), + new DividerElement(), + new TableOfContentsElement(), + ] as Element[], + icon: '📚' as SupportedEmoji, + }; + + jest.spyOn(markdownParser, 'parse').mockReturnValue(mockParseResult); + + const result = fileConverter.convertToElement(file); + + expect(result.content).toEqual(mockParseResult.content); + expect(result.icon).toBe(mockParseResult.icon); + }); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/filesystem/file.converter.test.ts b/src/infrastructure/converters/file/file.converter.test.ts similarity index 78% rename from src/infrastructure/filesystem/file.converter.test.ts rename to src/infrastructure/converters/file/file.converter.test.ts index 91dd70c..28f9324 100644 --- a/src/infrastructure/filesystem/file.converter.test.ts +++ b/src/infrastructure/converters/file/file.converter.test.ts @@ -1,9 +1,9 @@ import { FileConverter } from './file.converter'; import { PageElement, Element, TextElement, DividerElement, TableOfContentsElement } from '@/domains/elements'; import { SupportedEmoji } from '@/domains/elements/types'; -import { FakeParserRepository, FakeMarkdownParser } from '../../../__tests__/__fakes__/fakeParser.repository'; -import { fakeLogger } from '../../../__tests__/__fakes__/fakeLogger'; -import { FakeFile } from '../../../__tests__/__fakes__/fakeFile'; +import { FakeParserRepository, FakeMarkdownParser } from '../../../../__tests__/__fakes__/elements/fake-parser.repository'; +import { fakeLogger } from '../../../../__tests__/__fakes__/logger/fake-logger'; +import { FileFixture } from '../../../../__tests__/__fixtures__/file.fixture'; describe('FileConverter', () => { let fileConverter: FileConverter; @@ -23,7 +23,7 @@ describe('FileConverter', () => { describe('convertToElement', () => { it('should convert a markdown file to a PageElement', () => { - const file = new FakeFile({ + const file = new FileFixture({ name: 'test.md', extension: 'md', content: '# Test Content', @@ -41,18 +41,17 @@ describe('FileConverter', () => { const result = fileConverter.convertToElement(file); expect(result).toBeInstanceOf(PageElement); - expect(markdownParser.parse).toHaveBeenCalledWith({ content: file.content }); - expect(result).toEqual( - new PageElement({ - title: mockParseResult.title, - content: mockParseResult.content, - icon: mockParseResult.icon, - }) - ); + expect(markdownParser.parse).toHaveBeenCalledWith({ filepath: file.path, content: file.content }); + expect(result).toMatchObject({ + title: mockParseResult.title, + content: mockParseResult.content, + icon: mockParseResult.icon, + source: file, + }); }); it('should convert an HTML file to a PageElement', () => { - const file = new FakeFile({ + const file = new FileFixture({ name: 'test.html', extension: 'html', content: '

Test Content

', @@ -70,18 +69,17 @@ describe('FileConverter', () => { const result = fileConverter.convertToElement(file); expect(result).toBeInstanceOf(PageElement); - expect(htmlParser.parse).toHaveBeenCalledWith({ content: file.content }); - expect(result).toEqual( - new PageElement({ - title: mockParseResult.title, - content: mockParseResult.content, - icon: mockParseResult.icon, - }) - ); + expect(htmlParser.parse).toHaveBeenCalledWith({ filepath: file.path, content: file.content }); + expect(result).toMatchObject({ + title: mockParseResult.title, + content: mockParseResult.content, + icon: mockParseResult.icon, + source: file, + }); }); it('should throw an error for an unsupported file extension', () => { - const file = new FakeFile({ + const file = new FileFixture({ name: 'test.txt', extension: 'txt', content: 'Test Content', @@ -92,7 +90,7 @@ describe('FileConverter', () => { }); it('should use the file title as the page title', () => { - const file = new FakeFile({ + const file = new FileFixture({ name: 'Custom Title.md', extension: 'md', content: '# Different Title', @@ -113,7 +111,7 @@ describe('FileConverter', () => { }); it('should preserve the parser metadata in the PageElement', () => { - const file = new FakeFile({ + const file = new FileFixture({ name: 'test.md', extension: 'md', content: '# Test Content', diff --git a/src/infrastructure/converters/file/file.converter.ts b/src/infrastructure/converters/file/file.converter.ts new file mode 100644 index 0000000..c6c2564 --- /dev/null +++ b/src/infrastructure/converters/file/file.converter.ts @@ -0,0 +1,181 @@ +import { Logger } from 'winston'; + +import { + Element, + ElementConverterRepository, + PageElement, + PageElementProperties, + PageElementPropertyValue, + ParserRepository, + SupportedEmoji, +} from '@/domains/elements'; +import { File } from '@/domains/synchronization'; +import type { HtmlParser } from '@/infrastructure/parsers/html/html.parser'; +import type { MarkdownParser } from '@/infrastructure/parsers/markdown/markdown.parser'; + +export class FileConverter + implements ElementConverterRepository +{ + private htmlParser: HtmlParser; + private markdownParser: MarkdownParser; + private logger: Logger; + + constructor({ + htmlParser, + markdownParser, + logger, + }: { + htmlParser: HtmlParser; + markdownParser: MarkdownParser; + logger: Logger; + }) { + this.htmlParser = htmlParser; + this.markdownParser = markdownParser; + this.logger = logger; + } + + public convertToElement(file: File): PageElement { + const { content } = file; + + const args: { + title: string; + content: Element[]; + icon: SupportedEmoji | undefined; + } = { + title: file.name, + content: [], + icon: undefined, + }; + + let parser: ParserRepository | null = null; + + if (file.extension === 'md') { + parser = this.markdownParser; + } + + if (file.extension === 'html') { + parser = this.htmlParser; + } + + if (!parser) { + throw new Error('File extension not supported'); + } + + const result = parser.parse({ content, filepath: file.path }); + + return new PageElement({ + ...args, + ...result, + source: file, + }); + } + + public convertFromElement(pageElement: PageElement): File { + if (!pageElement.source || !(pageElement.source instanceof File)) { + throw new Error( + 'Filepath is required to convert from PageElement to File' + ); + } + + return new File({ + name: pageElement.title, + extension: pageElement.source.extension, + content: [ + this.getFrontmatterString(pageElement), + this.removeFrontmatterFromContent(pageElement.source.content), + ].join('\n'), + lastUpdated: pageElement.source.lastUpdated, + path: pageElement.source.path, + }); + } + + private getFrontmatterString(pageElement: PageElement): string { + const frontmatter: string[] = ['---']; + + if (pageElement.id) { + frontmatter.push(`id: ${pageElement.id}`); + } + + if (pageElement.title) { + frontmatter.push(`title: ${pageElement.title}`); + } + + if (pageElement.icon) { + frontmatter.push(`icon: ${pageElement.icon}`); + } + + if (pageElement.properties) { + frontmatter.push('properties:'); + frontmatter.push( + this.getPageElementPropertiesString(pageElement.properties) + ); + } + + frontmatter.push('---'); + + return frontmatter.join('\n'); + } + + private getPageElementPropertiesString( + properties: PageElementProperties[] + ): string { + const propertiesString: string[] = []; + + if (!properties) { + return ''; + } + + properties.forEach((property) => { + propertiesString.push( + ...[ + ` - name: ${property.name}`, + ` value: ${this.getPropertyValueString(property.value)}`, + ] + ); + }); + + return propertiesString.join('\n'); + } + + private getPropertyValueString(value: PageElementPropertyValue): string { + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number') { + return value.toString(); + } + + if (typeof value === 'boolean') { + return value.toString(); + } + + if (Array.isArray(value)) { + return this.getPropertyValueStringArray(value); + } + + if (value === null) { + return 'null'; + } + + if (typeof value === 'undefined') { + return 'undefined'; + } + + throw new Error(`Unsupported property value type: ${typeof value}`); + } + + private getPropertyValueStringArray( + value: PageElementPropertyValue[] + ): string { + return [ + `[`, + value.map((v) => this.getPropertyValueString(v)).join(','), + `]`, + ].join(''); + } + + private removeFrontmatterFromContent(content: string): string { + return content.replace(/^-{3,}\n.*?\n-{3,}/s, '').trim(); + } +} diff --git a/src/infrastructure/notion/notion.converter.test.ts b/src/infrastructure/converters/notion/__tests__/notion.converter.test.ts similarity index 99% rename from src/infrastructure/notion/notion.converter.test.ts rename to src/infrastructure/converters/notion/__tests__/notion.converter.test.ts index 01fceb2..1aa4485 100644 --- a/src/infrastructure/notion/notion.converter.test.ts +++ b/src/infrastructure/converters/notion/__tests__/notion.converter.test.ts @@ -1,4 +1,4 @@ -import { NotionConverterRepository } from './notion.converter'; +import { NotionConverterRepository } from '../notion.converter'; import { CalloutElement, CodeElement, diff --git a/src/infrastructure/notion/notion.converter.ts b/src/infrastructure/converters/notion/notion.converter.ts similarity index 96% rename from src/infrastructure/notion/notion.converter.ts rename to src/infrastructure/converters/notion/notion.converter.ts index bae8dab..43096b9 100644 --- a/src/infrastructure/notion/notion.converter.ts +++ b/src/infrastructure/converters/notion/notion.converter.ts @@ -23,9 +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 { NotionPage } from '@/domains/notion/entities/NotionPage'; import { BlockObjectRequest, BlockObjectRequestWithoutChildren, @@ -59,8 +57,8 @@ import { TitleProperty, ToggleBlock, UrlProperty, -} from '../../domains/notion/types/types'; -import { NotionFileUploadService } from './file-upload.service'; +} from '@/domains/notion/types/types'; +import { NotionFileUploadService } from '@/infrastructure/destinations/notion/file-upload.service'; type PartialCreatePageBodyParameters = Pick< CreatePageBodyParameters, @@ -83,7 +81,6 @@ export class NotionConverterRepository { private logger: Logger; private fileUploadService?: NotionFileUploadService; - private currentFilePath?: string; private basePath?: string; constructor({ @@ -97,10 +94,6 @@ export class NotionConverterRepository this.fileUploadService = fileUploadService; } - setCurrentFilePath(filePath: string): void { - this.currentFilePath = filePath; - } - setBasePath(basePath: string): void { this.basePath = basePath; } @@ -552,26 +545,14 @@ 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, + ...this.convertPageElementProperties( + element.properties, + notionPropertyDefinitions + ), }, }; @@ -938,16 +919,10 @@ export class NotionConverterRepository try { // Determine the base path for resolving relative image paths - // Prefer the filepath from the ImageElement, fallback to currentFilePath, then basePath - let imageBasePath: string | undefined; - - if (element.filepath) { - imageBasePath = path.dirname(element.filepath); - } else if (this.currentFilePath) { - imageBasePath = path.dirname(this.currentFilePath); - } else { - imageBasePath = this.basePath; - } + // Use the filepath from the ImageElement (set during parsing), fallback to basePath + const imageBasePath = element.filepath + ? path.dirname(element.filepath) + : this.basePath; this.logger.info(`Uploading local image: ${element.url}`); diff --git a/src/infrastructure/notion/file-upload.service.test.ts b/src/infrastructure/destinations/notion/__tests__/file-upload.service.test.ts similarity index 99% rename from src/infrastructure/notion/file-upload.service.test.ts rename to src/infrastructure/destinations/notion/__tests__/file-upload.service.test.ts index 6bbd2ac..450327c 100644 --- a/src/infrastructure/notion/file-upload.service.test.ts +++ b/src/infrastructure/destinations/notion/__tests__/file-upload.service.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import fetch from 'node-fetch'; import { Logger } from 'winston'; -import { NotionFileUploadService } from './file-upload.service'; +import { NotionFileUploadService } from '../file-upload.service'; // Mock dependencies jest.mock('@notionhq/client'); diff --git a/src/infrastructure/destinations/notion/__tests__/notion.destination.test.ts b/src/infrastructure/destinations/notion/__tests__/notion.destination.test.ts new file mode 100644 index 0000000..ad795db --- /dev/null +++ b/src/infrastructure/destinations/notion/__tests__/notion.destination.test.ts @@ -0,0 +1,441 @@ +import { NotionDestinationRepository } from '../notion.destination'; +import { NotionConverterRepository } from '@/infrastructure/converters/notion/notion.converter'; +import { PageElement } from '@/domains/elements'; +import { NotionPage } from '@/domains/notion/entities/NotionPage'; +import { NotionClientRepository } from '@/domains/notion/repositories/notion-client.repository'; +import winston from 'winston'; + +describe('NotionDestinationRepository', () => { + let repository: NotionDestinationRepository; + let mockNotionClient: jest.Mocked; + let mockNotionConverter: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + } as unknown as jest.Mocked; + + mockNotionClient = { + getPage: jest.fn(), + getPageBlocks: jest.fn(), + createPage: jest.fn(), + updatePage: jest.fn(), + deletePage: jest.fn(), + getBlockChildren: jest.fn(), + appendChildToBlock: jest.fn(), + deleteBlock: jest.fn(), + deleteBlocks: jest.fn(), + getBlock: jest.fn(), + updateBlock: jest.fn(), + getDatabaseById: jest.fn(), + getDataSourceById: jest.fn(), + getDataSourceIdFromDatabaseId: jest.fn(), + search: jest.fn(), + } as unknown as jest.Mocked; + + mockNotionConverter = { + convertFromElement: jest.fn(), + } as unknown as jest.Mocked; + + repository = new NotionDestinationRepository({ + notionClient: mockNotionClient, + notionConverter: mockNotionConverter, + logger: mockLogger, + }); + }); + + describe('destinationIsAccessible', () => { + it('should return true when page is accessible', async () => { + mockNotionClient.getPage.mockResolvedValue({ pageId: 'page-id' } as NotionPage); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'page-id', + }); + + expect(result).toBe(true); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ + pageId: 'page-id', + }); + }); + + it('should return false when page is not accessible', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockRejectedValue(new Error('Not found')); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'invalid-id', + }); + + expect(result).toBe(false); + }); + }); + + describe('getPage', () => { + it('should retrieve page with blocks', async () => { + const mockPage = new NotionPage({ + pageId: 'page-id', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + isLocked: false, + children: [], + }); + const mockBlocks = [{ id: 'block-1' }, { id: 'block-2' }]; + + mockNotionClient.getPage.mockResolvedValue(mockPage); + mockNotionClient.getPageBlocks.mockResolvedValue(mockBlocks as any); + + const result = await repository.getPage({ + pageId: 'page-id', + }); + + expect(result).toBeDefined(); + expect(result?.pageId).toBe('page-id'); + expect(result?.children).toEqual(mockBlocks); + }); + + it('should return null for non-existent page', async () => { + mockNotionClient.getPage.mockResolvedValue(null); + + const result = await repository.getPage({ pageId: 'invalid-id' }); + + expect(result).toBeNull(); + }); + }); + + describe('createPage', () => { + it('should create page with converted content when parent is a page', async () => { + const pageElement = new PageElement({ + title: 'Test Page', + content: [], + }); + + const mockNotionPage = new NotionPage({ + pageId: 'new-page-id', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + isLocked: false, + children: [], + properties: { Title: { title: [{ text: { content: 'Test Page' } }] } } as any, + }); + + mockNotionConverter.convertFromElement.mockResolvedValue(mockNotionPage); + mockNotionClient.createPage.mockResolvedValue(mockNotionPage); + mockNotionClient.getPage.mockResolvedValue(mockNotionPage); + mockNotionClient.getPageBlocks.mockResolvedValue([]); + + const result = await repository.createPage({ + parentObjectId: 'parent-id', + parentObjectType: 'page', + pageElement, + }); + + expect(mockNotionClient.createPage).toHaveBeenCalledWith({ + parent: { type: 'page_id', page_id: 'parent-id' }, + properties: mockNotionPage.properties, + icon: undefined, + children: [], + }); + + expect(result).toMatchObject({ + pageId: 'new-page-id', + 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', () => { + // it('should update page properties and blocks', async () => { + // const pageElement = new PageElement({ + // title: 'Updated Page', + // content: [], + // }); + + // const mockNotionPage = { + // properties: { + // Title: { title: [{ text: { content: 'Updated Page' } }] }, + // }, + // children: [{ id: 'block-1', type: 'paragraph' }], + // }; + + // const mockExistingBlocks = [ + // { id: 'existing-block-1', type: 'paragraph' }, + // ]; + + // jest.spyOn(mockNotionConverter,'convertFromElement').mockReturnValue(mockNotionPage as unknown as NotionPage); + // jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue({ + // results: mockExistingBlocks as unknown as BlockObjectResponse[], + // type: 'block', + // block: mockExistingBlocks[0], + // object: 'list', + // next_cursor: null, + // has_more: false, + // }); + // jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue({ + // id: 'page-id', + // object: 'page', + // created_time: '2024-01-01T00:00:00.000Z', + // last_edited_time: '2024-01-02T00:00:00.000Z', + // }); + + // await repository.updatePage({ + // pageId: 'page-id', + // pageElement, + // }); + + // expect(mockClient.pages.update).toHaveBeenCalledWith({ + // page_id: 'page-id', + // properties: mockNotionPage.properties, + // }); + + // expect(mockClient.blocks.update).toHaveBeenCalled(); + // }); + // }); + + // Methods like search, getBlocksFromPage have been moved to NotionClientRepository + // and are tested there; + + 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 }); + 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.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.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); + expect(result).toBe('12345678901234567890123456789012'); + }); + + 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: No valid Notion ID found'); + }); + + 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: No valid Notion ID found'); + }); + + 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: No valid Notion ID found'); + }); + + it('should extract the database ID from a Notion Database URL', () => { + const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?v=7d43042815524daa9c5c3a7a4f8e1fe4&pvs=4'; + 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', () => { + const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?pvs=4'; + 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', () => { + it('should lock a page when lockStatus is "locked"', async () => { + const pageId = 'test-page-id'; + const lockStatus = 'locked' as const; + + mockNotionClient.updatePage.mockResolvedValue({} as any); + + await repository.setPageLockedStatus({ pageId, lockStatus }); + + expect(mockNotionClient.updatePage).toHaveBeenCalledWith({ + pageId, + isLocked: true, + }); + }); + + it('should unlock a page when lockStatus is "unlocked"', async () => { + const pageId = 'test-page-id'; + const lockStatus = 'unlocked' as const; + + mockNotionClient.updatePage.mockResolvedValue({} as any); + + await repository.setPageLockedStatus({ pageId, lockStatus }); + + expect(mockNotionClient.updatePage).toHaveBeenCalledWith({ + pageId, + isLocked: false, + }); + }); + + it('should throw an error when the Notion API call fails', async () => { + const pageId = 'test-page-id'; + const lockStatus = 'locked' as const; + const error = new Error('Notion API error'); + + mockNotionClient.updatePage.mockRejectedValue(error); + + await expect(repository.setPageLockedStatus({ pageId, lockStatus })).rejects.toThrow('Notion API error'); + }); + }); + + describe('getPageLockedStatus', () => { + it('should return "locked" when page is locked', async () => { + const pageId = 'test-page-id'; + const mockPage = new NotionPage({ + pageId, + createdAt: new Date(), + updatedAt: new Date(), + isLocked: true, + children: [], + }); + + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getPageLockedStatus({ pageId }); + + expect(result).toBe('locked'); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ pageId }); + }); + + it('should return "unlocked" when page is unlocked', async () => { + const pageId = 'test-page-id'; + const mockPage = new NotionPage({ + pageId, + createdAt: new Date(), + updatedAt: new Date(), + isLocked: false, + children: [], + }); + + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getPageLockedStatus({ pageId }); + + expect(result).toBe('unlocked'); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ pageId }); + }); + + it('should return "unlocked" when isLocked is undefined', async () => { + const pageId = 'test-page-id'; + const mockPage = new NotionPage({ + pageId, + createdAt: new Date(), + updatedAt: new Date(), + children: [], + }); + + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getPageLockedStatus({ pageId }); + + expect(result).toBe('unlocked'); + }); + + it('should throw an error when the Notion API call fails', async () => { + const pageId = 'test-page-id'; + const error = new Error('Notion API error'); + + mockNotionClient.getPage.mockRejectedValue(error); + + await expect(repository.getPageLockedStatus({ pageId })).rejects.toThrow('Notion API error'); + }); + }); + + describe('getObjectType', () => { + it('should return "page" when the object is a page', async () => { + const mockPage = new NotionPage({ + pageId: 'page-id', + createdAt: new Date(), + updatedAt: new Date(), + isLocked: false, + children: [], + }); + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getObjectType({ id: 'page-id' }); + + expect(result).toBe('page'); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ + pageId: 'page-id', + }); + }); + + it('should return "database" when the object is a database', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockResolvedValue({ + id: 'database-id', + object: 'database', + } as any); + + const result = await repository.getObjectType({ id: 'database-id' }); + + expect(result).toBe('database'); + expect(mockNotionClient.getDatabaseById).toHaveBeenCalledWith({ + databaseId: 'database-id', + }); + }); + + it('should return "unknown" when the object is neither a page nor a database', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockRejectedValue(new Error('Not found')); + + const result = await repository.getObjectType({ id: 'invalid-id' }); + + expect(result).toBe('unknown'); + }); + }); + + // Methods like getDatabaseById, deleteObjectById, getDataSourceIdFromDatabaseId, + // getObjectIdInDatabaseByMkNotesInternalId have been moved to NotionClientRepository + + describe('destinationIsAccessible with database support', () => { + it('should return true when database is accessible', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.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 () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockRejectedValue(new Error('Not found')); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'invalid-id', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/infrastructure/notion/file-upload.service.ts b/src/infrastructure/destinations/notion/file-upload.service.ts similarity index 99% rename from src/infrastructure/notion/file-upload.service.ts rename to src/infrastructure/destinations/notion/file-upload.service.ts index c3b3f1a..ee3dc8c 100644 --- a/src/infrastructure/notion/file-upload.service.ts +++ b/src/infrastructure/destinations/notion/file-upload.service.ts @@ -180,8 +180,6 @@ export class NotionFileUploadService { // Step 2: Send file content await this.sendFileContent(fileUpload.upload_url, resolvedPath); - // No step 3 needed - file is ready to use after step 2 - this.logger.info( `Successfully uploaded file: ${fileName} with ID: ${fileUpload.id}` ); diff --git a/src/infrastructure/destinations/notion/notion.destination.test.ts b/src/infrastructure/destinations/notion/notion.destination.test.ts new file mode 100644 index 0000000..a368514 --- /dev/null +++ b/src/infrastructure/destinations/notion/notion.destination.test.ts @@ -0,0 +1,441 @@ +import { NotionDestinationRepository } from './notion.destination'; +import { NotionConverterRepository } from '@/infrastructure/converters/notion/notion.converter'; +import { PageElement } from '@/domains/elements'; +import { NotionPage } from '@/domains/notion/entities/NotionPage'; +import { NotionClientRepository } from '@/domains/notion/repositories/notion-client.repository'; +import winston from 'winston'; + +describe('NotionDestinationRepository', () => { + let repository: NotionDestinationRepository; + let mockNotionClient: jest.Mocked; + let mockNotionConverter: jest.Mocked; + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + } as unknown as jest.Mocked; + + mockNotionClient = { + getPage: jest.fn(), + getPageBlocks: jest.fn(), + createPage: jest.fn(), + updatePage: jest.fn(), + deletePage: jest.fn(), + getBlockChildren: jest.fn(), + appendChildToBlock: jest.fn(), + deleteBlock: jest.fn(), + deleteBlocks: jest.fn(), + getBlock: jest.fn(), + updateBlock: jest.fn(), + getDatabaseById: jest.fn(), + getDataSourceById: jest.fn(), + getDataSourceIdFromDatabaseId: jest.fn(), + search: jest.fn(), + } as unknown as jest.Mocked; + + mockNotionConverter = { + convertFromElement: jest.fn(), + } as unknown as jest.Mocked; + + repository = new NotionDestinationRepository({ + notionClient: mockNotionClient, + notionConverter: mockNotionConverter, + logger: mockLogger, + }); + }); + + describe('destinationIsAccessible', () => { + it('should return true when page is accessible', async () => { + mockNotionClient.getPage.mockResolvedValue({ pageId: 'page-id' } as NotionPage); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'page-id', + }); + + expect(result).toBe(true); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ + pageId: 'page-id', + }); + }); + + it('should return false when page is not accessible', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockRejectedValue(new Error('Not found')); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'invalid-id', + }); + + expect(result).toBe(false); + }); + }); + + describe('getPage', () => { + it('should retrieve page with blocks', async () => { + const mockPage = new NotionPage({ + pageId: 'page-id', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + isLocked: false, + children: [], + }); + const mockBlocks = [{ id: 'block-1' }, { id: 'block-2' }]; + + mockNotionClient.getPage.mockResolvedValue(mockPage); + mockNotionClient.getPageBlocks.mockResolvedValue(mockBlocks as any); + + const result = await repository.getPage({ + pageId: 'page-id', + }); + + expect(result).toBeDefined(); + expect(result?.pageId).toBe('page-id'); + expect(result?.children).toEqual(mockBlocks); + }); + + it('should return null for non-existent page', async () => { + mockNotionClient.getPage.mockResolvedValue(null); + + const result = await repository.getPage({ pageId: 'invalid-id' }); + + expect(result).toBeNull(); + }); + }); + + describe('createPage', () => { + it('should create page with converted content when parent is a page', async () => { + const pageElement = new PageElement({ + title: 'Test Page', + content: [], + }); + + const mockNotionPage = new NotionPage({ + pageId: 'new-page-id', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + isLocked: false, + children: [], + properties: { Title: { title: [{ text: { content: 'Test Page' } }] } } as any, + }); + + mockNotionConverter.convertFromElement.mockResolvedValue(mockNotionPage); + mockNotionClient.createPage.mockResolvedValue(mockNotionPage); + mockNotionClient.getPage.mockResolvedValue(mockNotionPage); + mockNotionClient.getPageBlocks.mockResolvedValue([]); + + const result = await repository.createPage({ + parentObjectId: 'parent-id', + parentObjectType: 'page', + pageElement, + }); + + expect(mockNotionClient.createPage).toHaveBeenCalledWith({ + parent: { type: 'page_id', page_id: 'parent-id' }, + properties: mockNotionPage.properties, + icon: undefined, + children: [], + }); + + expect(result).toMatchObject({ + pageId: 'new-page-id', + 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', () => { + // it('should update page properties and blocks', async () => { + // const pageElement = new PageElement({ + // title: 'Updated Page', + // content: [], + // }); + + // const mockNotionPage = { + // properties: { + // Title: { title: [{ text: { content: 'Updated Page' } }] }, + // }, + // children: [{ id: 'block-1', type: 'paragraph' }], + // }; + + // const mockExistingBlocks = [ + // { id: 'existing-block-1', type: 'paragraph' }, + // ]; + + // jest.spyOn(mockNotionConverter,'convertFromElement').mockReturnValue(mockNotionPage as unknown as NotionPage); + // jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue({ + // results: mockExistingBlocks as unknown as BlockObjectResponse[], + // type: 'block', + // block: mockExistingBlocks[0], + // object: 'list', + // next_cursor: null, + // has_more: false, + // }); + // jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue({ + // id: 'page-id', + // object: 'page', + // created_time: '2024-01-01T00:00:00.000Z', + // last_edited_time: '2024-01-02T00:00:00.000Z', + // }); + + // await repository.updatePage({ + // pageId: 'page-id', + // pageElement, + // }); + + // expect(mockClient.pages.update).toHaveBeenCalledWith({ + // page_id: 'page-id', + // properties: mockNotionPage.properties, + // }); + + // expect(mockClient.blocks.update).toHaveBeenCalled(); + // }); + // }); + + // Methods like search, getBlocksFromPage have been moved to NotionClientRepository + // and are tested there; + + 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 }); + 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.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.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); + expect(result).toBe('12345678901234567890123456789012'); + }); + + 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: No valid Notion ID found'); + }); + + 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: No valid Notion ID found'); + }); + + 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: No valid Notion ID found'); + }); + + it('should extract the database ID from a Notion Database URL', () => { + const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?v=7d43042815524daa9c5c3a7a4f8e1fe4&pvs=4'; + 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', () => { + const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?pvs=4'; + 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', () => { + it('should lock a page when lockStatus is "locked"', async () => { + const pageId = 'test-page-id'; + const lockStatus = 'locked' as const; + + mockNotionClient.updatePage.mockResolvedValue({} as any); + + await repository.setPageLockedStatus({ pageId, lockStatus }); + + expect(mockNotionClient.updatePage).toHaveBeenCalledWith({ + pageId, + isLocked: true, + }); + }); + + it('should unlock a page when lockStatus is "unlocked"', async () => { + const pageId = 'test-page-id'; + const lockStatus = 'unlocked' as const; + + mockNotionClient.updatePage.mockResolvedValue({} as any); + + await repository.setPageLockedStatus({ pageId, lockStatus }); + + expect(mockNotionClient.updatePage).toHaveBeenCalledWith({ + pageId, + isLocked: false, + }); + }); + + it('should throw an error when the Notion API call fails', async () => { + const pageId = 'test-page-id'; + const lockStatus = 'locked' as const; + const error = new Error('Notion API error'); + + mockNotionClient.updatePage.mockRejectedValue(error); + + await expect(repository.setPageLockedStatus({ pageId, lockStatus })).rejects.toThrow('Notion API error'); + }); + }); + + describe('getPageLockedStatus', () => { + it('should return "locked" when page is locked', async () => { + const pageId = 'test-page-id'; + const mockPage = new NotionPage({ + pageId, + createdAt: new Date(), + updatedAt: new Date(), + isLocked: true, + children: [], + }); + + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getPageLockedStatus({ pageId }); + + expect(result).toBe('locked'); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ pageId }); + }); + + it('should return "unlocked" when page is unlocked', async () => { + const pageId = 'test-page-id'; + const mockPage = new NotionPage({ + pageId, + createdAt: new Date(), + updatedAt: new Date(), + isLocked: false, + children: [], + }); + + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getPageLockedStatus({ pageId }); + + expect(result).toBe('unlocked'); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ pageId }); + }); + + it('should return "unlocked" when isLocked is undefined', async () => { + const pageId = 'test-page-id'; + const mockPage = new NotionPage({ + pageId, + createdAt: new Date(), + updatedAt: new Date(), + children: [], + }); + + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getPageLockedStatus({ pageId }); + + expect(result).toBe('unlocked'); + }); + + it('should throw an error when the Notion API call fails', async () => { + const pageId = 'test-page-id'; + const error = new Error('Notion API error'); + + mockNotionClient.getPage.mockRejectedValue(error); + + await expect(repository.getPageLockedStatus({ pageId })).rejects.toThrow('Notion API error'); + }); + }); + + describe('getObjectType', () => { + it('should return "page" when the object is a page', async () => { + const mockPage = new NotionPage({ + pageId: 'page-id', + createdAt: new Date(), + updatedAt: new Date(), + isLocked: false, + children: [], + }); + mockNotionClient.getPage.mockResolvedValue(mockPage); + + const result = await repository.getObjectType({ id: 'page-id' }); + + expect(result).toBe('page'); + expect(mockNotionClient.getPage).toHaveBeenCalledWith({ + pageId: 'page-id', + }); + }); + + it('should return "database" when the object is a database', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockResolvedValue({ + id: 'database-id', + object: 'database', + } as any); + + const result = await repository.getObjectType({ id: 'database-id' }); + + expect(result).toBe('database'); + expect(mockNotionClient.getDatabaseById).toHaveBeenCalledWith({ + databaseId: 'database-id', + }); + }); + + it('should return "unknown" when the object is neither a page nor a database', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockRejectedValue(new Error('Not found')); + + const result = await repository.getObjectType({ id: 'invalid-id' }); + + expect(result).toBe('unknown'); + }); + }); + + // Methods like getDatabaseById, deleteObjectById, getDataSourceIdFromDatabaseId, + // getObjectIdInDatabaseByMkNotesInternalId have been moved to NotionClientRepository + + describe('destinationIsAccessible with database support', () => { + it('should return true when database is accessible', async () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.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 () => { + mockNotionClient.getPage.mockRejectedValue(new Error('Not found')); + mockNotionClient.getDatabaseById.mockRejectedValue(new Error('Not found')); + + const result = await repository.destinationIsAccessible({ + parentObjectId: 'invalid-id', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/infrastructure/destinations/notion/notion.destination.ts b/src/infrastructure/destinations/notion/notion.destination.ts new file mode 100644 index 0000000..342b9c6 --- /dev/null +++ b/src/infrastructure/destinations/notion/notion.destination.ts @@ -0,0 +1,444 @@ +import { + BlockObjectResponse, + DatabaseObjectResponse, +} from '@notionhq/client/build/src/api-endpoints'; +import winston from 'winston'; + +import { PageElement } from '@/domains/elements/entities/Element'; +import { NotionPage } from '@/domains/notion/entities/NotionPage'; +import { + isNotionNestingValidationError, + NotionNestingValidationError, +} from '@/domains/notion/error'; +import { NotionClientRepository } from '@/domains/notion/repositories/notion-client.repository'; +import { + BlockObjectRequest, + BlockObjectRequestWithoutChildren, + DatabaseProperty, + Icon, + Parent, +} from '@/domains/notion/types'; +import { + DestinationRepository, + ObjectType, + PageLockedStatus, +} from '@/domains/synchronization/repositories/destination.repository'; +import { NotionConverterRepository } from '@/infrastructure/converters/notion/notion.converter'; + +export interface UpdatePageInput { + pageId: string; + blocks?: BlockObjectRequest[] | BlockObjectRequestWithoutChildren[]; + title?: string; + icon?: Icon; +} + +export class NotionDestinationRepository + implements DestinationRepository +{ + private notionClient: NotionClientRepository; + private logger: winston.Logger; + private notionConverter: NotionConverterRepository; + + constructor({ + logger, + notionClient, + notionConverter, + }: { + notionClient: NotionClientRepository; + logger: winston.Logger; + notionConverter: NotionConverterRepository; + }) { + this.notionClient = notionClient; + this.logger = logger; + this.notionConverter = notionConverter; + } + + /** + * Delete all child blocks from a parent page + */ + async deleteChildBlocks({ + parentPageId, + }: { + parentPageId: string; + }): Promise { + try { + // Get all blocks in the parent page + const blocks = await this.notionClient.getBlockChildren({ + blockId: parentPageId, + }); + + await this.notionClient.deleteBlocks({ + blockIds: blocks.map((block) => block.id), + }); + } catch (error: unknown) { + // Deletion failed - throw the error to be handled upstream + throw error instanceof Error ? error : new Error(String(error)); + } + } + + getObjectIdFromObjectUrl({ objectUrl }: { objectUrl: string }): string { + 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]; + } + + async destinationIsAccessible({ + parentObjectId, + }: { + parentObjectId: string; + }): Promise { + let page: NotionPage | null = null; + + try { + page = await this.notionClient.getPage({ pageId: parentObjectId }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_err) { + // Discard error, we'll check if it's a database + } + + if (page) { + return true; + } + + let database: DatabaseObjectResponse | null = null; + try { + database = await this.notionClient.getDatabaseById({ + databaseId: parentObjectId, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_err) { + // Discard error, we'll check if it's a page + } + + if (database) { + return true; + } + + return false; + } + + async getPage({ pageId }: { pageId: string }): Promise { + const notionPage = await this.notionClient.getPage({ + pageId, + }); + + if (!notionPage) { + return null; + } + + const blocks = await this.notionClient.getPageBlocks({ pageId: pageId }); + + notionPage.children = blocks; + + return notionPage; + } + + async createPage({ + parentObjectId, + parentObjectType, + pageElement, + }: { + parentObjectId: string; + parentObjectType: ObjectType; + pageElement: PageElement; + }): Promise { + 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 datasourceId = + await this.notionClient.getDataSourceIdFromDatabaseId({ + databaseId: parentObjectId, + }); + + if (!datasourceId) { + throw new Error('Failed to get Datasource'); + } + + const datasource = await this.notionClient.getDataSourceById({ + dataSourceId: datasourceId, + }); + + if (!datasource) { + throw new Error('Failed to get Datasource'); + } + + parent = { type: 'data_source_id', data_source_id: datasourceId }; + + availableProperties.push( + ...Object.entries(datasource.properties).map(([name, property]) => ({ + name, + definition: property, + type: property.type, + })) + ); + } + + const notionPage = await this.notionConverter.convertFromElement( + pageElement, + availableProperties + ); + + // First create the page without children + const createdPage = await this.notionClient.createPage({ + parent, + properties: notionPage.properties ?? {}, + icon: notionPage.icon, + children: [], + }); + + if (!createdPage.pageId) { + throw new Error('Failed to create Notion Page'); + } + + // If there are children blocks, append them in chunks + if (notionPage.children && notionPage.children.length > 0) { + const children = notionPage.children as BlockObjectRequest[]; + + const createdBlocks = await this.notionClient.appendChildToBlock({ + blockId: createdPage.pageId, + children: children, + }); + + createdPage.children = createdBlocks; + } + + const page = await this.getPage({ + pageId: createdPage.pageId, + }); + + if (!page) { + throw new Error('Failed to create Notion Page'); + } + + return page; + } + + async updatePage({ + pageId, + pageElement, + }: { + pageId: string; + pageElement: PageElement; + }): Promise { + const notionPageId = pageId; + + const notionPage = + await this.notionConverter.convertFromElement(pageElement); + + await this.notionClient.updatePage({ + pageId: notionPageId, + icon: notionPage.icon, + properties: notionPage.properties, + archived: false, + }); + + let existingBlocks = await this.notionClient.getBlockChildren({ + blockId: notionPageId, + }); + + let afterBlockId: string | undefined; + if ( + existingBlocks.length >= 2 && + existingBlocks[0].type === 'table_of_contents' && + existingBlocks[1].type === 'divider' + ) { + this.logger.warn( + 'First two blocks are TOC & Divider, appending to page after Divider' + ); + afterBlockId = existingBlocks[1]?.id; + existingBlocks = existingBlocks.slice(2); + } + + // Remove all non-page blocks + await this.removeNonPageBlocks({ blocks: existingBlocks }); + + if (notionPage.children && notionPage.children?.length > 0) { + let blocks = notionPage.children as BlockObjectRequest[]; + + if ( + blocks.length >= 2 && + blocks[0]?.type === 'table_of_contents' && + blocks[1]?.type === 'divider' + ) { + blocks = blocks.slice(2); + } + + await this.notionClient.appendChildToBlock({ + blockId: notionPageId, + children: blocks, + afterBlockId: afterBlockId, + }); + } + + await this.removeUnusedPageBlocks({ pageElement, blocks: existingBlocks }); + + const page = await this.getPage({ pageId: notionPageId }); + + if (!page) { + throw new Error('Failed to update Notion Page'); + } + return page; + } + + private async removeNonPageBlocks({ + blocks, + }: { + blocks: BlockObjectResponse[]; + }): Promise { + const blockIdsToDelete = blocks + .filter((block) => block.type !== 'child_page') + .map((block) => block.id); + + await this.notionClient.deleteBlocks({ + blockIds: blockIdsToDelete, + }); + } + + private async removeUnusedPageBlocks({ + pageElement, + blocks, + }: { + pageElement: PageElement; + blocks: BlockObjectResponse[]; + }): Promise { + const pageBlocks = blocks.filter((block) => block.type === 'child_page'); + const newPageBlocksIds = pageElement.content + .filter((element) => element instanceof PageElement) + .map((element) => element.id); + + const unusedPageBlocks = pageBlocks + .filter((block) => !newPageBlocksIds.includes(block.id)) + .map((block) => block.id); + + await this.notionClient.deleteBlocks({ + blockIds: unusedPageBlocks, + }); + } + + // Used for root level index.md where the page is already present + async appendToPage({ + pageId, + pageElement, + }: { + pageId: string; + pageElement: PageElement; + }): Promise { + const notionPage = + await this.notionConverter.convertFromElement(pageElement); + + // Update page properties (title/icon) if specified in metadata + await this.updatePageProperties({ pageId, pageElement }); + + if (notionPage.children && notionPage.children.length > 0) { + // Append blocks to the existing page + try { + await this.notionClient.appendChildToBlock({ + blockId: pageId, + children: notionPage.children as BlockObjectRequest[], + }); + } catch (error) { + if (isNotionNestingValidationError(error)) { + throw new NotionNestingValidationError({ message: 'Nesting error' }); + } + + this.logger.debug(`Failed to append block to page ${pageId}:`, { + error, + block: notionPage.children, + }); + throw error; + } + } + } + + async updatePageProperties({ + pageId, + pageElement, + }: { + pageId: string; + pageElement: PageElement; + }): Promise { + const notionPage = + await this.notionConverter.convertFromElement(pageElement); + + // Only update if there are properties to update + if (notionPage.properties || notionPage.icon) { + await this.notionClient.updatePage({ + pageId, + icon: notionPage.icon, + properties: notionPage.properties, + }); + } + } + + async setPageLockedStatus({ + pageId, + lockStatus, + }: { + pageId: string; + lockStatus: PageLockedStatus; + }): Promise { + const isLocked = lockStatus === 'locked'; + + await this.notionClient.updatePage({ + pageId, + isLocked, + }); + } + + async getPageLockedStatus({ + pageId, + }: { + pageId: string; + }): Promise { + const page = await this.notionClient.getPage({ pageId }); + + if (!page) { + throw new Error('Page not found'); + } + + const isLocked = page.isLocked ?? false; + + if (isLocked === undefined) { + return 'unlocked'; + } + + return isLocked ? 'locked' : 'unlocked'; + } + + async getObjectType({ + id, + }: { + id: string; + }): Promise<'page' | 'database' | 'unknown'> { + try { + await this.notionClient.getPage({ pageId: id }); + return 'page'; + } catch { + try { + await this.notionClient.getDatabaseById({ databaseId: id }); + return 'database'; + } catch { + return 'unknown'; + } + } + } +} diff --git a/src/infrastructure/notion/utils.ts b/src/infrastructure/destinations/notion/utils.ts similarity index 98% rename from src/infrastructure/notion/utils.ts rename to src/infrastructure/destinations/notion/utils.ts index 7c41a3c..a98e49e 100644 --- a/src/infrastructure/notion/utils.ts +++ b/src/infrastructure/destinations/notion/utils.ts @@ -3,7 +3,7 @@ import { BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints'; import { BlockObjectRequest, BlockObjectRequestWithoutChildren, -} from '../../domains/notion/types/types'; +} from '../../../domains/notion/types/types'; export const normalizeBlock = ( block: diff --git a/src/infrastructure/filesystem/file.converter.ts b/src/infrastructure/filesystem/file.converter.ts deleted file mode 100644 index 2cf2584..0000000 --- a/src/infrastructure/filesystem/file.converter.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Logger } from 'winston'; - -import { - Element, - ElementConverterRepository, - PageElement, - ParserRepository, - SupportedEmoji, -} from '@/domains/elements'; -import { File } from '@/domains/synchronization'; -import type { HtmlParser } from '@/infrastructure/html/html.parser'; -import type { MarkdownParser } from '@/infrastructure/markdown/markdown.parser'; - -export class FileConverter - implements ElementConverterRepository -{ - private htmlParser: HtmlParser; - private markdownParser: MarkdownParser; - private logger: Logger; - - constructor({ - htmlParser, - markdownParser, - logger, - }: { - htmlParser: HtmlParser; - markdownParser: MarkdownParser; - logger: Logger; - }) { - this.htmlParser = htmlParser; - this.markdownParser = markdownParser; - this.logger = logger; - } - - setCurrentFilePath(filePath: string): void { - if (this.markdownParser.setCurrentFilePath) { - this.markdownParser.setCurrentFilePath(filePath); - } - } - - public convertToElement(file: File): PageElement { - const { content } = file; - - const args: { - title: string; - content: Element[]; - icon: SupportedEmoji | undefined; - } = { - title: file.name, - content: [], - icon: undefined, - }; - - let parser: ParserRepository | null = null; - - if (file.extension === 'md') { - parser = this.markdownParser; - } - - if (file.extension === 'html') { - parser = this.htmlParser; - } - - if (!parser) { - throw new Error('File extension not supported'); - } - - const result = parser.parse({ content }); - - return new PageElement({ - ...args, - ...result, - }); - } -} diff --git a/src/infrastructure/filesystem/index.ts b/src/infrastructure/filesystem/index.ts deleted file mode 100644 index 235d324..0000000 --- a/src/infrastructure/filesystem/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './file.converter'; -export * from './fileSystem.source'; diff --git a/src/infrastructure/index.ts b/src/infrastructure/index.ts index d257f0b..3db1e6f 100644 --- a/src/infrastructure/index.ts +++ b/src/infrastructure/index.ts @@ -6,18 +6,16 @@ import { PageElement, SourceRepository, } from '@/domains'; -import { - FileConverter, - FileSystemSourceRepository, -} from '@/infrastructure/filesystem'; -import { HtmlParser } from '@/infrastructure/html'; -import { MarkdownParser } from '@/infrastructure/markdown'; -import { - NotionConverterRepository, - NotionDestinationRepository, - NotionFileUploadService, - NotionPage, -} from '@/infrastructure/notion'; +import { NotionPage } from '@/domains/notion/entities/NotionPage'; +// Infrastructure imports +import { FileConverter } from '@/infrastructure/converters/file/file.converter'; +import { NotionConverterRepository } from '@/infrastructure/converters/notion/notion.converter'; +import { NotionFileUploadService } from '@/infrastructure/destinations/notion/file-upload.service'; +import { NotionDestinationRepository } from '@/infrastructure/destinations/notion/notion.destination'; +import { NotionClientRepository } from '@/infrastructure/notion/notion-client.repository'; +import { HtmlParser } from '@/infrastructure/parsers/html'; +import { MarkdownParser } from '@/infrastructure/parsers/markdown'; +import { FileSystemSourceRepository } from '@/infrastructure/sources/filesystem/fileSystem.source'; let infraInstances: InfrastructureInstances | null; @@ -43,6 +41,9 @@ const buildInstances = ({ apiKey: notionApiKey, logger, }); + const notionClient = new NotionClientRepository({ + apiKey: notionApiKey, + }); const notionConverter = new NotionConverterRepository({ logger, fileUploadService, @@ -65,7 +66,7 @@ const buildInstances = ({ notionDestination: new NotionDestinationRepository({ logger, notionConverter, - apiKey: notionApiKey, + notionClient, }), notionConverter, }; diff --git a/src/infrastructure/notion/__tests__/notion-client.repository.test.ts b/src/infrastructure/notion/__tests__/notion-client.repository.test.ts new file mode 100644 index 0000000..e2db538 --- /dev/null +++ b/src/infrastructure/notion/__tests__/notion-client.repository.test.ts @@ -0,0 +1,747 @@ +import { Client, LogLevel, isFullPage } from '@notionhq/client'; +import { + BlockObjectResponse, + PageObjectResponse, +} from '@notionhq/client/build/src/api-endpoints'; + +import { NotionClientRepository } from '../notion-client.repository'; + +// Mock the @notionhq/client module +jest.mock('@notionhq/client', () => ({ + Client: jest.fn(), + LogLevel: { ERROR: 'error' }, + isFullPage: jest.fn(), +})); + +describe('NotionClientRepository', () => { + let repository: NotionClientRepository; + let mockClient: { + search: jest.Mock; + databases: { retrieve: jest.Mock }; + dataSources: { retrieve: jest.Mock }; + pages: { + retrieve: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + blocks: { + retrieve: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + children: { + list: jest.Mock; + append: jest.Mock; + }; + }; + }; + + const mockPageResponse: PageObjectResponse = { + id: 'page-id-123', + object: 'page', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-02T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + cover: null, + icon: null, + parent: { type: 'workspace', workspace: true }, + archived: false, + in_trash: false, + properties: {}, + url: 'https://notion.so/page-id-123', + public_url: null, + is_locked: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockClient = { + search: jest.fn(), + databases: { retrieve: jest.fn() }, + dataSources: { retrieve: jest.fn() }, + pages: { + retrieve: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + blocks: { + retrieve: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + children: { + list: jest.fn(), + append: jest.fn(), + }, + }, + }; + + (Client as unknown as jest.Mock).mockImplementation(() => mockClient); + (isFullPage as unknown as jest.Mock).mockReturnValue(true); + + repository = new NotionClientRepository({ apiKey: 'test-api-key' }); + }); + + describe('constructor', () => { + it('should create a Notion client with the provided API key', () => { + expect(Client).toHaveBeenCalledWith({ + auth: 'test-api-key', + logLevel: LogLevel.ERROR, + }); + }); + }); + + describe('search', () => { + it('should call client.search with the provided filter', async () => { + const mockSearchResponse = { + results: [{ id: 'page-1' }], + next_cursor: null, + has_more: false, + type: 'page_or_database', + page_or_database: {}, + }; + mockClient.search.mockResolvedValue(mockSearchResponse); + + const result = await repository.search({ + filter: { property: 'object', value: 'page' }, + }); + + expect(mockClient.search).toHaveBeenCalledWith({ + filter: { property: 'object', value: 'page' }, + }); + expect(result).toEqual(mockSearchResponse); + }); + + it('should search for data_source objects', async () => { + const mockSearchResponse = { + results: [], + next_cursor: null, + has_more: false, + type: 'page_or_database', + page_or_database: {}, + }; + mockClient.search.mockResolvedValue(mockSearchResponse); + + await repository.search({ + filter: { property: 'object', value: 'data_source' }, + }); + + expect(mockClient.search).toHaveBeenCalledWith({ + filter: { property: 'object', value: 'data_source' }, + }); + }); + }); + + describe('getDatabaseById', () => { + it('should retrieve a database by ID', async () => { + const mockDatabase = { + id: 'database-id', + object: 'database', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-02T00:00:00.000Z', + title: [], + description: [], + icon: null, + cover: null, + properties: {}, + parent: { type: 'workspace', workspace: true }, + url: 'https://notion.so/database-id', + public_url: null, + archived: false, + in_trash: false, + is_inline: false, + }; + mockClient.databases.retrieve.mockResolvedValue(mockDatabase); + + const result = await repository.getDatabaseById({ + databaseId: 'database-id', + }); + + expect(mockClient.databases.retrieve).toHaveBeenCalledWith({ + database_id: 'database-id', + }); + expect(result).toEqual(mockDatabase); + }); + + it('should return null when database does not exist', async () => { + mockClient.databases.retrieve.mockResolvedValue(null); + + const result = await repository.getDatabaseById({ + databaseId: 'non-existent-id', + }); + + expect(result).toBeNull(); + }); + }); + + describe('getDataSourceById', () => { + it('should retrieve a data source by ID', async () => { + const mockDataSource = { + id: 'data-source-id', + object: 'data_source', + }; + mockClient.dataSources.retrieve.mockResolvedValue(mockDataSource); + + const result = await repository.getDataSourceById({ + dataSourceId: 'data-source-id', + }); + + expect(mockClient.dataSources.retrieve).toHaveBeenCalledWith({ + data_source_id: 'data-source-id', + }); + expect(result).toEqual(mockDataSource); + }); + + it('should return null when data source does not exist', async () => { + mockClient.dataSources.retrieve.mockResolvedValue(null); + + const result = await repository.getDataSourceById({ + dataSourceId: 'non-existent-id', + }); + + expect(result).toBeNull(); + }); + }); + + 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-1' }, { id: 'data-source-id-2' }], + }; + mockClient.databases.retrieve.mockResolvedValue(mockDatabase); + + const result = await repository.getDataSourceIdFromDatabaseId({ + databaseId: 'database-id', + }); + + expect(result).toBe('data-source-id-1'); + }); + + it('should throw an error when database has no data sources', async () => { + const mockDatabase = { + id: 'database-id', + object: 'database', + }; + mockClient.databases.retrieve.mockResolvedValue(mockDatabase); + + await expect( + repository.getDataSourceIdFromDatabaseId({ databaseId: 'database-id' }) + ).rejects.toThrow('Database does not have any datasources'); + }); + + it('should throw an error when database is null', async () => { + mockClient.databases.retrieve.mockResolvedValue(null); + + await expect( + repository.getDataSourceIdFromDatabaseId({ + databaseId: 'non-existent-id', + }) + ).rejects.toThrow('Database does not have any datasources'); + }); + }); + + describe('getPage', () => { + it('should retrieve a page and return a NotionPage', async () => { + mockClient.pages.retrieve.mockResolvedValue(mockPageResponse); + + const result = await repository.getPage({ pageId: 'page-id-123' }); + + expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ + page_id: 'page-id-123', + }); + expect(result).not.toBeNull(); + expect(result?.pageId).toBe('page-id-123'); + expect(result?.isLocked).toBe(false); + }); + + it('should throw an error when page is not a full page', async () => { + (isFullPage as unknown as jest.Mock).mockReturnValue(false); + mockClient.pages.retrieve.mockResolvedValue({ id: 'partial-page' }); + + await expect( + repository.getPage({ pageId: 'partial-page' }) + ).rejects.toThrow('Not able to retrieve Notion Page'); + }); + + it('should return null when page response is falsy', async () => { + mockClient.pages.retrieve.mockResolvedValue(null); + (isFullPage as unknown as jest.Mock).mockReturnValue(true); + + const result = await repository.getPage({ pageId: 'non-existent' }); + + expect(result).toBeNull(); + }); + }); + + describe('createPage', () => { + it('should create a page with parent, properties, icon, and children', async () => { + mockClient.pages.create.mockResolvedValue(mockPageResponse); + + const input = { + parent: { type: 'page_id' as const, page_id: 'parent-page-id' }, + properties: { + title: { + title: [{ text: { content: 'Test Page' } }], + id: 'title' as const, + }, + }, + icon: { type: 'emoji' as const, emoji: '📄' as const }, + children: [ + { type: 'paragraph' as const, paragraph: { rich_text: [] } }, + ], + }; + + const result = await repository.createPage(input); + + expect(mockClient.pages.create).toHaveBeenCalledWith({ + parent: input.parent, + properties: input.properties, + icon: input.icon, + children: input.children, + }); + expect(result.pageId).toBe('page-id-123'); + }); + + it('should create a page in a database', async () => { + mockClient.pages.create.mockResolvedValue(mockPageResponse); + + const input = { + parent: { type: 'database_id' as const, database_id: 'database-id' }, + properties: { + Name: { title: [{ text: { content: 'Test' } }], id: 'title' as const }, + }, + }; + + await repository.createPage(input); + + expect(mockClient.pages.create).toHaveBeenCalledWith({ + parent: input.parent, + properties: input.properties, + icon: undefined, + children: undefined, + }); + }); + }); + + describe('getPageBlocks', () => { + it('should return block children of a page', async () => { + const mockBlocks: BlockObjectResponse[] = [ + { + id: 'block-1', + type: 'paragraph', + object: 'block', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-01T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + parent: { type: 'page_id', page_id: 'page-id' }, + archived: false, + in_trash: false, + has_children: false, + paragraph: { rich_text: [], color: 'default' }, + }, + ]; + mockClient.blocks.children.list.mockResolvedValue({ results: mockBlocks }); + + const result = await repository.getPageBlocks({ pageId: 'page-id' }); + + expect(mockClient.blocks.children.list).toHaveBeenCalledWith({ + block_id: 'page-id', + }); + expect(result).toEqual(mockBlocks); + }); + }); + + describe('updatePage', () => { + it('should update page properties', async () => { + mockClient.pages.update.mockResolvedValue(mockPageResponse); + + await repository.updatePage({ + pageId: 'page-id-123', + properties: { + title: { + title: [{ text: { content: 'Updated' } }], + id: 'title' as const, + }, + }, + }); + + expect(mockClient.pages.update).toHaveBeenCalledWith({ + page_id: 'page-id-123', + properties: { + title: { + title: [{ text: { content: 'Updated' } }], + id: 'title', + }, + }, + archived: undefined, + is_locked: undefined, + }); + }); + + it('should update page icon', async () => { + mockClient.pages.update.mockResolvedValue(mockPageResponse); + + await repository.updatePage({ + pageId: 'page-id-123', + icon: { type: 'emoji', emoji: '🚀' }, + }); + + expect(mockClient.pages.update).toHaveBeenCalledWith( + expect.objectContaining({ + page_id: 'page-id-123', + icon: { type: 'emoji', emoji: '🚀' }, + }) + ); + }); + + it('should update page archived status', async () => { + mockClient.pages.update.mockResolvedValue(mockPageResponse); + + await repository.updatePage({ + pageId: 'page-id-123', + archived: true, + }); + + expect(mockClient.pages.update).toHaveBeenCalledWith( + expect.objectContaining({ + page_id: 'page-id-123', + archived: true, + }) + ); + }); + + it('should update page locked status', async () => { + mockClient.pages.update.mockResolvedValue(mockPageResponse); + + await repository.updatePage({ + pageId: 'page-id-123', + isLocked: true, + }); + + expect(mockClient.pages.update).toHaveBeenCalledWith( + expect.objectContaining({ + page_id: 'page-id-123', + is_locked: true, + }) + ); + }); + }); + + describe('deletePage', () => { + it('should delete a page by deleting the block', async () => { + mockClient.blocks.delete.mockResolvedValue({}); + + await repository.deletePage({ pageId: 'page-id-123' }); + + expect(mockClient.blocks.delete).toHaveBeenCalledWith({ + block_id: 'page-id-123', + }); + }); + }); + + describe('appendChildToBlock', () => { + it('should append children to a block', async () => { + const mockCreatedBlocks: BlockObjectResponse[] = [ + { + id: 'new-block-1', + type: 'paragraph', + object: 'block', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-01T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + parent: { type: 'page_id', page_id: 'page-id' }, + archived: false, + in_trash: false, + has_children: false, + paragraph: { rich_text: [], color: 'default' }, + }, + ]; + mockClient.blocks.children.append.mockResolvedValue({ + results: mockCreatedBlocks, + }); + + const children = [ + { type: 'paragraph' as const, paragraph: { rich_text: [] } }, + ]; + + const result = await repository.appendChildToBlock({ + blockId: 'page-id', + children, + }); + + expect(mockClient.blocks.children.append).toHaveBeenCalledWith({ + block_id: 'page-id', + children, + after: undefined, + }); + expect(result).toEqual(mockCreatedBlocks); + }); + + it('should append children after a specific block', async () => { + const children = [ + { type: 'paragraph' as const, paragraph: { rich_text: [] } }, + ]; + mockClient.blocks.children.append.mockResolvedValue({ + results: [{ id: 'new-block-1' }], + }); + + await repository.appendChildToBlock({ + blockId: 'page-id', + children, + afterBlockId: 'after-block-id', + }); + + expect(mockClient.blocks.children.append).toHaveBeenCalledWith({ + block_id: 'page-id', + children, + after: 'after-block-id', + }); + }); + + it('should handle empty children array', async () => { + const result = await repository.appendChildToBlock({ + blockId: 'page-id', + children: [], + }); + + expect(mockClient.blocks.children.append).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should chunk children into batches of 100', async () => { + const children = Array(250) + .fill(null) + .map((_, i) => ({ + type: 'paragraph' as const, + paragraph: { rich_text: [{ text: { content: `Block ${i}` } }] }, + })); + + mockClient.blocks.children.append + .mockResolvedValueOnce({ + results: children.slice(0, 100).map((_, i) => ({ id: `block-${i}` })), + }) + .mockResolvedValueOnce({ + results: children + .slice(100, 200) + .map((_, i) => ({ id: `block-${100 + i}` })), + }) + .mockResolvedValueOnce({ + results: children + .slice(200) + .map((_, i) => ({ id: `block-${200 + i}` })), + }); + + const result = await repository.appendChildToBlock({ + blockId: 'page-id', + children, + }); + + expect(mockClient.blocks.children.append).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(250); + }); + + it('should use the last created block ID as afterBlockId for subsequent chunks', async () => { + const children = Array(150) + .fill(null) + .map(() => ({ + type: 'paragraph' as const, + paragraph: { rich_text: [] }, + })); + + mockClient.blocks.children.append + .mockResolvedValueOnce({ + results: [{ id: 'last-block-of-first-chunk' }], + }) + .mockResolvedValueOnce({ + results: [{ id: 'last-block-of-second-chunk' }], + }); + + await repository.appendChildToBlock({ + blockId: 'page-id', + children, + }); + + expect(mockClient.blocks.children.append).toHaveBeenNthCalledWith(2, { + block_id: 'page-id', + children: expect.any(Array), + after: 'last-block-of-first-chunk', + }); + }); + }); + + describe('deleteBlock', () => { + it('should delete a block by ID', async () => { + mockClient.blocks.delete.mockResolvedValue({}); + + await repository.deleteBlock({ blockId: 'block-id-123' }); + + expect(mockClient.blocks.delete).toHaveBeenCalledWith({ + block_id: 'block-id-123', + }); + }); + }); + + describe('deleteBlocks', () => { + it('should delete multiple blocks', async () => { + mockClient.blocks.delete.mockResolvedValue({}); + + await repository.deleteBlocks({ + blockIds: ['block-1', 'block-2', 'block-3'], + }); + + expect(mockClient.blocks.delete).toHaveBeenCalledTimes(3); + expect(mockClient.blocks.delete).toHaveBeenCalledWith({ + block_id: 'block-1', + }); + expect(mockClient.blocks.delete).toHaveBeenCalledWith({ + block_id: 'block-2', + }); + expect(mockClient.blocks.delete).toHaveBeenCalledWith({ + block_id: 'block-3', + }); + }); + + it('should chunk deletion into batches of 50', async () => { + mockClient.blocks.delete.mockResolvedValue({}); + + const blockIds = Array(120) + .fill(null) + .map((_, i) => `block-${i}`); + + await repository.deleteBlocks({ blockIds }); + + // Should be called 120 times total but in 3 batches + expect(mockClient.blocks.delete).toHaveBeenCalledTimes(120); + }); + + it('should handle empty blockIds array', async () => { + await repository.deleteBlocks({ blockIds: [] }); + + expect(mockClient.blocks.delete).not.toHaveBeenCalled(); + }); + }); + + describe('getBlock', () => { + it('should retrieve a block by ID', async () => { + const mockBlock: BlockObjectResponse = { + id: 'block-id', + type: 'paragraph', + object: 'block', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-01T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + parent: { type: 'page_id', page_id: 'page-id' }, + archived: false, + in_trash: false, + has_children: false, + paragraph: { rich_text: [], color: 'default' }, + }; + mockClient.blocks.retrieve.mockResolvedValue(mockBlock); + + const result = await repository.getBlock({ blockId: 'block-id' }); + + expect(mockClient.blocks.retrieve).toHaveBeenCalledWith({ + block_id: 'block-id', + }); + expect(result).toEqual(mockBlock); + }); + }); + + describe('updateBlock', () => { + it('should update a block', async () => { + const mockBlock: BlockObjectResponse = { + id: 'block-id', + type: 'paragraph', + object: 'block', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-01T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + parent: { type: 'page_id', page_id: 'page-id' }, + archived: false, + in_trash: false, + has_children: false, + paragraph: { rich_text: [], color: 'default' }, + }; + mockClient.blocks.update.mockResolvedValue(mockBlock); + + const blockUpdate = { + type: 'paragraph' as const, + paragraph: { + rich_text: [{ type: 'text' as const, text: { content: 'Updated' } }], + }, + }; + + const result = await repository.updateBlock({ + blockId: 'block-id', + block: blockUpdate, + }); + + expect(mockClient.blocks.update).toHaveBeenCalledWith({ + block_id: 'block-id', + ...blockUpdate, + }); + expect(result).toEqual(mockBlock); + }); + }); + + describe('getBlockChildren', () => { + it('should retrieve children of a block', async () => { + const mockBlocks: BlockObjectResponse[] = [ + { + id: 'child-block-1', + type: 'paragraph', + object: 'block', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-01T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + parent: { type: 'block_id', block_id: 'parent-block-id' }, + archived: false, + in_trash: false, + has_children: false, + paragraph: { rich_text: [], color: 'default' }, + }, + { + id: 'child-block-2', + type: 'heading_1', + object: 'block', + created_time: '2024-01-01T00:00:00.000Z', + last_edited_time: '2024-01-01T00:00:00.000Z', + created_by: { id: 'user-id', object: 'user' }, + last_edited_by: { id: 'user-id', object: 'user' }, + parent: { type: 'block_id', block_id: 'parent-block-id' }, + archived: false, + in_trash: false, + has_children: false, + heading_1: { rich_text: [], is_toggleable: false, color: 'default' }, + }, + ]; + mockClient.blocks.children.list.mockResolvedValue({ results: mockBlocks }); + + const result = await repository.getBlockChildren({ + blockId: 'parent-block-id', + }); + + expect(mockClient.blocks.children.list).toHaveBeenCalledWith({ + block_id: 'parent-block-id', + }); + expect(result).toEqual(mockBlocks); + }); + + it('should return empty array when block has no children', async () => { + mockClient.blocks.children.list.mockResolvedValue({ results: [] }); + + const result = await repository.getBlockChildren({ + blockId: 'block-without-children', + }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/infrastructure/notion/index.ts b/src/infrastructure/notion/index.ts deleted file mode 100644 index b60c3a6..0000000 --- a/src/infrastructure/notion/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from '../../domains/notion/NotionPage'; -export * from '../../domains/notion/types/types'; -export * from './file-upload.service'; -export * from './notion.converter'; -export * from './notion.destination'; -export * from './utils'; diff --git a/src/infrastructure/notion/notion-client.repository.ts b/src/infrastructure/notion/notion-client.repository.ts new file mode 100644 index 0000000..6dd2604 --- /dev/null +++ b/src/infrastructure/notion/notion-client.repository.ts @@ -0,0 +1,307 @@ +import { + BlockObjectRequest, + BlockObjectResponse, + Client, + CreatePageParameters, + DatabaseObjectResponse, + DataSourceObjectResponse, + isFullPage, + LogLevel, + PageObjectResponse, + SearchResponse, + UpdatePageParameters, +} from '@notionhq/client'; + +import { NotionPage } from '@/domains/notion/entities/NotionPage'; +import { + CreatePageInput, + NotionClientRepository as NotionClientRepositoryInterface, +} from '@/domains/notion/repositories/notion-client.repository'; +import { Icon, PageProperties, TitleProperty } from '@/domains/notion/types'; + +export class NotionClientRepository implements NotionClientRepositoryInterface { + private client: Client; + constructor({ apiKey }: { apiKey: string }) { + this.client = new Client({ + auth: apiKey, + logLevel: LogLevel.ERROR, + }); + } + + /** + * ------------------------------------------------------------ + * GENERAL METHODS + * ------------------------------------------------------------ + */ + public async search({ + filter, + }: { + filter: { property: 'object'; value: 'page' | 'data_source' }; + }): Promise { + return this.client.search({ filter }); + } + + /** + * ------------------------------------------------------------ + * DATABASES METHODS + * ------------------------------------------------------------ + */ + public async getDatabaseById({ + databaseId, + }: { + databaseId: string; + }): Promise { + const response = await this.client.databases.retrieve({ + database_id: databaseId, + }); + + if (!response) { + return null; + } + + return response as DatabaseObjectResponse; + } + + /** + * ------------------------------------------------------------ + * DATA SOURCES METHODS + * ------------------------------------------------------------ + */ + public async getDataSourceById({ + dataSourceId, + }: { + dataSourceId: string; + }): Promise { + const response = await this.client.dataSources.retrieve({ + data_source_id: dataSourceId, + }); + if (!response) { + return null; + } + return response as DataSourceObjectResponse; + } + + public async getDataSourceIdFromDatabaseId({ + databaseId, + }: { + databaseId: string; + }): Promise { + const database = await this.getDatabaseById({ databaseId }); + + if (!database || !('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + return database.data_sources[0].id; + } + /** + * ------------------------------------------------------------ + * PAGES METHODS + * ------------------------------------------------------------ + */ + + public async getPage({ + pageId, + }: { + pageId: string; + }): Promise { + const response = await this.client.pages.retrieve({ page_id: pageId }); + + if (!isFullPage(response)) { + throw new Error('Not able to retrieve Notion Page'); + } + + return response + ? this.toNotionPage({ page: response, children: [] }) + : null; + } + + async createPage({ + parent, + properties, + icon, + children, + }: CreatePageInput): Promise { + const response = await this.client.pages.create({ + parent, + properties: properties as CreatePageParameters['properties'], + icon, + children, + }); + + return this.toNotionPage({ + page: response as PageObjectResponse, + children: [], + }); + } + + async getPageBlocks({ + pageId, + }: { + pageId: string; + }): Promise { + return this.getBlockChildren({ blockId: pageId }); + } + + async updatePage({ + pageId, + icon, + properties, + archived, + isLocked, + }: { + pageId: string; + icon?: Icon; + properties?: PageProperties; + archived?: boolean; + isLocked?: boolean; + }): Promise { + const updateBody: UpdatePageParameters = { + page_id: pageId, + properties: {}, + archived, + is_locked: isLocked, + }; + + if (icon) { + updateBody.icon = icon; + } + + if (properties?.title) { + updateBody.properties!['title'] = properties.title as TitleProperty; + } + + const response = await this.client.pages.update(updateBody); + + return this.toNotionPage({ + page: response as PageObjectResponse, + children: [], + }); + } + + public async deletePage({ pageId }: { pageId: string }): Promise { + await this.deleteBlock({ blockId: pageId }); + } + + private toNotionPage({ + page, + children, + }: { + page: PageObjectResponse; + children: BlockObjectResponse[]; + }): NotionPage { + if (!page.id) { + throw new Error('Page ID is required'); + } + + return new NotionPage({ + pageId: page.id, + children, + createdAt: new Date(page.created_time), + updatedAt: new Date(page.last_edited_time), + isLocked: page.is_locked ?? false, + }); + } + + /** + * ------------------------------------------------------------ + * BLOCKS METHODS + * ------------------------------------------------------------ + */ + + /** + * There is a limit of 100 block children that can be appended by a single API request. + * Arrays of block children longer than 100 will result in an error. + * + * see: https://developers.notion.com/reference/patch-block-children + */ + private readonly APPEND_BLOCK_CHILDREN_CHUNK_SIZE = 100; + public async appendChildToBlock({ + blockId, + children, + afterBlockId, + }: { + blockId: string; + children: BlockObjectRequest[]; + afterBlockId?: string; + }): Promise { + const createdBlocks: BlockObjectResponse[] = []; + // Split children into chunks of 100 blocks + for ( + let i = 0; + i < children.length; + i += this.APPEND_BLOCK_CHILDREN_CHUNK_SIZE + ) { + const chunk = children.slice( + i, + i + this.APPEND_BLOCK_CHILDREN_CHUNK_SIZE + ); + const response = await this.client.blocks.children.append({ + block_id: blockId, + children: chunk, + after: afterBlockId, + }); + + if (response.results.length > 0) { + createdBlocks.push(...(response.results as BlockObjectResponse[])); + } + + afterBlockId = createdBlocks[createdBlocks.length - 1]?.id; + } + + return createdBlocks; + } + + public async deleteBlock({ blockId }: { blockId: string }): Promise { + await this.client.blocks.delete({ block_id: blockId }); + } + + private readonly DELETE_BLOCKS_CHUNK_SIZE = 50; + public async deleteBlocks({ + blockIds, + }: { + blockIds: string[]; + }): Promise { + for (let i = 0; i < blockIds.length; i += this.DELETE_BLOCKS_CHUNK_SIZE) { + const chunk = blockIds.slice(i, i + this.DELETE_BLOCKS_CHUNK_SIZE); + await Promise.all( + chunk.map(async (blockId) => + this.client.blocks.delete({ block_id: blockId }) + ) + ); + } + } + public async getBlock({ + blockId, + }: { + blockId: string; + }): Promise { + const response = await this.client.blocks.retrieve({ block_id: blockId }); + + return response as BlockObjectResponse; + } + + public async updateBlock({ + blockId, + block, + }: { + blockId: string; + block: BlockObjectRequest; + }): Promise { + const response = await this.client.blocks.update({ + block_id: blockId, + ...block, + }); + return response as BlockObjectResponse; + } + + public async getBlockChildren({ + blockId, + }: { + blockId: string; + }): Promise { + const response = await this.client.blocks.children.list({ + block_id: blockId, + }); + return response.results as BlockObjectResponse[]; + } +} diff --git a/src/infrastructure/notion/notion.destination.test.ts b/src/infrastructure/notion/notion.destination.test.ts deleted file mode 100644 index d77897e..0000000 --- a/src/infrastructure/notion/notion.destination.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { Client, Logger } from '@notionhq/client'; -import { NotionDestinationRepository } from './notion.destination'; -import { NotionConverterRepository } from './notion.converter'; -import { PageElement } from '@/domains/elements'; -import { NotionPage } from '@/domains/notion/NotionPage'; -import { CreatePageResponse, GetPageResponse, ListBlockChildrenResponse, SearchResponse } from '@notionhq/client/build/src/api-endpoints'; -import { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints'; -import winston from 'winston'; - -jest.mock('@notionhq/client'); - -describe('NotionDestinationRepository', () => { - let repository: NotionDestinationRepository; - let mockClient: jest.Mocked; - let mockNotionConverter: jest.Mocked; - let mockLogger: jest.Mocked; - - beforeEach(() => { - mockLogger = { - warn: jest.fn(), - } as unknown as jest.Mocked; - - mockClient = { - pages: { - create: jest.fn(), - retrieve: jest.fn(), - update: jest.fn(), - }, - 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; - - // Mock the Client constructor to return our mock client - jest.mocked(Client).mockImplementation(() => mockClient); - - mockNotionConverter = { - convertFromElement: jest.fn(), - } as unknown as jest.Mocked; - - repository = new NotionDestinationRepository({ - apiKey: 'fake-api-key', - notionConverter: mockNotionConverter, - logger: mockLogger as unknown as winston.Logger, - }); - }); - - describe.skip('destinationIsAccessible', () => { - it('should return true when page is accessible', async () => { - jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue({ id: 'page-id', object: 'page' }); - - const result = await repository.destinationIsAccessible({ - parentObjectId: 'page-id', - }); - - expect(result).toBe(true); - expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ - page_id: 'page-id', - }); - }); - - it('should return false when page is not accessible', async () => { - jest.spyOn(mockClient.pages,'retrieve').mockRejectedValue(new Error('Not found')); - - const result = await repository.destinationIsAccessible({ - parentObjectId: 'invalid-id', - }); - - expect(result).toBe(false); - }); - }); - - describe.skip('getPageById', () => { - it('should retrieve page with blocks', async () => { - const mockPage = { - id: 'page-id', - created_time: '2024-01-01T00:00:00.000Z', - last_edited_time: '2024-01-02T00:00:00.000Z', - object: 'page', - }; - const mockBlocks = { - results: [{ id: 'block-1' }, { id: 'block-2' }], - }; - - jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue(mockPage as PageObjectResponse); - jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue(mockBlocks as ListBlockChildrenResponse); - - const result = await repository.getPageById({ - notionPageId: 'page-id', - }); - - expect(result).toEqual({ - pageId: 'page-id', - children: mockBlocks.results, - createdAt: new Date('2024-01-01T00:00:00.000Z'), - updatedAt: new Date('2024-01-02T00:00:00.000Z'), - }); - }); - - it('should throw error for invalid page response', async () => { - jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue({ object: 'invalid' } as unknown as PageObjectResponse); - - await expect( - repository.getPageById({ notionPageId: 'invalid-id' }) - ).rejects.toThrow('Not able to retrieve Notion Page'); - }); - }); - - describe.skip('createPage', () => { - it('should create page with converted content when parent is a page', async () => { - const pageElement = new PageElement({ - title: 'Test Page', - content: [], - }); - - const mockNotionPage = { - properties: { Title: { title: [{ text: { content: 'Test Page' } }] } }, - children: [], - }; - - const mockCreatedPage = { - id: 'new-page-id', - created_time: '2024-01-01T00:00:00.000Z', - last_edited_time: '2024-01-01T00:00:00.000Z', - object: 'page', - }; - - jest.spyOn(mockNotionConverter,'convertFromElement').mockResolvedValue(mockNotionPage as unknown as NotionPage); - jest.spyOn(mockClient.pages,'create').mockResolvedValue({ id: 'new-page-id' } as unknown as CreatePageResponse); - jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue(mockCreatedPage as unknown as GetPageResponse); - jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue({ results: [] } as unknown as ListBlockChildrenResponse); - - const result = await repository.createPage({ - parentObjectId: 'parent-id', - parentObjectType: 'page', - pageElement, - }); - - expect(mockClient.pages.create).toHaveBeenCalledWith({ - parent: { type: 'page_id', page_id: 'parent-id' }, - properties: mockNotionPage.properties, - icon: undefined, - children: [], - }); - - expect(result).toMatchObject({ - pageId: 'new-page-id', - 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', () => { - // it('should update page properties and blocks', async () => { - // const pageElement = new PageElement({ - // title: 'Updated Page', - // content: [], - // }); - - // const mockNotionPage = { - // properties: { - // Title: { title: [{ text: { content: 'Updated Page' } }] }, - // }, - // children: [{ id: 'block-1', type: 'paragraph' }], - // }; - - // const mockExistingBlocks = [ - // { id: 'existing-block-1', type: 'paragraph' }, - // ]; - - // jest.spyOn(mockNotionConverter,'convertFromElement').mockReturnValue(mockNotionPage as unknown as NotionPage); - // jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue({ - // results: mockExistingBlocks as unknown as BlockObjectResponse[], - // type: 'block', - // block: mockExistingBlocks[0], - // object: 'list', - // next_cursor: null, - // has_more: false, - // }); - // jest.spyOn(mockClient.pages,'retrieve').mockResolvedValue({ - // id: 'page-id', - // object: 'page', - // created_time: '2024-01-01T00:00:00.000Z', - // last_edited_time: '2024-01-02T00:00:00.000Z', - // }); - - // await repository.updatePage({ - // pageId: 'page-id', - // pageElement, - // }); - - // expect(mockClient.pages.update).toHaveBeenCalledWith({ - // page_id: 'page-id', - // properties: mockNotionPage.properties, - // }); - - // expect(mockClient.blocks.update).toHaveBeenCalled(); - // }); - // }); - - describe.skip('search', () => { - it('should perform search with filter', async () => { - const mockSearchResults = { - results: [{ id: 'page-1' }, { id: 'page-2' }], - }; - - jest.spyOn(mockClient,'search').mockResolvedValue(mockSearchResults as unknown as SearchResponse); - - const result = await repository.search({ - filter: { property: 'object', value: 'page' }, - }); - - expect(result).toEqual(mockSearchResults); - expect(mockClient.search).toHaveBeenCalledWith({ - filter: { property: 'object', value: 'page' }, - }); - }); - }); - - describe.skip('getBlocksFromPage', () => { - it('should retrieve blocks from page', async () => { - const mockBlocks = { - results: [{ id: 'block-1' }, { id: 'block-2' }], - }; - - jest.spyOn(mockClient.blocks.children,'list').mockResolvedValue(mockBlocks as unknown as ListBlockChildrenResponse); - - const result = await repository.getBlocksFromPage({ - notionPageId: 'page-id', - }); - - expect(result).toEqual(mockBlocks.results); - expect(mockClient.blocks.children.list).toHaveBeenCalledWith({ - block_id: 'page-id', - }); - }); - }); - - // describe('updateBlock', () => { - // it('should update block content', async () => { - // const mockBlock = { - // type: 'paragraph', - // paragraph: { text: 'Updated content' }, - // } as unknown as BlockObjectRequest; - - // await repository.updateBlock({ - // blockId: 'block-id', - // block: mockBlock as unknown as BlockObjectRequest, - // }); - - // expect(mockClient.blocks.update).toHaveBeenCalledWith({ - // block_id: 'block-id', - // ...mockBlock, - // }); - // }); - // }); - - 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 }); - 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.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.getObjectIdFromObjectUrl({ objectUrl: pageUrl }); - expect(result).toBe('12345678901234567890123456789012'); - }); - - 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: No valid Notion ID found'); - }); - - 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: No valid Notion ID found'); - }); - - 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: No valid Notion ID found'); - }); - - it('should extract the database ID from a Notion Database URL', () => { - const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?v=7d43042815524daa9c5c3a7a4f8e1fe4&pvs=4'; - 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', () => { - const pageUrl = 'https://www.notion.so/16d4754ea1e980d1a2fdc2ab5fa4dfaf?pvs=4'; - 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', () => { - it('should lock a page when lockStatus is "locked"', async () => { - const pageId = 'test-page-id'; - const lockStatus = 'locked'; - - jest.spyOn(mockClient.pages, 'update').mockResolvedValue({} as any); - - await repository.setPageLockedStatus({ pageId, lockStatus }); - - expect(mockClient.pages.update).toHaveBeenCalledWith({ - page_id: pageId, - is_locked: true, - }); - }); - - it('should unlock a page when lockStatus is "unlocked"', async () => { - const pageId = 'test-page-id'; - const lockStatus = 'unlocked'; - - jest.spyOn(mockClient.pages, 'update').mockResolvedValue({} as any); - - await repository.setPageLockedStatus({ pageId, lockStatus }); - - expect(mockClient.pages.update).toHaveBeenCalledWith({ - page_id: pageId, - is_locked: false, - }); - }); - - it('should throw an error when the Notion API call fails', async () => { - const pageId = 'test-page-id'; - const lockStatus = 'locked'; - const error = new Error('Notion API error'); - - jest.spyOn(mockClient.pages, 'update').mockRejectedValue(error); - - await expect(repository.setPageLockedStatus({ pageId, lockStatus })).rejects.toThrow('Notion API error'); - }); - }); - - describe('getPageLockedStatus', () => { - it('should return "locked" when page is locked', async () => { - const pageId = 'test-page-id'; - const mockPage = { - id: pageId, - object: 'page', - properties: { - is_locked: true, - }, - } as unknown as PageObjectResponse; - - jest.spyOn(mockClient.pages, 'retrieve').mockResolvedValue(mockPage); - - const result = await repository.getPageLockedStatus({ pageId }); - - expect(result).toBe('locked'); - expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ - page_id: pageId, - }); - }); - - it('should return "unlocked" when page is unlocked', async () => { - const pageId = 'test-page-id'; - const mockPage = { - id: pageId, - object: 'page', - properties: { - is_locked: false, - }, - } as unknown as PageObjectResponse; - - jest.spyOn(mockClient.pages, 'retrieve').mockResolvedValue(mockPage); - - const result = await repository.getPageLockedStatus({ pageId }); - - expect(result).toBe('unlocked'); - expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ - page_id: pageId, - }); - }); - - it('should return "unlocked" when is_locked property is undefined', async () => { - const pageId = 'test-page-id'; - const mockPage = { - id: pageId, - object: 'page', - properties: {}, - } as unknown as PageObjectResponse; - - jest.spyOn(mockClient.pages, 'retrieve').mockResolvedValue(mockPage); - - const result = await repository.getPageLockedStatus({ pageId }); - - expect(result).toBe('unlocked'); - expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ - page_id: pageId, - }); - }); - - it('should return "unlocked" when properties is undefined', async () => { - const pageId = 'test-page-id'; - const mockPage = { - id: pageId, - object: 'page', - } as unknown as PageObjectResponse; - - jest.spyOn(mockClient.pages, 'retrieve').mockResolvedValue(mockPage); - - const result = await repository.getPageLockedStatus({ pageId }); - - expect(result).toBe('unlocked'); - expect(mockClient.pages.retrieve).toHaveBeenCalledWith({ - page_id: pageId, - }); - }); - - it('should throw an error when the Notion API call fails', async () => { - const pageId = 'test-page-id'; - const error = new Error('Notion API error'); - - jest.spyOn(mockClient.pages, 'retrieve').mockRejectedValue(error); - - await expect(repository.getPageLockedStatus({ pageId })).rejects.toThrow('Notion API error'); - }); - }); - - 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); - }); - }); - -}); diff --git a/src/infrastructure/notion/notion.destination.ts b/src/infrastructure/notion/notion.destination.ts deleted file mode 100644 index 46d86a8..0000000 --- a/src/infrastructure/notion/notion.destination.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { Client, isFullPage, LogLevel } from '@notionhq/client'; -import { - BlockObjectResponse, - CreatePageParameters, - GetDatabaseResponse, - GetDataSourceResponse, - PageObjectResponse, - PartialBlockObjectResponse, - UpdatePageParameters, -} from '@notionhq/client/build/src/api-endpoints'; -import winston from 'winston'; - -import { PageElement } from '@/domains/elements/Element'; -import { MK_NOTES_INTERNAL_ID_PROPERTY_NAME } from '@/domains/notion/constants'; -import { - isNotionNestingValidationError, - NotionNestingValidationError, -} from '@/domains/notion/error'; -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'; -import { isBlockEquals } from './utils'; - -export interface UpdatePageInput { - pageId: string; - blocks?: BlockObjectRequest[] | BlockObjectRequestWithoutChildren[]; - title?: string; - icon?: Icon; -} - -export class NotionDestinationRepository - implements DestinationRepository -{ - private client: Client; - private logger: winston.Logger; - private notionConverter: NotionConverterRepository; - - constructor({ - apiKey, - logger, - notionConverter, - }: { - apiKey: string; - logger: winston.Logger; - notionConverter: NotionConverterRepository; - }) { - this.client = new Client({ - auth: apiKey, - logLevel: LogLevel.ERROR, - }); - this.logger = logger; - this.notionConverter = notionConverter; - } - - /** - * Delete all child blocks from a parent page - */ - async deleteChildBlocks({ - parentPageId, - }: { - parentPageId: string; - }): Promise { - try { - // Get all blocks in the parent page - const blocks = await this.getBlocksFromPage({ - notionPageId: parentPageId, - }); - - // Delete each block - for (const block of blocks) { - await this.client.blocks.delete({ block_id: block.id }); - } - } catch (error: unknown) { - // Deletion failed - throw the error to be handled upstream - throw error instanceof Error ? error : new Error(String(error)); - } - } - - getObjectIdFromObjectUrl({ objectUrl }: { objectUrl: string }): string { - 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]; - } - - async destinationIsAccessible({ - parentObjectId, - }: { - parentObjectId: string; - }): Promise { - try { - await this.getPage({ pageId: parentObjectId }); - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (err) { - try { - await this.getDatabaseById({ databaseId: parentObjectId }); - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_err) { - return false; - } - } - } - - async getPageById({ - notionPageId, - }: { - notionPageId: string; - }): Promise { - const pageObjectResponse = await this.client.pages.retrieve({ - page_id: notionPageId, - }); - - if (!isFullPage(pageObjectResponse)) { - throw new Error('Not able to retrieve Notion Page'); - } - - const blocks = await this.getBlocksFromPage({ notionPageId }); - - return new NotionPage({ - pageId: pageObjectResponse.id, - children: blocks, - createdAt: new Date(pageObjectResponse.created_time), - updatedAt: new Date(pageObjectResponse.last_edited_time), - isLocked: pageObjectResponse.is_locked ?? false, - }); - } - - async createPage({ - parentObjectId, - parentObjectType, - pageElement, - filePath, - }: { - parentObjectId: string; - parentObjectType: ObjectType; - pageElement: PageElement; - filePath?: string; - }): Promise { - // Set the current file path for image resolution - if (filePath) { - this.notionConverter.setCurrentFilePath(filePath); - } - - 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, - properties: notionPage.properties as CreatePageParameters['properties'], - icon: notionPage.icon, - children: [], // Create page without children initially - }); - - // If there are children blocks, append them in chunks - if (notionPage.children && notionPage.children.length > 0) { - const children = notionPage.children as BlockObjectRequest[]; - - // Split children into chunks of 100 blocks - for (let i = 0; i < children.length; i += NOTION_BLOCK_LIMIT) { - const chunk = children.slice(i, i + NOTION_BLOCK_LIMIT); - await this.client.blocks.children.append({ - block_id: notionPageId, - children: chunk, - }); - } - } - - return this.getPageById({ notionPageId }); - } - - async updateBlock({ - blockId, - block, - }: { - blockId: string; - block: BlockObjectRequest; - }) { - return this.client.blocks.update({ - block_id: blockId, - ...block, - }); - } - - async getPage({ pageId }: { pageId: string }): Promise { - const page = await this.client.pages.retrieve({ page_id: pageId }); - - return page as PageObjectResponse; - } - - async getChildBlocksFromBlock({ - blockId, - }: { - blockId: string; - }): Promise { - const response = await this.client.blocks.children.list({ - block_id: blockId, - }); - return response.results as BlockObjectResponse[]; - } - - async getBlocksFromPage({ - notionPageId, - }: { - notionPageId: string; - }): Promise<(BlockObjectResponse | PartialBlockObjectResponse)[]> { - const blocks = await this.client.blocks.children.list({ - block_id: notionPageId, - }); - - return blocks.results; - } - async updatePage({ - pageId, - pageElement, - filePath, - }: { - pageId: string; - pageElement: PageElement; - filePath?: string; - }): Promise { - const notionPageId = pageId; - - // Set the current file path for image resolution - if (filePath) { - this.notionConverter.setCurrentFilePath(filePath); - } - - const notionPage = - await this.notionConverter.convertFromElement(pageElement); - - const updateBody: UpdatePageParameters = { - page_id: notionPageId, - properties: {}, - }; - - if (notionPage.icon) { - updateBody.icon = notionPage.icon; - } - - if (notionPage?.properties?.Name) { - updateBody.properties!['Title'] = notionPage.properties - .Title as TitleProperty; - } - - await this.client.pages.update({ - page_id: notionPageId, - icon: notionPage.icon, - properties: updateBody.properties, - }); - - const existingBlocks = await this.getChildBlocksFromBlock({ - blockId: notionPageId, - }); - - const pageBlocks = existingBlocks; - - if (notionPage.children && notionPage.children?.length > 0) { - const blocks = notionPage.children; - - const promises = existingBlocks - .filter((existingBlock, index) => { - // @ts-expect-error - We know that the blocks are not equal - return !isBlockEquals(blocks[index], existingBlock); - }) - .map(async (existingBlock, index) => - this.client.blocks - .update({ - block_id: existingBlock.id, - ...blocks[index], - }) - .then((block) => { - pageBlocks[index] = block as BlockObjectResponse; - }) - ); - - await Promise.all(promises); - } - // Now it's time to compare the existing blocks with the new blocks - // and update the existing blocks with the new ones - - return this.getPageById({ notionPageId }); - } - - // Used for root level index.md where the page is already present - async appendToPage({ - pageId, - pageElement, - }: { - pageId: string; - pageElement: PageElement; - }): Promise { - const notionPage = - await this.notionConverter.convertFromElement(pageElement); - - // Update page properties (title/icon) if specified in metadata - await this.updatePageProperties({ pageId, pageElement }); - - if (notionPage.children && notionPage.children.length > 0) { - // Append blocks to the existing page - try { - await this.client.blocks.children.append({ - block_id: pageId, - children: notionPage.children as BlockObjectRequest[], - }); - } catch (error) { - if (isNotionNestingValidationError(error)) { - throw new NotionNestingValidationError({ message: 'Nesting error' }); - } - - this.logger.debug(`Failed to append block to page ${pageId}:`, { - error, - block: notionPage.children, - }); - throw error; - } - } - } - - async updatePageProperties({ - pageId, - pageElement, - }: { - pageId: string; - pageElement: PageElement; - }): Promise { - const notionPage = - await this.notionConverter.convertFromElement(pageElement); - - // Only update if there are properties to update - if (notionPage.properties || notionPage.icon) { - // Update page properties and icon separately to avoid type conflicts - const updatePayload: { - page_id: string; - properties?: unknown; - icon?: unknown; - } = { - page_id: pageId, - }; - - if (notionPage.properties) { - updatePayload.properties = notionPage.properties; - } - - if (notionPage.icon) { - updatePayload.icon = notionPage.icon; - } - - await this.client.pages.update( - updatePayload as Parameters[0] - ); - } - } - - async search({ - filter, - }: { - filter: { property: 'object'; value: 'page' | 'data_source' }; - }) { - return this.client.search({ - filter, - }); - } - - async setPageLockedStatus({ - pageId, - lockStatus, - }: { - pageId: string; - lockStatus: PageLockedStatus; - }): Promise { - const isLocked = lockStatus === 'locked'; - - await this.client.pages.update({ - page_id: pageId, - is_locked: isLocked, - }); - } - - async getPageLockedStatus({ - pageId, - }: { - pageId: string; - }): Promise { - const page = await this.client.pages.retrieve({ page_id: pageId }); - - const isLocked = (page as PageObjectResponse).properties?.is_locked; - - if (isLocked === undefined) { - return 'unlocked'; - } - - 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 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, - }: { - datasourceId: string; - }): Promise { - return this.client.dataSources.retrieve({ - 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 }); - } -} diff --git a/src/infrastructure/html/html.parser.test.ts b/src/infrastructure/parsers/html/__tests__/html.parser.test.ts similarity index 98% rename from src/infrastructure/html/html.parser.test.ts rename to src/infrastructure/parsers/html/__tests__/html.parser.test.ts index 160ef30..f7229bc 100644 --- a/src/infrastructure/html/html.parser.test.ts +++ b/src/infrastructure/parsers/html/__tests__/html.parser.test.ts @@ -1,4 +1,4 @@ -import { HtmlParser } from './html.parser'; +import { HtmlParser } from '../html.parser'; import { ElementCodeLanguage, TextElementStyle } from '@/domains/elements'; import winston from 'winston'; diff --git a/src/infrastructure/html/html.parser.ts b/src/infrastructure/parsers/html/html.parser.ts similarity index 100% rename from src/infrastructure/html/html.parser.ts rename to src/infrastructure/parsers/html/html.parser.ts diff --git a/src/infrastructure/html/index.ts b/src/infrastructure/parsers/html/index.ts similarity index 100% rename from src/infrastructure/html/index.ts rename to src/infrastructure/parsers/html/index.ts diff --git a/src/infrastructure/parsers/markdown/__tests__/markdown.parser.test.ts b/src/infrastructure/parsers/markdown/__tests__/markdown.parser.test.ts new file mode 100644 index 0000000..c7e369a --- /dev/null +++ b/src/infrastructure/parsers/markdown/__tests__/markdown.parser.test.ts @@ -0,0 +1,571 @@ +import { MarkdownParser } from '../markdown.parser'; +import { HtmlParser } from '@/infrastructure/parsers/html'; +import { + TextElementLevel, + TextElementStyle, + ElementCodeLanguage, + SupportedEmoji, + ElementType, + TextElement, + ListItemElement, + QuoteElement, + CalloutElement, + LinkElement, + ImageElement, + EquationElement, +} from '@/domains/elements'; +import winston from 'winston'; +import { assert } from 'console'; + +describe('MarkdownParser', () => { + let parser: MarkdownParser; + let mockHtmlParser: jest.Mocked; + let mockLogger: winston.Logger; + + beforeEach(() => { + mockHtmlParser = { + parse: jest.fn().mockReturnValue({ content: [] }), + } as unknown as jest.Mocked; + + mockLogger = { + debug: jest.fn(), + error: jest.fn(), + } as unknown as winston.Logger; + + parser = new MarkdownParser({ + htmlParser: mockHtmlParser, + logger: mockLogger, + }); + }); + + describe('parse', () => { + it('should parse headings with different levels', () => { + const markdown = ` +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(6); + expect(result.content[0]).toMatchObject({ + text: 'Heading 1', + level: TextElementLevel.Heading1, + }); + expect(result.content[5]).toMatchObject({ + text: 'Heading 6', + level: TextElementLevel.Heading6, + }); + }); + + it('should parse text styling', () => { + const markdown = ` +**Bold text** +*Italic text* +~~Strikethrough text~~ +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + + assert(result.content[0] instanceof TextElement); + expect(result.content[0].type).toBe(ElementType.Text); + + const textElement = result.content[0] as TextElement; + + expect(textElement).toBeInstanceOf(TextElement); + + expect(textElement.text).toMatchObject([ + { text: 'Bold text', styles: { bold: true } }, + { text: '\n', styles: { bold: false } }, + { text: 'Italic text', styles: { italic: true } }, + { text: '\n', styles: { italic: false } }, + { text: 'Strikethrough text', styles: { strikethrough: true } }, + ]); + }); + + it('should parse inline code with single backticks', () => { + const markdown = ` +This is normal text with \`inline code\` and more text. +Another line with \`code\` and **bold \`code in bold\`** text. +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + + assert(result.content[0] instanceof TextElement); + const textElement = result.content[0] as TextElement; + + expect(textElement).toBeInstanceOf(TextElement); + + // Check that we have the right structure based on actual parsed output + expect(textElement.text).toHaveLength(7); + + // First text: "This is normal text with " + const text1 = textElement.text[0] as TextElement; + expect(text1).toBeInstanceOf(TextElement); + expect(text1).toMatchObject({ + text: 'This is normal text with ', + styles: { bold: false, italic: false, strikethrough: false, underline: false, code: false } + }); + + // First inline code: "inline code" - THIS IS THE KEY TEST! + const codeElement1 = textElement.text[1] as TextElement; + expect(codeElement1).toBeInstanceOf(TextElement); + expect(codeElement1).toMatchObject({ + text: 'inline code', + styles: { bold: false, italic: false, strikethrough: false, underline: false, code: true } + }); + + // Text between: " and more text.\n\nAnother line with " + const text2 = textElement.text[2] as TextElement; + expect(text2).toBeInstanceOf(TextElement); + expect(text2.styles).toMatchObject({ + bold: false, italic: false, strikethrough: false, underline: false, code: false + }); + + // Second inline code: "code" - ANOTHER KEY TEST! + const codeElement2 = textElement.text[3] as TextElement; + expect(codeElement2).toBeInstanceOf(TextElement); + expect(codeElement2).toMatchObject({ + text: 'code', + styles: { bold: false, italic: false, strikethrough: false, underline: false, code: true } + }); + }); + + it('should parse lists', () => { + const markdown = ` +- Unordered item 1 +- Unordered item 2 +1. Ordered item 1 +2. Ordered item 2 +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(4); + assert(result.content[0] instanceof ListItemElement); + const listItemElement = result.content[0] as ListItemElement; + + expect(listItemElement).toBeInstanceOf(ListItemElement); + + expect(listItemElement.listType).toBe('unordered'); + + assert(listItemElement.text[0] instanceof TextElement); + const textElement = listItemElement.text[0] as TextElement; + + expect(textElement).toBeInstanceOf(TextElement); + expect(textElement).toMatchObject({ text: 'Unordered item 1', styles: { bold: false, italic: false, strikethrough: false, underline: false } }); + + assert(result.content[1] instanceof ListItemElement); + const listItemElement2 = result.content[1] as ListItemElement; + + expect(listItemElement2).toBeInstanceOf(ListItemElement); + + expect(listItemElement2.listType).toBe('unordered'); + + assert(listItemElement2.text[0] instanceof TextElement); + const textElement2 = listItemElement2.text[0] as TextElement; + + expect(textElement2).toBeInstanceOf(TextElement); + expect(textElement2).toMatchObject({ text: 'Unordered item 2', styles: { bold: false, italic: false, strikethrough: false, underline: false } }); + + assert(result.content[2] instanceof ListItemElement); + const listItemElement3 = result.content[2] as ListItemElement; + + expect(listItemElement3).toBeInstanceOf(ListItemElement); + + expect(listItemElement3.listType).toBe('ordered'); + + assert(listItemElement3.text[0] instanceof TextElement); + const textElement3 = listItemElement3.text[0] as TextElement; + + expect(textElement3).toBeInstanceOf(TextElement); + expect(textElement3).toMatchObject({ text: 'Ordered item 1', styles: { bold: false, italic: false, strikethrough: false, underline: false } }); + + + }); + + it('should parse nested lists', () => { + const markdown = ` +- **Scope:** This applies to all deployment environments. +- **Code Repositories:** + - **System A:** [Module Link](https://github.com/example/module-a) **|** [Configuration File](https://github.com/example/repo-a/config.tf) + - **System B:** [Network Modules](https://github.com/example/system-b/modules) **|** [Settings File](https://github.com/example/system-b/settings.tf) +- **Documentation:** + - **System A:** [README Documentation](https://github.com/example/repo-a/README.md) + - **System B:** + - [Review Process](https://github.com/example/system-b/docs/review-process.md) + - [Technical Deep Dive](https://github.com/example/system-b/docs/technical-guide.md) +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(3); + + // First item - simple item without nested content + assert(result.content[0] instanceof ListItemElement); + const firstItem = result.content[0] as ListItemElement; + expect(firstItem.listType).toBe('unordered'); + expect(firstItem.children).toBeUndefined(); + + // Second item - has nested list + assert(result.content[1] instanceof ListItemElement); + const secondItem = result.content[1] as ListItemElement; + expect(secondItem.listType).toBe('unordered'); + expect(secondItem.children).toBeDefined(); + expect(secondItem.children).toHaveLength(2); + + // Check nested items in second item + assert(secondItem.children![0] instanceof ListItemElement); + const nestedItem1 = secondItem.children![0] as ListItemElement; + expect(nestedItem1.listType).toBe('unordered'); + + assert(secondItem.children![1] instanceof ListItemElement); + const nestedItem2 = secondItem.children![1] as ListItemElement; + expect(nestedItem2.listType).toBe('unordered'); + + // Third item - has nested list with deeper nesting + assert(result.content[2] instanceof ListItemElement); + const thirdItem = result.content[2] as ListItemElement; + expect(thirdItem.listType).toBe('unordered'); + expect(thirdItem.children).toBeDefined(); + expect(thirdItem.children).toHaveLength(2); + + // Check nested items in third item + assert(thirdItem.children![0] instanceof ListItemElement); + const thirdNestedItem1 = thirdItem.children![0] as ListItemElement; + expect(thirdNestedItem1.listType).toBe('unordered'); + expect(thirdNestedItem1.children).toBeUndefined(); + + assert(thirdItem.children![1] instanceof ListItemElement); + const thirdNestedItem2 = thirdItem.children![1] as ListItemElement; + expect(thirdNestedItem2.listType).toBe('unordered'); + expect(thirdNestedItem2.children).toBeDefined(); + expect(thirdNestedItem2.children).toHaveLength(2); + + // Check deeply nested items + assert(thirdNestedItem2.children![0] instanceof ListItemElement); + const deepNestedItem1 = thirdNestedItem2.children![0] as ListItemElement; + expect(deepNestedItem1.listType).toBe('unordered'); + + assert(thirdNestedItem2.children![1] instanceof ListItemElement); + const deepNestedItem2 = thirdNestedItem2.children![1] as ListItemElement; + expect(deepNestedItem2.listType).toBe('unordered'); + }); + + it('should parse mixed ordered and unordered nested lists', () => { + const markdown = ` +1. First ordered item +2. Second ordered item with nested unordered list: + - Unordered nested item 1 + - Unordered nested item 2 +3. Third ordered item +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(3); + + // First item - simple ordered item + assert(result.content[0] instanceof ListItemElement); + const firstItem = result.content[0] as ListItemElement; + expect(firstItem.listType).toBe('ordered'); + expect(firstItem.children).toBeUndefined(); + + // Second item - ordered item with nested unordered list + assert(result.content[1] instanceof ListItemElement); + const secondItem = result.content[1] as ListItemElement; + expect(secondItem.listType).toBe('ordered'); + expect(secondItem.children).toBeDefined(); + expect(secondItem.children).toHaveLength(2); + + // Check nested unordered items + assert(secondItem.children![0] instanceof ListItemElement); + const nestedItem1 = secondItem.children![0] as ListItemElement; + expect(nestedItem1.listType).toBe('unordered'); + + assert(secondItem.children![1] instanceof ListItemElement); + const nestedItem2 = secondItem.children![1] as ListItemElement; + expect(nestedItem2.listType).toBe('unordered'); + + // Third item - simple ordered item + assert(result.content[2] instanceof ListItemElement); + const thirdItem = result.content[2] as ListItemElement; + expect(thirdItem.listType).toBe('ordered'); + expect(thirdItem.children).toBeUndefined(); + }); + + it('should parse code blocks with language', () => { + const markdown = '```typescript\nconst x = 1;\n```'; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ + text: 'const x = 1;', + language: ElementCodeLanguage.TypeScript, + }); + }); + + it('should parse mermaid code blocks', () => { + const markdown = '```mermaid\ngraph TD;\n A-->B;\n```'; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ + text: 'graph TD;\n A-->B;', + language: ElementCodeLanguage.Mermaid, + }); + }); + + it('should parse simple blockquotes', () => { + const markdown = ` +> Regular quote +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + + assert(result.content[0] instanceof QuoteElement); + const quoteElement = result.content[0] as QuoteElement; + + expect(quoteElement).toBeInstanceOf(QuoteElement); + + }); + + it('should parse callouts', () => { + const markdown = ` +> [!NOTE] This is a callout +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + + assert(result.content[0] instanceof CalloutElement); + const calloutElement = result.content[0] as CalloutElement; + + expect(calloutElement).toBeInstanceOf(CalloutElement); + expect(calloutElement).toMatchObject({ text: 'This is a callout', icon: '💡' }); + }); + + it('should parse tables', () => { + const markdown = ` +| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 | +`; + + const result = parser.parse({ content: markdown }); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ + rows: [ + ['Header 1', 'Header 2'], + ['Cell 1', 'Cell 2'], + ], + }); + }); + + it('should parse links and images', () => { + const markdown = ` +[Link text](https://example.com) +![Image alt](https://example.com/image.jpg) +`; + + const result = parser.parse({ content: markdown }); + + const textElement = result.content[0] as TextElement; + + expect(textElement).toBeInstanceOf(TextElement); + expect(textElement.text).toHaveLength(3); + const linkElement = textElement.text[0] as LinkElement; + + expect(linkElement).toBeInstanceOf(LinkElement); + expect(linkElement).toMatchObject({ text: 'Link text', url: 'https://example.com' }); + + const newLineElement = textElement.text[1] as TextElement; + expect(newLineElement).toBeInstanceOf(TextElement); + expect(newLineElement).toMatchObject({ text: '\n', styles: { bold: false, italic: false, strikethrough: false, underline: false } }); + + const imageElement = textElement.text[2] as ImageElement; + + expect(imageElement).toBeInstanceOf(ImageElement); + expect(imageElement).toMatchObject({ url: 'https://example.com/image.jpg', caption: 'Image alt' }); + }); + + it('should parse equations', () => { + const markdown = ` +$$ +a^2 + b^2 = c^2 +$$ + +This is an inline equation: $E=mc^2$. + +- This is an item with an equation: $E=mc^2$. +`; + + const result = parser.parse({ content: markdown }); + expect(result.content).toHaveLength(3); + + const blockEquation = result.content[0] as EquationElement; + expect(blockEquation).toBeInstanceOf(EquationElement); + expect(blockEquation).toMatchObject({ equation: 'a^2 + b^2 = c^2' }); + + const textWithInlineEquation = result.content[1] as TextElement; + expect(textWithInlineEquation).toBeInstanceOf(TextElement); + expect(textWithInlineEquation.text).toHaveLength(3); + + const textPart = textWithInlineEquation.text[0] as TextElement; + expect(textPart).toBeInstanceOf(TextElement); + expect(textPart.text).toBe('This is an inline equation: '); + + const inlineEquation = textWithInlineEquation.text[1] as EquationElement; + expect(inlineEquation).toBeInstanceOf(EquationElement); + expect(inlineEquation).toMatchObject({ equation: 'E=mc^2' }); + + const textPart2 = textWithInlineEquation.text[2] as TextElement; + expect(textPart2).toBeInstanceOf(TextElement); + expect(textPart2.text).toBe('.'); + + const listItemWithEquation = result.content[2] as ListItemElement; + expect(listItemWithEquation).toBeInstanceOf(ListItemElement); + expect(listItemWithEquation.text).toHaveLength(3); + + const listItemTextPart = listItemWithEquation.text[0] as TextElement; + expect(listItemTextPart).toBeInstanceOf(TextElement); + expect(listItemTextPart.text).toBe('This is an item with an equation: '); + + const listItemInlineEquation = listItemWithEquation.text[1] as EquationElement; + expect(listItemInlineEquation).toBeInstanceOf(EquationElement); + expect(listItemInlineEquation.equation).toBe('E=mc^2'); + + const listItemTextPart2 = listItemWithEquation.text[2] as TextElement; + expect(listItemTextPart2).toBeInstanceOf(TextElement); + expect(listItemTextPart2.text).toBe('.'); + }); + + it('should parse front matter metadata', () => { + const markdown = `--- +title: Test Title +icon: 👋 +--- +Content +`; + + const result = parser.parse({ content: markdown }); + + expect(result.title).toBe('Test Title'); + expect(result.icon).toBe('👋' as SupportedEmoji); + 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.id).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.id).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.id).toBeUndefined(); + expect(result.properties).toBeUndefined(); + }); + + it('should handle HTML content', () => { + const markdown = '
HTML content
'; + mockHtmlParser.parse.mockReturnValue({ + content: [new TextElement({ text: 'Parsed HTML' })], + }); + + const result = parser.parse({ content: markdown }); + + expect(mockHtmlParser.parse).toHaveBeenCalledWith({ + content: markdown, + }); + expect(result.content).toHaveLength(1); + }); + + it.failing('should handle mixed inline styles', () => { + const markdown = 'This is **bold and *italic* text**'; + + const result = parser.parse({ content: markdown }); + + assert(result.content[0] instanceof TextElement); + const textElement = result.content[0] as TextElement; + + expect(textElement).toBeInstanceOf(TextElement); + expect(textElement.text).toHaveLength(5); + + const boldElement = textElement.text[0] as TextElement; + expect(boldElement).toBeInstanceOf(TextElement); + expect(boldElement).toMatchObject({ text: 'This is ', styles: { bold: false, italic: false , strikethrough: false, underline: false } }); + + const italicElement = textElement.text[1] as TextElement; + expect(italicElement).toBeInstanceOf(TextElement); + expect(italicElement).toMatchObject({ text: 'bold and ', styles: { bold: false, italic: true, strikethrough: false, underline: false } }); + + const boldItalicElement = textElement.text[2] as TextElement; + expect(boldItalicElement).toBeInstanceOf(TextElement); + expect(boldItalicElement).toMatchObject({ text: 'italic', styles: { bold: true, italic: true, strikethrough: false, underline: false } }); + + const textElement2 = textElement.text[3] as TextElement; + expect(textElement2).toBeInstanceOf(TextElement); + expect(textElement2).toMatchObject({ text: ' text', styles: { bold: false, italic: false, strikethrough: false, underline: false } }); + + }); + }); +}); diff --git a/src/infrastructure/markdown/index.ts b/src/infrastructure/parsers/markdown/index.ts similarity index 100% rename from src/infrastructure/markdown/index.ts rename to src/infrastructure/parsers/markdown/index.ts diff --git a/src/infrastructure/markdown/markdown.parser.test.ts b/src/infrastructure/parsers/markdown/markdown.parser.test.ts similarity index 98% rename from src/infrastructure/markdown/markdown.parser.test.ts rename to src/infrastructure/parsers/markdown/markdown.parser.test.ts index 730fbd8..a1a900a 100644 --- a/src/infrastructure/markdown/markdown.parser.test.ts +++ b/src/infrastructure/parsers/markdown/markdown.parser.test.ts @@ -1,5 +1,5 @@ import { MarkdownParser } from './markdown.parser'; -import { HtmlParser } from '@/infrastructure/html'; +import { HtmlParser } from '@/infrastructure/parsers/html'; import { TextElementLevel, TextElementStyle, @@ -384,7 +384,6 @@ Another line with \`code\` and **bold \`code in bold\`** text. const result = parser.parse({ content: markdown }); - console.log({result}); const textElement = result.content[0] as TextElement; expect(textElement).toBeInstanceOf(TextElement); @@ -482,7 +481,7 @@ Content const result = parser.parse({ content: markdown }); expect(result.title).toBe('Test Title'); - expect(result.mkNotesInternalId).toBe('unique-page-id-123'); + expect(result.id).toBe('unique-page-id-123'); expect(result.icon).toBe('📄' as SupportedEmoji); expect(result.content).toHaveLength(1); }); @@ -503,7 +502,7 @@ Content const result = parser.parse({ content: markdown }); expect(result.title).toBe('Database Entry'); - expect(result.mkNotesInternalId).toBe('entry-001'); + expect(result.id).toBe('entry-001'); expect(result.properties).toBeDefined(); expect(result.properties).toHaveLength(2); expect(result.properties).toEqual([ @@ -522,7 +521,7 @@ Content const result = parser.parse({ content: markdown }); expect(result.title).toBe('Simple Page'); - expect(result.mkNotesInternalId).toBeUndefined(); + expect(result.id).toBeUndefined(); expect(result.properties).toBeUndefined(); }); diff --git a/src/infrastructure/markdown/markdown.parser.ts b/src/infrastructure/parsers/markdown/markdown.parser.ts similarity index 94% rename from src/infrastructure/markdown/markdown.parser.ts rename to src/infrastructure/parsers/markdown/markdown.parser.ts index dbc5e92..9eae1b3 100644 --- a/src/infrastructure/markdown/markdown.parser.ts +++ b/src/infrastructure/parsers/markdown/markdown.parser.ts @@ -23,7 +23,7 @@ import { TextElement, TextElementLevel, } from '@/domains/elements'; -import { HtmlParser } from '@/infrastructure/html'; +import { HtmlParser } from '@/infrastructure/parsers/html'; import { EquationToken, ExtendedToken } from './types'; @@ -36,7 +36,8 @@ export interface MarkdownMetadata { export class MarkdownParser extends ParserRepository { private htmlParser: HtmlParser; - private currentFilePath?: string; + // Used during synchronous parse() call to provide context for image paths + private parsingFilePath?: string; constructor({ htmlParser, @@ -51,10 +52,6 @@ export class MarkdownParser extends ParserRepository { marked.use(markedKatex({ throwOnError: false, nonStandard: true })); } - setCurrentFilePath(filePath: string): void { - this.currentFilePath = filePath; - } - private preParseMarkdown(src: string): ExtendedToken[] { const { body } = fm(src); return marked.lexer(body); @@ -189,7 +186,7 @@ export class MarkdownParser extends ParserRepository { return new ImageElement({ url: token.href, caption: token.text, - filepath: this.currentFilePath, + filepath: this.parsingFilePath, }); } @@ -203,6 +200,7 @@ export class MarkdownParser extends ParserRepository { return new LinkElement({ text: token.text, url: token.href, + filepath: this.parsingFilePath, }); } @@ -410,7 +408,16 @@ export class MarkdownParser extends ParserRepository { return elements; } - parse({ content }: { content: string }): ParseResult { + parse({ + content, + filepath, + }: { + content: string; + filepath?: string; + }): ParseResult { + // Set the filepath context for use during this synchronous parse operation + this.parsingFilePath = filepath; + const tokens = this.preParseMarkdown(content); const elements: Element[] = []; @@ -426,7 +433,7 @@ export class MarkdownParser extends ParserRepository { const fileMetadata = this.getMetadata(content); if (fileMetadata.id) { - result.mkNotesInternalId = fileMetadata.id; + result.id = fileMetadata.id; } if (fileMetadata.title) { @@ -441,6 +448,9 @@ export class MarkdownParser extends ParserRepository { result.properties = fileMetadata.properties; } + // Clear the filepath context after parsing + this.parsingFilePath = undefined; + return result; } } diff --git a/src/infrastructure/markdown/types.ts b/src/infrastructure/parsers/markdown/types.ts similarity index 100% rename from src/infrastructure/markdown/types.ts rename to src/infrastructure/parsers/markdown/types.ts diff --git a/src/infrastructure/filesystem/fileSystem.source.test.ts b/src/infrastructure/sources/filesystem/__tests__/fileSystem.source.test.ts similarity index 94% rename from src/infrastructure/filesystem/fileSystem.source.test.ts rename to src/infrastructure/sources/filesystem/__tests__/fileSystem.source.test.ts index 72eccd6..6546500 100644 --- a/src/infrastructure/filesystem/fileSystem.source.test.ts +++ b/src/infrastructure/sources/filesystem/__tests__/fileSystem.source.test.ts @@ -1,6 +1,6 @@ import { accessSync, constants, readdirSync, readFileSync, statSync } from 'fs'; import { join } from 'path'; -import { FileSystemSourceRepository } from './fileSystem.source'; +import { FileSystemSourceRepository } from '../fileSystem.source'; jest.mock('fs'); @@ -160,11 +160,12 @@ describe('FileSystemSourceRepository', () => { const result = await repository.getFile({ path: '/test/path/file.md' }); - expect(result).toEqual({ + expect(result).toMatchObject({ name: 'file', content: 'file content', extension: 'md', - lastUpdated: mockDate + lastUpdated: mockDate, + path: '/test/path/file.md', }); }); @@ -175,11 +176,12 @@ describe('FileSystemSourceRepository', () => { const result = await repository.getFile({ path: '/test/path/README' }); - expect(result).toEqual({ + expect(result).toMatchObject({ name: 'README', content: 'file content', extension: '', - lastUpdated: mockDate + lastUpdated: mockDate, + path: '/test/path/README', }); }); @@ -191,11 +193,12 @@ describe('FileSystemSourceRepository', () => { const result = await repository.getFile({ path: '/test/docs/index.md' }); - expect(result).toEqual({ + expect(result).toMatchObject({ name: 'index', content: '# Some Title\nContent here', extension: 'md', - lastUpdated: mockDate + lastUpdated: mockDate, + path: '/test/docs/index.md', }); }); @@ -207,11 +210,12 @@ describe('FileSystemSourceRepository', () => { const result = await repository.getFile({ path: 'index.md' }); - expect(result).toEqual({ + expect(result).toMatchObject({ name: 'index', content: '# My Amazing Project\nWelcome to my project', extension: 'md', - lastUpdated: mockDate + lastUpdated: mockDate, + path: 'index.md', }); }); diff --git a/src/infrastructure/filesystem/fileSystem.source.ts b/src/infrastructure/sources/filesystem/fileSystem.source.ts similarity index 92% rename from src/infrastructure/filesystem/fileSystem.source.ts rename to src/infrastructure/sources/filesystem/fileSystem.source.ts index e61ccc1..5b0476b 100644 --- a/src/infrastructure/filesystem/fileSystem.source.ts +++ b/src/infrastructure/sources/filesystem/fileSystem.source.ts @@ -1,4 +1,11 @@ -import { accessSync, constants, readdirSync, readFileSync, statSync } from 'fs'; +import { + accessSync, + constants, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'fs'; import { basename, extname, join } from 'path'; import { File } from '@/domains/synchronization'; @@ -133,11 +140,17 @@ export class FileSystemSourceRepository name = base.slice(0, -3); } - return { + return new File({ name, content: readFileSync(path, 'utf-8'), extension: extname(path).slice(1), lastUpdated: this.getLastUpdatedDate(path), - }; + path, + }); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async updateFile(file: File): Promise { + return writeFileSync(file.path, file.content, 'utf-8'); } } diff --git a/sync/index.js b/sync/index.js index b01126d..67c44b4 100644 --- a/sync/index.js +++ b/sync/index.js @@ -14894,7 +14894,7 @@ module.exports.Type = __nccwpck_require__(7600); module.exports.Schema = __nccwpck_require__(5947); module.exports.FAILSAFE_SCHEMA = __nccwpck_require__(7891); module.exports.JSON_SCHEMA = __nccwpck_require__(6188); -module.exports.CORE_SCHEMA = __nccwpck_require__(8733); +module.exports.CORE_SCHEMA = __nccwpck_require__(3495); module.exports.DEFAULT_SAFE_SCHEMA = __nccwpck_require__(2685); module.exports.DEFAULT_FULL_SCHEMA = __nccwpck_require__(4637); module.exports.load = loader.load; @@ -17747,7 +17747,7 @@ module.exports = Schema; /***/ }), -/***/ 8733: +/***/ 3495: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { "use strict"; @@ -17825,7 +17825,7 @@ var Schema = __nccwpck_require__(5947); module.exports = new Schema({ include: [ - __nccwpck_require__(8733) + __nccwpck_require__(3495) ], implicit: [ __nccwpck_require__(4867), @@ -19617,7 +19617,7 @@ module.exports = $gOPD; var origSymbol = typeof Symbol !== 'undefined' && Symbol; -var hasSymbolSham = __nccwpck_require__(5876); +var hasSymbolSham = __nccwpck_require__(8733); /** @type {import('.')} */ module.exports = function hasNativeSymbols() { @@ -19632,7 +19632,7 @@ module.exports = function hasNativeSymbols() { /***/ }), -/***/ 5876: +/***/ 8733: /***/ ((module) => { "use strict"; @@ -19691,7 +19691,7 @@ module.exports = function hasSymbols() { "use strict"; -var hasSymbols = __nccwpck_require__(5876); +var hasSymbols = __nccwpck_require__(8733); /** @type {import('.')} */ module.exports = function hasToStringTagShams() { @@ -75020,7 +75020,7 @@ class MkNotes { /** * Synchronize a markdown file to Notion */ - async synchronizeMarkdownToNotionFromFileSystem({ inputPath, parentNotionPageId, cleanSync = false, lockPage = false, }) { + async synchronizeMarkdownToNotionFromFileSystem({ inputPath, parentNotionPageId, cleanSync = false, lockPage = false, saveId = false, forceNew = false, }) { const synchronizeMarkdownToNotion = new domains_1.SynchronizeMarkdownToNotion({ logger: this.logger, destinationRepository: this.infrastructureInstances.notionDestination, @@ -75032,6 +75032,8 @@ class MkNotes { notionParentPageUrl: parentNotionPageId, cleanSync, lockPage, + saveId, + forceNew, }); } } @@ -75057,6 +75059,8 @@ var Inputs; Inputs["NotionApiKey"] = "notion-api-key"; Inputs["Destination"] = "destination"; Inputs["Lock"] = "lock"; + Inputs["SaveId"] = "save-id"; + Inputs["ForceNew"] = "force-new"; })(Inputs || (Inputs = {})); const sync = async (earlyExit = false) => { try { @@ -75065,6 +75069,8 @@ const sync = async (earlyExit = false) => { const notionApiKey = (0, core_1.getInput)(Inputs.NotionApiKey, { required: true }); const clean = (0, utils_1.getInputAsBool)(Inputs.Clean); const lock = (0, utils_1.getInputAsBool)(Inputs.Lock) ?? false; + const saveId = (0, utils_1.getInputAsBool)(Inputs.SaveId); + const forceNew = (0, utils_1.getInputAsBool)(Inputs.ForceNew); const mkNotes = new MkNotes_1.MkNotes({ notionApiKey, }); @@ -75073,6 +75079,8 @@ const sync = async (earlyExit = false) => { parentNotionPageId: destination, cleanSync: clean, lockPage: lock, + saveId: saveId, + forceNew: forceNew, }); // node will stay alive if any promises are not resolved, // which is a possibility if HTTP requests are dangling @@ -75148,15 +75156,15 @@ function getInputAsBool(name, options) { /***/ }), -/***/ 7512: +/***/ 9548: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.CalloutElement = exports.SpecialCalloutType = void 0; -const Element_class_1 = __nccwpck_require__(5238); -const types_1 = __nccwpck_require__(9461); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); const specialCalloutRegex = // eslint-disable-next-line no-useless-escape /^\s*\[\!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*)/ims; @@ -75175,8 +75183,8 @@ class CalloutElement extends Element_class_1.Element { static isSpecialCalloutText(text) { return specialCalloutRegex.test(text.trim()); } - constructor({ icon, text }) { - super(types_1.ElementType.Callout); + constructor({ id, icon, text, }) { + super({ id, type: types_1.ElementType.Callout }); this.icon = icon; this.text = text; const { text: parsedText, calloutType } = this.getSpecialCalloutTypeAndText(text); @@ -75220,21 +75228,24 @@ class CalloutElement extends Element_class_1.Element { } return this.icon; } + toContentString() { + return `[!${this.calloutType}](${this.text})`; + } } exports.CalloutElement = CalloutElement; /***/ }), -/***/ 9769: +/***/ 1925: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); var ElementCodeLanguage; (function (ElementCodeLanguage) { ElementCodeLanguage["JavaScript"] = "javascript"; @@ -75266,29 +75277,35 @@ exports.isElementCodeLanguage = isElementCodeLanguage; class CodeElement extends Element_class_1.Element { language; text; - constructor({ language, text, }) { - super(types_1.ElementType.Code); + constructor({ id, language, text, }) { + super({ id, type: types_1.ElementType.Code }); this.language = language; this.text = text; } + toContentString() { + return `\`\`\`${this.language}\n${this.text}\n\`\`\``; + } } exports.CodeElement = CodeElement; /***/ }), -/***/ 1983: +/***/ 9755: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class DividerElement extends Element_class_1.Element { - constructor() { - super(types_1.ElementType.Divider); + constructor({ id } = { id: undefined }) { + super({ id, type: types_1.ElementType.Divider }); + } + toContentString() { + return '------'; } } exports.DividerElement = DividerElement; @@ -75296,7 +75313,7 @@ exports.DividerElement = DividerElement; /***/ }), -/***/ 5238: +/***/ 4154: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -75304,25 +75321,30 @@ exports.DividerElement = DividerElement; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.Element = void 0; class Element { + id; type; - constructor(type) { + constructor({ id, type }) { + this.id = id; this.type = type; } + toContentString() { + throw new Error('toContentString must be implemented by the subclass'); + } } exports.Element = Element; /***/ }), -/***/ 1020: +/***/ 8792: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class EquationElement extends Element_class_1.Element { equation; styles = { @@ -75332,8 +75354,8 @@ class EquationElement extends Element_class_1.Element { underline: false, code: false, }; - constructor({ equation, styles, }) { - super(types_1.ElementType.Equation); + constructor({ id, equation, styles, }) { + super({ id, type: types_1.ElementType.Equation }); this.equation = equation; this.styles.bold = styles?.bold || false; this.styles.italic = styles?.italic || false; @@ -75341,21 +75363,34 @@ class EquationElement extends Element_class_1.Element { this.styles.underline = styles?.underline || false; this.styles.code = styles?.code || false; } + toContentString() { + let { equation } = this; + if (this.styles.italic) { + equation = `_${equation}_`; + } + if (this.styles.strikethrough) { + equation = `~~${equation}~~`; + } + if (this.styles.underline) { + equation = `__${equation}__`; + } + return equation; + } } exports.EquationElement = EquationElement; /***/ }), -/***/ 5320: +/***/ 9524: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); /** * Element that represents a file in the system */ @@ -75365,50 +75400,56 @@ class FileElement extends Element_class_1.Element { creationDate; lastUpdatedDate; extension; - constructor({ content, name, creationDate, lastUpdatedDate, extension, }) { - super(types_1.ElementType.File); + constructor({ id, content, name, creationDate, lastUpdatedDate, extension, }) { + super({ id, type: types_1.ElementType.File }); this.content = content; this.name = name; this.creationDate = creationDate; this.lastUpdatedDate = lastUpdatedDate; this.extension = extension; } + toContentString() { + return `[${this.name}](${this.content})`; + } } exports.FileElement = FileElement; /***/ }), -/***/ 877: +/***/ 5193: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class HtmlElement extends Element_class_1.Element { html; - constructor({ html }) { - super(types_1.ElementType.Html); + constructor({ id, html }) { + super({ id, type: types_1.ElementType.Html }); this.html = html; } + toContentString() { + return this.html; + } } exports.HtmlElement = HtmlElement; /***/ }), -/***/ 7047: +/***/ 7515: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class ImageElement extends Element_class_1.Element { base64; url; @@ -75418,8 +75459,8 @@ class ImageElement extends Element_class_1.Element { lastUpdatedDate; extension; filepath; - constructor({ base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { - super(types_1.ElementType.Image); + constructor({ id, base64, url, name, creationDate, lastUpdatedDate, extension, caption, filepath, }) { + super({ id, type: types_1.ElementType.Image }); this.name = name; this.creationDate = creationDate; this.lastUpdatedDate = lastUpdatedDate; @@ -75429,30 +75470,38 @@ class ImageElement extends Element_class_1.Element { this.caption = caption; this.filepath = filepath; } + toContentString() { + return `![${this.name}](${this.url})`; + } } exports.ImageElement = ImageElement; /***/ }), -/***/ 538: +/***/ 3070: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class LinkElement extends Element_class_1.Element { url; text; caption; - constructor({ url, text, caption, }) { - super(types_1.ElementType.Link); + filepath; + constructor({ id, url, text, caption, filepath, }) { + super({ id, type: types_1.ElementType.Link }); this.url = url; this.text = text; this.caption = caption; + this.filepath = filepath; + } + toContentString() { + return `[${this.text}](${this.url})`; } } exports.LinkElement = LinkElement; @@ -75460,56 +75509,69 @@ exports.LinkElement = LinkElement; /***/ }), -/***/ 1539: +/***/ 3119: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class ListItemElement extends Element_class_1.Element { listType; text; children; - constructor({ listType, text, children, }) { - super(types_1.ElementType.ListItem); + constructor({ id, listType, text, children, }) { + super({ id, type: types_1.ElementType.ListItem }); this.listType = listType; this.text = text; this.children = children; } + toContentString() { + let content = ''; + if (this.listType === 'ordered') { + content = `1. ${this.text.map((element) => element.toContentString()).join('')}`; + } + else { + content = `- ${this.text.map((element) => element.toContentString()).join('')}`; + } + if (this.children) { + content += `\n${this.children.map((element) => element.toContentString()).join('')}`; + } + return content; + } } exports.ListItemElement = ListItemElement; /***/ }), -/***/ 2407: +/***/ 6939: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); /** * 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; + source; + constructor({ id, title, icon, content = [], properties, source, }) { + super({ id, type: types_1.ElementType.Page }); this.title = title; this.icon = icon; this.content = content; this.properties = properties; + this.source = source; } getIcon() { return this.icon; @@ -75526,60 +75588,69 @@ exports.PageElement = PageElement; /***/ }), -/***/ 9248: +/***/ 8076: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class QuoteElement extends Element_class_1.Element { text; - constructor({ text }) { - super(types_1.ElementType.Quote); + constructor({ id, text }) { + super({ id, type: types_1.ElementType.Quote }); this.text = text; } + toContentString() { + return `> ${this.text}`; + } } exports.QuoteElement = QuoteElement; /***/ }), -/***/ 9704: +/***/ 60: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class TableElement extends Element_class_1.Element { rows; - constructor({ rows }) { - super(types_1.ElementType.Table); + constructor({ id, rows }) { + super({ id, type: types_1.ElementType.Table }); this.rows = rows; } + toContentString() { + return this.rows.map((row) => row.join(' | ')).join('\n'); + } } exports.TableElement = TableElement; /***/ }), -/***/ 4728: +/***/ 5172: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class TableOfContentsElement extends Element_class_1.Element { - constructor() { - super(types_1.ElementType.TableOfContents); + constructor({ id } = { id: undefined }) { + super({ id, type: types_1.ElementType.TableOfContents }); + } + toContentString() { + return ''; } } exports.TableOfContentsElement = TableOfContentsElement; @@ -75587,15 +75658,15 @@ exports.TableOfContentsElement = TableOfContentsElement; /***/ }), -/***/ 8731: +/***/ 4375: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); var TextElementLevel; (function (TextElementLevel) { TextElementLevel["Heading1"] = "heading_1"; @@ -75623,8 +75694,8 @@ class TextElement extends Element_class_1.Element { underline: false, code: false, }; - constructor({ text, level = TextElementLevel.Paragraph, styles, }) { - super(types_1.ElementType.Text); + constructor({ id, text, level = TextElementLevel.Paragraph, styles, }) { + super({ id, type: types_1.ElementType.Text }); this.text = text; this.level = level; this.styles.bold = styles?.bold || false; @@ -75633,36 +75704,57 @@ class TextElement extends Element_class_1.Element { this.styles.underline = styles?.underline || false; this.styles.code = styles?.code || false; } + toContentString() { + let { text } = this; + if (typeof text === 'string') { + return text; + } + text = text.map((element) => element.toContentString()).join(''); + if (this.styles.italic) { + text = `_${text}_`; + } + if (this.styles.strikethrough) { + text = `~~${text}~~`; + } + if (this.styles.underline) { + text = `__${text}__`; + } + return text; + } } exports.TextElement = TextElement; /***/ }), -/***/ 1690: +/***/ 8878: /***/ ((__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); +const Element_class_1 = __nccwpck_require__(4154); +const types_1 = __nccwpck_require__(7153); class ToggleElement extends Element_class_1.Element { title; children; - constructor({ title, children }) { - super(types_1.ElementType.Toggle); + constructor({ id, title, children, }) { + super({ id, type: types_1.ElementType.Toggle }); this.title = title; this.children = children; } + toContentString() { + const { title } = this; + return `[${title}](${this.children.map((element) => element.toContentString()).join('')})`; + } } exports.ToggleElement = ToggleElement; /***/ }), -/***/ 2052: +/***/ 7672: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -75682,28 +75774,28 @@ 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); +__exportStar(__nccwpck_require__(9548), exports); +__exportStar(__nccwpck_require__(1925), exports); +__exportStar(__nccwpck_require__(9755), exports); +__exportStar(__nccwpck_require__(4154), exports); +__exportStar(__nccwpck_require__(8792), exports); +__exportStar(__nccwpck_require__(9524), exports); +__exportStar(__nccwpck_require__(5193), exports); +__exportStar(__nccwpck_require__(7515), exports); +__exportStar(__nccwpck_require__(3070), exports); +__exportStar(__nccwpck_require__(3119), exports); +__exportStar(__nccwpck_require__(6939), exports); +__exportStar(__nccwpck_require__(8076), exports); +__exportStar(__nccwpck_require__(60), exports); +__exportStar(__nccwpck_require__(5172), exports); +__exportStar(__nccwpck_require__(4375), exports); +__exportStar(__nccwpck_require__(8878), exports); +__exportStar(__nccwpck_require__(7153), exports); /***/ }), -/***/ 9461: +/***/ 7153: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -75730,16 +75822,6 @@ var ElementType; })(ElementType || (exports.ElementType = ElementType = {})); -/***/ }), - -/***/ 2057: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); - - /***/ }), /***/ 7591: @@ -75762,15 +75844,25 @@ 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__(2057), exports); -__exportStar(__nccwpck_require__(2052), exports); -__exportStar(__nccwpck_require__(464), exports); +__exportStar(__nccwpck_require__(7672), exports); +__exportStar(__nccwpck_require__(7120), exports); +__exportStar(__nccwpck_require__(467), exports); __exportStar(__nccwpck_require__(5666), exports); /***/ }), -/***/ 464: +/***/ 7120: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); + + +/***/ }), + +/***/ 467: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -75828,331 +75920,8 @@ 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__(9257), exports); -__exportStar(__nccwpck_require__(5270), exports); - - -/***/ }), - -/***/ 9257: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PreviewSynchronization = exports.isValidFormat = void 0; -const sitemap_1 = __nccwpck_require__(4617); -const serializers_1 = __nccwpck_require__(9151); -const isValidFormat = (format) => { - return (typeof format === 'string' && (format === 'plainText' || format === 'json')); -}; -exports.isValidFormat = isValidFormat; -class PreviewSynchronization { - sourceRepository; - constructor(params) { - this.sourceRepository = params.sourceRepository; - } - async execute(args, { format } = {}) { - // Check if the GitHub repository is accessible - try { - await this.sourceRepository.sourceIsAccessible(args); - } - catch (err) { - throw new Error(`Source is not accessible:`, { - cause: err, - }); - } - let sitemapSerializer; - if (!format) { - sitemapSerializer = serializers_1.serializeInPlainText; - } - else { - switch (format) { - case 'plainText': - sitemapSerializer = serializers_1.serializeInPlainText; - break; - case 'json': - sitemapSerializer = serializers_1.serializeInJson; - break; - default: - throw new Error(`Invalid serialization format:`, format); - } - } - const filePaths = await this.sourceRepository.getFilePathList(args); - const siteMap = sitemap_1.SiteMap.buildFromFilePaths(filePaths); - return sitemapSerializer(siteMap); - } -} -exports.PreviewSynchronization = PreviewSynchronization; - - -/***/ }), - -/***/ 5270: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.SynchronizeMarkdownToNotion = void 0; -const elements_1 = __nccwpck_require__(7591); -const sitemap_1 = __nccwpck_require__(4617); -class SynchronizeMarkdownToNotion { - sourceRepository; - destinationRepository; - elementConverter; - logger; - constructor(params) { - this.sourceRepository = params.sourceRepository; - this.destinationRepository = params.destinationRepository; - this.elementConverter = params.elementConverter; - this.logger = params.logger; - } - async execute(args) { - const { notionParentPageUrl, cleanSync, lockPage, ...others } = args; - 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'); - } - try { - 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, - parentObjectId: notionObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - this.logger.info('Synchronization process completed successfully'); - } - catch (error) { - if (error instanceof Error) { - this.logger.error(`Synchronization process failed`, { - error, - }); - } - throw error; - } - } - /** - * 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, 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, - }); - this.logger.info(`Added content from ${node.filepath} to parent page`); - await this.lockPageIfNeeded(parentObjectId, lockPage); - return parentObjectId; - } - if (cleanSync) { - await this.cleanSyncDatabase({ - databaseId: parentObjectId, - pageElement, - }); - } - // 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; - } - 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 - */ - 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, cleanSync, }) { - 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, - cleanSync, - }); - } - else { - parentPageId = await this.synchronizeRootNode({ - node: node.children[0], - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - } - break; - case 'page': - if (this.getIsRootNode(node)) { - parentPageId = await this.synchronizeRootNode({ - node, - parentObjectId, - parentObjectType, - lockPage, - cleanSync, - }); - } - 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; - } - } - } - getIsRootNode(node) { - return node.parent === null && !['', undefined].includes(node.filepath); - } -} -exports.SynchronizeMarkdownToNotion = SynchronizeMarkdownToNotion; +__exportStar(__nccwpck_require__(3833), exports); +__exportStar(__nccwpck_require__(4122), exports); /***/ }), @@ -76185,7 +75954,7 @@ __exportStar(__nccwpck_require__(1230), exports); /***/ }), -/***/ 3913: +/***/ 9749: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -76230,18 +75999,6 @@ 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: @@ -76275,114 +76032,7 @@ exports.isNotionNestingValidationError = isNotionNestingValidationError; /***/ }), -/***/ 8188: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isPageObjectResponse = void 0; -const isPageObjectResponse = (obj) => { - return (typeof obj === 'object' && - obj !== null && - obj.object === 'page' && - typeof obj.id === 'string' && - typeof obj.created_time === 'string' && - typeof obj.last_edited_time === 'string' && - typeof obj.archived === 'boolean' && - typeof obj.in_trash === 'boolean' && - typeof obj.url === 'string' && - (typeof obj.public_url === 'string' || obj.public_url === null) && - isParent(obj.parent) && - typeof obj.properties === 'object' && - isIcon(obj.icon) && - isCover(obj.cover) && - isCreatedBy(obj.created_by) && - isLastEditedBy(obj.last_edited_by)); -}; -exports.isPageObjectResponse = isPageObjectResponse; -// Helper function to check the parent field -function isParent(parent) { - return (typeof parent === 'object' && - parent !== null && - 'type' in parent && - 'database_id' in parent && - ((parent.type === 'database_id' && - typeof parent.database_id === 'string') || - (parent.type === 'page_id' && - 'page_id' in parent && - typeof parent.page_id === 'string') || - (parent.type === 'block_id' && - 'block_id' in parent && - typeof parent.block_id === 'string') || - (parent.type === 'workspace' && - 'workspace' in parent && - parent.workspace === true))); -} -// Helper function to check the icon field -function isIcon(icon) { - return (icon === null || - (typeof icon === 'object' && - (('type' in icon && - typeof icon.type === 'string' && - icon.type === 'emoji' && - 'emoji' in icon && - typeof icon.emoji === 'string') || - ('type' in icon && - typeof icon.type === 'string' && - icon.type === 'external' && - 'external' in icon && - typeof icon.external === 'object' && - icon.external !== null && - 'url' in icon.external && - typeof icon.external.url === 'string') || - ('type' in icon && - typeof icon.type === 'string' && - icon.type === 'file' && - 'file' in icon && - typeof icon.file === 'object' && - icon.file !== null && - 'url' in icon.file && - 'expiry_time' in icon.file && - typeof icon.file.url === 'string' && - typeof icon.file.expiry_time === 'string')))); -} -// Helper function to check the cover field -function isCover(cover) { - return (cover === null || - (typeof cover === 'object' && - (('type' in cover && - typeof cover.type === 'string' && - cover.type === 'external' && - 'external' in cover && - typeof cover.external === 'object' && - cover.external !== null && - 'url' in cover.external && - typeof cover.external.url === 'string') || - ('type' in cover && - typeof cover.type === 'string' && - cover.type === 'file' && - 'file' in cover && - cover.file !== null && - typeof cover.file === 'object' && - 'url' in cover.file && - 'expiry_time' in cover.file && - typeof cover.file.url === 'string' && - typeof cover.file.expiry_time === 'string')))); -} -// Helper function to check created_by field -function isCreatedBy(created_by) { - return typeof created_by === 'object'; // Assuming PartialUserObjectResponse is an object, refine this if needed -} -// Helper function to check last_edited_by field -function isLastEditedBy(last_edited_by) { - return typeof last_edited_by === 'object'; // Assuming PartialUserObjectResponse is an object, refine this if needed -} - - -/***/ }), - -/***/ 4350: +/***/ 386: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -76423,7 +76073,7 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SiteMap = void 0; const path = __importStar(__nccwpck_require__(6928)); -const TreeNode_1 = __nccwpck_require__(2553); +const TreeNode_1 = __nccwpck_require__(1349); class SiteMap { _root; constructor() { @@ -76569,7 +76219,7 @@ exports.SiteMap = SiteMap; /***/ }), -/***/ 2553: +/***/ 1349: /***/ ((__unused_webpack_module, exports) => { "use strict"; @@ -76657,12 +76307,12 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.TreeNode = exports.SiteMap = exports.serializers = void 0; -exports.serializers = __importStar(__nccwpck_require__(9151)); -var SiteMap_1 = __nccwpck_require__(4350); +exports.serializers = exports.TreeNode = exports.SiteMap = void 0; +var SiteMap_1 = __nccwpck_require__(386); Object.defineProperty(exports, "SiteMap", ({ enumerable: true, get: function () { return SiteMap_1.SiteMap; } })); -var TreeNode_1 = __nccwpck_require__(2553); +var TreeNode_1 = __nccwpck_require__(1349); Object.defineProperty(exports, "TreeNode", ({ enumerable: true, get: function () { return TreeNode_1.TreeNode; } })); +exports.serializers = __importStar(__nccwpck_require__(9151)); /***/ }), @@ -76757,12 +76407,367 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); /***/ }), -/***/ 7226: -/***/ ((__unused_webpack_module, exports) => { +/***/ 3833: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PreviewSynchronization = exports.isValidFormat = void 0; +const sitemap_1 = __nccwpck_require__(4617); +const serializers_1 = __nccwpck_require__(9151); +const isValidFormat = (format) => { + return (typeof format === 'string' && (format === 'plainText' || format === 'json')); +}; +exports.isValidFormat = isValidFormat; +class PreviewSynchronization { + sourceRepository; + constructor(params) { + this.sourceRepository = params.sourceRepository; + } + async execute(args, { format } = {}) { + // Check if the source repository is accessible + try { + await this.sourceRepository.sourceIsAccessible(args); + } + catch (err) { + throw new Error(`Source is not accessible:`, { + cause: err, + }); + } + let sitemapSerializer; + if (!format) { + sitemapSerializer = serializers_1.serializeInPlainText; + } + else { + switch (format) { + case 'plainText': + sitemapSerializer = serializers_1.serializeInPlainText; + break; + case 'json': + sitemapSerializer = serializers_1.serializeInJson; + break; + default: + throw new Error(`Invalid serialization format:`, format); + } + } + const filePaths = await this.sourceRepository.getFilePathList(args); + const siteMap = sitemap_1.SiteMap.buildFromFilePaths(filePaths); + return sitemapSerializer(siteMap); + } +} +exports.PreviewSynchronization = PreviewSynchronization; + + +/***/ }), + +/***/ 4122: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SynchronizeMarkdownToNotion = void 0; +const elements_1 = __nccwpck_require__(7591); +const sitemap_1 = __nccwpck_require__(4617); +class SynchronizeMarkdownToNotion { + sourceRepository; + destinationRepository; + elementConverter; + logger; + constructor(params) { + this.sourceRepository = params.sourceRepository; + this.destinationRepository = params.destinationRepository; + this.elementConverter = params.elementConverter; + this.logger = params.logger; + } + async execute(args) { + const { notionParentPageUrl, cleanSync, lockPage, saveId, forceNew, ...others } = args; + 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'); + } + try { + 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 + const pages = await this.synchronizeTreeNode({ + node: siteMap.root, + parentObjectId: notionObjectId, + parentObjectType, + lockPage, + cleanSync, + forceNew, + }); + this.logger.info('Synchronization process completed successfully'); + if (saveId) { + await this.executeSaveIdOperation(pages); + this.logger.info('Page IDs saved to source repository'); + } + } + catch (error) { + if (error instanceof Error) { + this.logger.error(`Synchronization process failed`, { + error, + }); + } + throw error; + } + } + /** + * ------------- + * PRIVATE METHODS + * ------------- + */ + /** + * Executes the save ID operation + */ + async executeSaveIdOperation(syncResult) { + const promises = syncResult.map(async (element) => { + const file = await this.elementConverter.convertFromElement(element.page); + await this.sourceRepository.updateFile(file); + }); + await Promise.all(promises); + } + /** + * Fetches a file and converts it to a PageElement + */ + async fetchAndConvertToPageElement(filePath, { forceNew } = {}) { + const file = await this.sourceRepository.getFile({ path: filePath }); + const element = this.elementConverter.convertToElement(file); + if (!(element instanceof elements_1.PageElement)) { + throw new Error('Element is not a PageElement'); + } + if (forceNew) { + element.id = undefined; + } + 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}`); + } + } + /** + * Main orchestrator for synchronizing a tree node and its children + */ + async synchronizeTreeNode({ node, parentObjectId, parentObjectType, lockPage, cleanSync, forceNew, }) { + this.validateParentObjectType(parentObjectType); + const nodeToSync = this.getNodeToSynchronize(node, parentObjectType); + const results = []; + const { page: rootPageElement, treeNodeId: rootTreeNodeId } = await this.synchronizeRootNode({ + node: nodeToSync, + parentObjectId, + parentObjectType, + lockPage, + cleanSync, + forceNew, + }); + results.push({ page: rootPageElement, treeNodeId: rootTreeNodeId }); + for (const childNode of node.children) { + try { + const childResults = await this.synchronizeChildNode({ + childNode, + parentPageId: rootPageElement.id, + lockPage, + forceNew, + }); + results.push(...childResults); + } + catch (error) { + this.logger.error(`Failed to synchronize file: ${childNode.filepath}`, { + error, + }); + throw error; + } + } + return results; + } + /** + * 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, cleanSync, forceNew, }) { + this.logger.info(`Adding content from ${node.filepath} to parent ${parentObjectType}`); + const pageElement = await this.fetchAndConvertToPageElement(node.filepath, { + forceNew, + }); + if (pageElement.id !== undefined) { + const existingPage = await this.destinationRepository.getPage({ + pageId: pageElement.id, + }); + if (existingPage) { + await this.destinationRepository.updatePage({ + pageElement, + pageId: pageElement.id, + }); + return { + page: pageElement, + treeNodeId: node.id, + }; + } + } + 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 }); + } + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + }); + pageElement.id = newPage.pageId; + return { page: pageElement, treeNodeId: node.id }; + } + const updatedPage = await this.destinationRepository.updatePage({ + pageId: parentObjectId, + pageElement, + }); + pageElement.id = updatedPage.pageId; + this.logger.info(`Updated parent page ${parentObjectId}`); + await this.lockPageIfNeeded(parentObjectId, lockPage); + return { page: pageElement, treeNodeId: node.id }; + } + if (cleanSync) { + await this.destinationRepository.deleteChildBlocks({ + parentPageId: parentObjectId, + }); + } + // parentObjectType === 'database' + const newPage = await this.destinationRepository.createPage({ + pageElement, + parentObjectId, + parentObjectType, + }); + if (!newPage.pageId) { + throw new Error('New page ID is undefined'); + } + pageElement.id = newPage.pageId; + return { page: pageElement, treeNodeId: node.id }; + } + /** + * Synchronizes a child node and its descendants recursively + */ + async synchronizeChildNode({ childNode, parentPageId, lockPage, forceNew, }) { + const syncResult = []; + const filePath = childNode.filepath; + this.logger.info(`Processing file: ${filePath}`); + const pageElement = await this.fetchAndConvertToPageElement(filePath, { + forceNew, + }); + if (pageElement.id !== undefined) { + await this.destinationRepository.updatePage({ + pageId: pageElement.id, + pageElement, + }); + } + else { + // 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', + }); + this.logger.info(`Created Notion page for file: ${filePath}`); + if (!newPage.pageId) { + throw new Error('Page ID is undefined'); + } + pageElement.id = newPage.pageId; + } + syncResult.push({ + page: pageElement, + treeNodeId: childNode.id, + }); + // Recursively process children + for (const grandChild of childNode.children) { + const grandChildSyncResult = await this.synchronizeChildNode({ + childNode: grandChild, + parentPageId: pageElement.id, + lockPage, + forceNew, + }); + syncResult.push(...grandChildSyncResult); + } + await this.lockPageIfNeeded(pageElement.id, lockPage); + return syncResult; + } + getIsRootNode(node) { + return node.parent === null && !['', undefined].includes(node.filepath); + } + /** + * Validates that the parent object type is supported for synchronization + */ + validateParentObjectType(parentObjectType) { + if (parentObjectType === 'unknown') { + throw new Error('Parent object type is unknown'); + } + if (!['database', 'page'].includes(parentObjectType)) { + throw new Error(`Invalid parent object type: ${parentObjectType}`); + } + } + /** + * Determines the effective node to synchronize as root based on parent type + * For database parents with non-root nodes, uses the first child + * For page parents, returns null if the node is not a root node + */ + getNodeToSynchronize(node, parentObjectType) { + const isRootNode = this.getIsRootNode(node); + if (parentObjectType === 'database' && !isRootNode) { + return node.children[0]; + } + if (parentObjectType === 'page' && !isRootNode) { + return node.children[0]; + } + return node; + } +} +exports.SynchronizeMarkdownToNotion = SynchronizeMarkdownToNotion; /***/ }), @@ -76787,23 +76792,50 @@ 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__(7226), exports); -__exportStar(__nccwpck_require__(111), exports); +__exportStar(__nccwpck_require__(5873), exports); +__exportStar(__nccwpck_require__(8362), exports); + + +/***/ }), + +/***/ 5873: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); /***/ }), -/***/ 111: +/***/ 8362: /***/ ((__unused_webpack_module, exports) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.File = void 0; +class File { + name; + icon; + content; + path; + lastUpdated; + extension; + constructor({ name, content, path, lastUpdated, extension, }) { + this.name = name; + this.content = content; + this.path = path; + this.lastUpdated = lastUpdated; + this.extension = extension; + } +} +exports.File = File; /***/ }), -/***/ 9375: +/***/ 2378: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -76811,6 +76843,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.FileConverter = void 0; const elements_1 = __nccwpck_require__(7591); +const synchronization_1 = __nccwpck_require__(1230); class FileConverter { htmlParser; markdownParser; @@ -76820,11 +76853,6 @@ class FileConverter { this.markdownParser = markdownParser; this.logger = logger; } - setCurrentFilePath(filePath) { - if (this.markdownParser.setCurrentFilePath) { - this.markdownParser.setCurrentFilePath(filePath); - } - } convertToElement(file) { const { content } = file; const args = { @@ -76842,184 +76870,97 @@ class FileConverter { if (!parser) { throw new Error('File extension not supported'); } - const result = parser.parse({ content }); + const result = parser.parse({ content, filepath: file.path }); return new elements_1.PageElement({ ...args, ...result, + source: file, }); } -} -exports.FileConverter = FileConverter; - - -/***/ }), - -/***/ 8141: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.FileSystemSourceRepository = void 0; -const fs_1 = __nccwpck_require__(9896); -const path_1 = __nccwpck_require__(6928); -class FileSystemSourceRepository { - isFile(path) { - try { - const stats = (0, fs_1.statSync)(path); - return stats.isFile(); + convertFromElement(pageElement) { + if (!pageElement.source || !(pageElement.source instanceof synchronization_1.File)) { + throw new Error('Filepath is required to convert from PageElement to File'); + } + return new synchronization_1.File({ + name: pageElement.title, + extension: pageElement.source.extension, + content: [ + this.getFrontmatterString(pageElement), + this.removeFrontmatterFromContent(pageElement.source.content), + ].join('\n'), + lastUpdated: pageElement.source.lastUpdated, + path: pageElement.source.path, + }); + } + getFrontmatterString(pageElement) { + const frontmatter = ['---']; + if (pageElement.id) { + frontmatter.push(`id: ${pageElement.id}`); } - catch { - return false; + if (pageElement.title) { + frontmatter.push(`title: ${pageElement.title}`); } - } - isDirectory(path) { - try { - const stats = (0, fs_1.statSync)(path); - return stats.isDirectory(); + if (pageElement.icon) { + frontmatter.push(`icon: ${pageElement.icon}`); } - catch { - return false; + if (pageElement.properties) { + frontmatter.push('properties:'); + frontmatter.push(this.getPageElementPropertiesString(pageElement.properties)); } + frontmatter.push('---'); + return frontmatter.join('\n'); } - isReadableRecursiveSync(path) { - try { - // Check if the path is readable - (0, fs_1.accessSync)(path, fs_1.constants.R_OK); - // Get directory contents - const entries = (0, fs_1.readdirSync)(path, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = (0, path_1.join)(path, entry.name); - if (entry.isDirectory()) { - // Recursively check subdirectory readability - if (!this.isReadableRecursiveSync(fullPath)) - return false; - } - else { - // Check if the file is readable - try { - (0, fs_1.accessSync)(fullPath, fs_1.constants.R_OK); - } - catch { - return false; - } - } - } - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } - catch (error) { - return false; + getPageElementPropertiesString(properties) { + const propertiesString = []; + if (!properties) { + return ''; } + properties.forEach((property) => { + propertiesString.push(...[ + ` - name: ${property.name}`, + ` value: ${this.getPropertyValueString(property.value)}`, + ]); + }); + return propertiesString.join('\n'); } - isReadableFile(path) { - try { - (0, fs_1.accessSync)(path, fs_1.constants.R_OK); - return true; + getPropertyValueString(value) { + if (typeof value === 'string') { + return value; } - catch { - return false; + if (typeof value === 'number') { + return value.toString(); } - } - // eslint-disable-next-line @typescript-eslint/require-await - async sourceIsAccessible({ path }) { - if (this.isFile(path)) { - return this.isReadableFile(path); + if (typeof value === 'boolean') { + return value.toString(); } - else if (this.isDirectory(path)) { - return this.isReadableRecursiveSync(path); + if (Array.isArray(value)) { + return this.getPropertyValueStringArray(value); } - return false; - } - // eslint-disable-next-line @typescript-eslint/require-await - async getFilePathList({ path }) { - // If it's a single file, return it as a single-item array - if (this.isFile(path)) { - if (!path.endsWith('.md')) { - throw new Error(`File ${path} is not a markdown file. Only .md files are supported.`); - } - return [path]; + if (value === null) { + return 'null'; } - // If it's a directory, collect all markdown files recursively - if (!this.isDirectory(path)) { - throw new Error(`Path ${path} is neither a file nor a directory.`); + if (typeof value === 'undefined') { + return 'undefined'; } - const markdownFiles = []; - const collectMarkdownFiles = (dirPath) => { - try { - const entries = (0, fs_1.readdirSync)(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = (0, path_1.join)(dirPath, entry.name); - if (entry.isDirectory()) { - // Recursively process subdirectories - collectMarkdownFiles(fullPath); - } - else if (entry.isFile() && fullPath.endsWith('.md')) { - // Store markdown file path - markdownFiles.push(fullPath); - } - } - } - catch (error) { - throw new Error(`Error reading directory ${dirPath}`, { cause: error }); - } - }; - collectMarkdownFiles(path); - return markdownFiles; + throw new Error(`Unsupported property value type: ${typeof value}`); } - getLastUpdatedDate(filePath) { - const stats = (0, fs_1.statSync)(filePath); - return stats.mtime; // mtime (modification time) represents last updated date + getPropertyValueStringArray(value) { + return [ + `[`, + value.map((v) => this.getPropertyValueString(v)).join(','), + `]`, + ].join(''); } - // eslint-disable-next-line @typescript-eslint/require-await - async getFile({ path }) { - // Determine the display name for the Notion page - const base = (0, path_1.basename)(path); - let name = base; - if (base.toLowerCase().endsWith('.md')) { - // Remove .md extension for all other files - name = base.slice(0, -3); - } - return { - name, - content: (0, fs_1.readFileSync)(path, 'utf-8'), - extension: (0, path_1.extname)(path).slice(1), - lastUpdated: this.getLastUpdatedDate(path), - }; + removeFrontmatterFromContent(content) { + return content.replace(/^-{3,}\n.*?\n-{3,}/s, '').trim(); } } -exports.FileSystemSourceRepository = FileSystemSourceRepository; - - -/***/ }), - -/***/ 2503: -/***/ (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__(9375), exports); -__exportStar(__nccwpck_require__(8141), exports); +exports.FileConverter = FileConverter; /***/ }), -/***/ 3011: +/***/ 5900: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -77058,558 +76999,858 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.HtmlParser = void 0; -const DomSerializer = __importStar(__nccwpck_require__(9943)); -const domelementtype_1 = __nccwpck_require__(1108); -const htmlparser2_1 = __nccwpck_require__(3231); +exports.NotionConverterRepository = void 0; +const path = __importStar(__nccwpck_require__(6928)); const elements_1 = __nccwpck_require__(7591); -class HtmlParser extends elements_1.ParserRepository { - constructor({ logger }) { - super({ logger }); +const NotionPage_1 = __nccwpck_require__(9749); +const SUPPORTED_IMAGE_URL_EXTENSIONS = [ + '.bmp', + '.gif', + '.heic', + '.jpeg', + '.jpg', + '.png', + '.svg', + '.tif', + '.tiff', +]; +class NotionConverterRepository { + logger; + fileUploadService; + basePath; + constructor({ logger, fileUploadService, }) { + this.logger = logger; + this.fileUploadService = fileUploadService; } - parse({ content }) { - const document = (0, htmlparser2_1.parseDocument)(content); - const elements = []; - for (const node of document.children) { - if (node.type === domelementtype_1.ElementType.Tag) { - switch (node.name) { - case 'details': { - const summaryNode = htmlparser2_1.DomUtils.findOne((n) => n.name === 'summary', node.children); - const detailsContent = DomSerializer.render(node); - elements.push(new elements_1.ToggleElement({ - title: summaryNode ? htmlparser2_1.DomUtils.textContent(summaryNode) : '', - children: [new elements_1.TextElement({ text: detailsContent })], - })); - break; - } - case 'kbd': - case 'samp': - const codeElement = new elements_1.CodeElement({ - text: htmlparser2_1.DomUtils.textContent(node), - language: elements_1.ElementCodeLanguage.PlainText, - }); - elements.push(codeElement); - break; - case 'sub': - this.logger.warn(' tag is not supported'); - break; - case 'sup': - this.logger.warn(' tag is not supported'); - break; - case 'ins': - elements.push(new elements_1.TextElement({ - text: htmlparser2_1.DomUtils.textContent(node), - styles: { underline: true }, - })); - break; - case 'del': - elements.push(new elements_1.TextElement({ - text: htmlparser2_1.DomUtils.textContent(node), - styles: { strikethrough: true }, - })); - break; - case 'var': - elements.push(new elements_1.TextElement({ - text: htmlparser2_1.DomUtils.textContent(node), - styles: { italic: true }, - })); - break; - case 'q': - elements.push(new elements_1.QuoteElement({ - text: htmlparser2_1.DomUtils.textContent(node), - })); - break; - case 'div': - elements.push(new elements_1.DividerElement()); - break; - default: - break; - } - } + setBasePath(basePath) { + this.basePath = basePath; + } + /** + * Determine if an image URL is a local file path (relative or absolute local path) + */ + isLocalImagePath(url) { + if (!url) + return false; + // External URLs (http/https) + if (url.startsWith('http://') || url.startsWith('https://')) { + return false; + } + // Data URLs + if (url.startsWith('data:')) { + return false; + } + // Relative paths or absolute local paths + return true; + } + // ============================================ + // 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 { - content: elements, + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: value, + link: null, + }, + }, + ], }; } -} -exports.HtmlParser = HtmlParser; - - -/***/ }), - -/***/ 313: -/***/ (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__(3011), exports); - - -/***/ }), - -/***/ 947: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getInfrastructureInstances = void 0; -const filesystem_1 = __nccwpck_require__(2503); -const html_1 = __nccwpck_require__(313); -const markdown_1 = __nccwpck_require__(401); -const notion_1 = __nccwpck_require__(9717); -let infraInstances; -const buildInstances = ({ logger, notionApiKey, }) => { - const fileUploadService = new notion_1.NotionFileUploadService({ - apiKey: notionApiKey, - logger, - }); - const notionConverter = new notion_1.NotionConverterRepository({ - logger, - fileUploadService, - }); - const htmlParser = new html_1.HtmlParser({ logger }); - const markdownParser = new markdown_1.MarkdownParser({ htmlParser, logger }); - return { - fileSystemSource: new filesystem_1.FileSystemSourceRepository(), - fileConverter: new filesystem_1.FileConverter({ - logger, - htmlParser, - markdownParser, - }), - htmlParser, - markdownParser: new markdown_1.MarkdownParser({ - htmlParser, - logger, - }), - notionDestination: new notion_1.NotionDestinationRepository({ - logger, - notionConverter, - apiKey: notionApiKey, - }), - notionConverter, - }; -}; -const getInfrastructureInstances = (args) => { - if (!infraInstances) { - infraInstances = buildInstances(args); + /** + * 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, + }, + }, + ], + }; } - return infraInstances; -}; -exports.getInfrastructureInstances = getInfrastructureInstances; - - -/***/ }), - -/***/ 401: -/***/ (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]; } }; + /** + * 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, + }; } - 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__(8047), exports); -__exportStar(__nccwpck_require__(6772), exports); - - -/***/ }), - -/***/ 8047: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.MarkdownParser = void 0; -const front_matter_1 = __importDefault(__nccwpck_require__(2247)); -const marked_1 = __nccwpck_require__(1638); -const marked_katex_extension_1 = __importDefault(__nccwpck_require__(7647)); -const elements_1 = __nccwpck_require__(7591); -class MarkdownParser extends elements_1.ParserRepository { - htmlParser; - currentFilePath; - constructor({ htmlParser, logger, }) { - super({ logger }); - this.htmlParser = htmlParser; - marked_1.marked.use((0, marked_katex_extension_1.default)({ throwOnError: false, nonStandard: true })); + /** + * 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, + }; } - setCurrentFilePath(filePath) { - this.currentFilePath = filePath; + /** + * 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, + }, + }; } - preParseMarkdown(src) { - const { body } = (0, front_matter_1.default)(src); - return marked_1.marked.lexer(body); + /** + * 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, + }; } - getMetadata(src) { - const { attributes } = (0, front_matter_1.default)(src); - if (!attributes || typeof attributes !== 'object') { - return {}; + /** + * 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}`); } - return attributes; + // 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 + }; } - getTextLevelFromDepth(depth) { - const mapping = { - 1: elements_1.TextElementLevel.Heading1, - 2: elements_1.TextElementLevel.Heading2, - 3: elements_1.TextElementLevel.Heading3, - 4: elements_1.TextElementLevel.Heading4, - 5: elements_1.TextElementLevel.Heading5, - 6: elements_1.TextElementLevel.Heading6, + /** + * 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, }; - if (depth < 1 || depth > 6) { - return elements_1.TextElementLevel.Paragraph; + } + /** + * Converts a string value to a Notion EmailProperty + */ + convertToEmailProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); } - return mapping[depth]; + return { + type: 'email', + email: value || null, + }; } /** - * Parse a heading token + * Converts a string value to a Notion PhoneNumberProperty */ - parseHeadingToken(token) { - const level = this.getTextLevelFromDepth(token.depth); - return new elements_1.TextElement({ - text: token.text, - level, - }); + convertToPhoneNumberProperty(value) { + if (typeof value !== 'string') { + throw new Error(`Invalid value type: ${typeof value}`); + } + return { + type: 'phone_number', + phone_number: value || null, + }; } - parseListToken(token) { - return token.items.map((item) => { - let text = []; - const children = []; - const paragraph = item.tokens.shift(); - if (paragraph && paragraph.type === 'text') { - text = this.parseParagraphToken(paragraph); - } - // Check if the list item has nested tokens (like nested lists) - if (item.tokens) { - for (const nestedToken of item.tokens) { - const contentItem = this.parseToken(nestedToken); - children.push(...contentItem); - } + /** + * 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, + }, + }; } - return new elements_1.ListItemElement({ - listType: token.ordered ? 'ordered' : 'unordered', - text, - children: children.length > 0 ? children : undefined, - }); - }); + } + // 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, + }, + }; } - parseBlockQuoteToken(token) { - const text = token.text.trim(); - if (text.startsWith('[!NOTE]')) { - return new elements_1.CalloutElement({ - text: text.replace('[!NOTE]', '').trim(), - icon: '💡', - }); + /** + * 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; } - return new elements_1.QuoteElement({ - text: text, - }); } - parseCodeToken(token) { - const language = token.lang || elements_1.ElementCodeLanguage.PlainText; - if (language === 'js') { - return new elements_1.CodeElement({ - text: token.text, - language: elements_1.ElementCodeLanguage.JavaScript, - }); + /** + * 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); } - const isSupportedLanguage = (0, elements_1.isElementCodeLanguage)(language); - if (!isSupportedLanguage) { - return new elements_1.CodeElement({ - text: token.text, - language: elements_1.ElementCodeLanguage.PlainText, - }); + // 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 new elements_1.CodeElement({ - text: token.text, - language, - }); + return result; } - parseCalloutToken(token) { - if (!token.callout || typeof token.callout !== 'string') { - throw new Error('Callout token does not have a callout property'); + async convertPageElement(element, notionPropertyDefinitions = []) { + const title = { + id: 'title', + type: 'title', + title: [ + { + type: 'text', + text: { + content: element.title, + link: null, + }, + }, + ], + }; + const result = { + children: [], + properties: { + title, + ...this.convertPageElementProperties(element.properties, notionPropertyDefinitions), + }, + }; + for (const contentElement of element.content) { + const convertedElement = await this.convertElement(contentElement); + if (convertedElement) { + result.children?.push(convertedElement); + } } - return new elements_1.CalloutElement({ - text: token.callout, - icon: '💡', - }); + const icon = element.getIcon(); + if (icon) { + result.icon = { type: 'emoji', emoji: icon }; + } + return result; } - parseTableToken(token) { - const headers = token.header.map((cell) => cell.text); - const rows = token.rows.map((row) => row.map((cell) => cell.text)); - return new elements_1.TableElement({ - rows: [headers, ...rows], - }); + async convertElement(element) { + switch (element.type) { + case elements_1.ElementType.Page: + return null; // Pages should not be converted as child blocks + case elements_1.ElementType.Text: + return this.convertText(element); + case elements_1.ElementType.Quote: + return this.convertQuote(element); + case elements_1.ElementType.Callout: + return this.convertCallout(element); + case elements_1.ElementType.ListItem: + return this.convertListItem(element); + case elements_1.ElementType.Table: + return this.convertTable(element); + case elements_1.ElementType.Toggle: + return await this.convertToggle(element); + case elements_1.ElementType.Link: + return this.convertLink(element); + case elements_1.ElementType.Divider: + return this.convertDivider(); + case elements_1.ElementType.Code: + return this.convertCodeBlock(element); + case elements_1.ElementType.Image: + return await this.convertImage(element); + case elements_1.ElementType.Html: + return this.convertHtml(element); + case elements_1.ElementType.TableOfContents: + return this.convertTableOfContents(); + case elements_1.ElementType.Equation: + return this.convertEquation(element); + default: + this.logger.warn(`Unsupported element type: ${element.type}`); + return null; + } } - parseImageToken(token) { - return new elements_1.ImageElement({ - url: token.href, - caption: token.text, - filepath: this.currentFilePath, - }); + async convertFromElement(element, availableProperties = []) { + const notionPageInput = await this.convertPageElement(element, availableProperties); + return NotionPage_1.NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); } - parseHtmlToken(token) { - const { content } = this.htmlParser.parse({ content: token.text }); - return content; + convertText(element) { + switch (element.level) { + case elements_1.TextElementLevel.Heading1: + return { + type: 'heading_1', + object: 'block', + heading_1: { + rich_text: this.convertRichText(element.text), + color: 'default', + is_toggleable: false, // Set based on your requirements + }, + }; + case elements_1.TextElementLevel.Heading2: + return { + type: 'heading_2', + object: 'block', + heading_2: { + rich_text: this.convertRichText(element.text), + color: 'default', + is_toggleable: false, + }, + }; + case elements_1.TextElementLevel.Heading3: + return { + type: 'heading_3', + object: 'block', + heading_3: { + rich_text: this.convertRichText(element.text), + color: 'default', + is_toggleable: false, + }, + }; + case elements_1.TextElementLevel.Paragraph: + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: this.convertRichText(element.text), + color: 'default', + }, + }; + default: + this.logger.warn(`Unsupported text level ${element.level} - using paragraph`); + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: this.convertRichText(element.text), + color: 'default', + }, + }; + } } - parseLinkToken(token) { - return new elements_1.LinkElement({ - text: token.text, - url: token.href, - }); + convertQuote(element) { + return { + type: 'quote', + object: 'block', + quote: { + rich_text: this.convertRichText(element.text), + }, + }; } - parseTextToken(token) { - if (token.type === 'strong') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: true, - italic: false, - strikethrough: false, - underline: false, - code: false, - }, - }); + convertCallout(element) { + const icon = element.getIcon(); + const calloutParams = { + rich_text: this.convertRichText(element.text), + icon: undefined, + }; + if (icon) { + // @ts-expect-error - Notion API types are incorrect + calloutParams.icon = { type: 'emoji', emoji: icon }; } - if (token.type === 'em') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: false, - italic: true, - strikethrough: false, - underline: false, - code: false, - }, - }); + return { + type: 'callout', + object: 'block', + callout: calloutParams, + }; + } + async convertListItem(element) { + let item; + if (element.listType === 'unordered') { + item = await this.convertBulletedListItem(element); } - if (token.type === 'del') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: false, - italic: false, - strikethrough: true, - underline: false, - code: false, - }, - }); + else { + item = await this.convertNumberedListItem(element); } - if (token.type === 'codespan') { - return new elements_1.TextElement({ - text: token.text, - styles: { - bold: false, - italic: false, - strikethrough: false, - underline: false, - code: true, - }, - }); + return item; + } + async convertBulletedListItem(element) { + return { + type: 'bulleted_list_item', + object: 'block', + bulleted_list_item: { + rich_text: this.convertRichText(element.text), + children: await this.convertListItemChildren(element.children), + }, + }; + } + async convertNumberedListItem(element) { + return { + type: 'numbered_list_item', + object: 'block', + numbered_list_item: { + rich_text: this.convertRichText(element.text), + children: await this.convertListItemChildren(element.children), + }, + }; + } + async convertListItemChildren(children) { + const convertedChildren = (await Promise.all(children?.map(async (child) => this.convertElement(child)) ?? [])).filter((child) => child !== null); + if (convertedChildren.length === 0) { + return undefined; } - return new elements_1.TextElement({ - text: token.text, - }); + return convertedChildren; } - parseBlockKatexToken(token) { - return new elements_1.EquationElement({ - equation: token.text, - styles: { - italic: false, - bold: false, - strikethrough: false, - underline: false, + convertTable(element) { + return { + type: 'table', + object: 'block', + table: { + table_width: element.rows[0]?.length || 0, + has_column_header: false, // Customize as needed + has_row_header: false, // Customize as needed + children: element.rows.map((row) => this.convertTableRow(row)), }, - }); + }; } - parseRawText(text) { - const tokens = this.preParseMarkdown(text); - const elements = []; - for (const t of tokens) { - switch (t.type) { - case 'paragraph': - elements.push(...this.parseParagraphToken(t)); - break; - case 'text': - elements.push(this.parseTextToken(t)); - break; + convertTableRow(row) { + return { + type: 'table_row', + object: 'block', + table_row: { + cells: row.map((cell) => this.convertRichText(cell)), + }, + }; + } + async convertToggle(element) { + const children = []; + for (const contentElement of element.children) { + const convertedElement = await this.convertElement(contentElement); + if (convertedElement) { + children.push(convertedElement); } } - return elements; + return { + type: 'toggle', + object: 'block', + toggle: { + rich_text: this.convertRichText(element.title), + children, + }, + }; } - parseParagraphToken(token) { - const elements = []; - token.tokens.forEach((t) => { - switch (t.type) { - case 'text': - elements.push(this.parseTextToken(t)); - break; - case 'inlineKatex': - elements.push(this.parseBlockKatexToken(t)); - break; - case 'strong': - elements.push(this.parseTextToken(t)); - break; - case 'em': - elements.push(this.parseTextToken(t)); - break; - case 'del': - elements.push(this.parseTextToken(t)); - break; - case 'codespan': - elements.push(this.parseTextToken(t)); - break; - case 'link': - elements.push(this.parseLinkToken(t)); - break; - case 'image': - elements.push(this.parseImageToken(t)); - break; - } - }); - return elements; + convertLink(element) { + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [ + { + text: { + content: element.text, + link: element.url.startsWith('http') + ? { url: element.url } + : null, + }, + }, + ], + color: 'default', + }, + }; } - parseToken(token) { - const elements = []; - switch (token.type) { - case 'heading': { - elements.push(this.parseHeadingToken(token)); - break; - } - case 'paragraph': { - if (token.tokens?.length === 1 && token.tokens[0].type === 'image') { - elements.push(this.parseImageToken(token.tokens[0])); - } - else { - elements.push(new elements_1.TextElement({ - text: this.parseParagraphToken(token), - level: elements_1.TextElementLevel.Paragraph, - })); - } - break; - } - case 'text': { - elements.push(this.parseTextToken(token)); - break; - } - case 'list': - const listItems = this.parseListToken(token); - elements.push(...listItems); - break; - case 'blockquote': { - elements.push(this.parseBlockQuoteToken(token)); - break; - } - case 'code': - elements.push(this.parseCodeToken(token)); - break; - case 'callout': - elements.push(this.parseCalloutToken(token)); - break; - case 'table': { - elements.push(this.parseTableToken(token)); - break; - } - case 'hr': - elements.push(new elements_1.DividerElement()); - break; - case 'image': - elements.push(this.parseImageToken(token)); - break; - case 'html': - elements.push(...this.parseHtmlToken(token)); - break; - case 'link': - elements.push(this.parseLinkToken(token)); - break; - case 'strong': - case 'em': - case 'del': - elements.push(this.parseTextToken(token)); - break; - case 'blockKatex': - elements.push(this.parseBlockKatexToken(token)); - break; - case 'inlineKatex': - elements.push(this.parseBlockKatexToken(token)); - break; - default: - break; + convertDivider() { + return { + type: 'divider', + object: 'block', + divider: {}, + }; + } + getNotionLanguageFromElementLanguage(language) { + const languageMap = { + [elements_1.ElementCodeLanguage.JavaScript]: 'javascript', + [elements_1.ElementCodeLanguage.TypeScript]: 'typescript', + [elements_1.ElementCodeLanguage.Python]: 'python', + [elements_1.ElementCodeLanguage.Java]: 'java', + [elements_1.ElementCodeLanguage.CSharp]: 'c#', + [elements_1.ElementCodeLanguage.CPlusPlus]: 'c++', + [elements_1.ElementCodeLanguage.Go]: 'go', + [elements_1.ElementCodeLanguage.Ruby]: 'ruby', + [elements_1.ElementCodeLanguage.Swift]: 'swift', + [elements_1.ElementCodeLanguage.Kotlin]: 'kotlin', + [elements_1.ElementCodeLanguage.Rust]: 'rust', + [elements_1.ElementCodeLanguage.Scala]: 'scala', + [elements_1.ElementCodeLanguage.Shell]: 'bash', // Mapping to 'bash' as Notion supports bash/shell + [elements_1.ElementCodeLanguage.SQL]: 'sql', + [elements_1.ElementCodeLanguage.HTML]: 'html', + [elements_1.ElementCodeLanguage.CSS]: 'css', + [elements_1.ElementCodeLanguage.JSON]: 'json', + [elements_1.ElementCodeLanguage.YAML]: 'yaml', + [elements_1.ElementCodeLanguage.Markdown]: 'markdown', + [elements_1.ElementCodeLanguage.Mermaid]: 'mermaid', + [elements_1.ElementCodeLanguage.PlainText]: 'plain text', // Mapping to 'plain text' as Notion supports this + }; + // Return the mapped Notion language, or 'plain text' as a fallback + return languageMap[language] || 'plain text'; + } + convertCodeBlock(element) { + return { + type: 'code', + object: 'block', + code: { + rich_text: this.convertRichText(element.text), + language: this.getNotionLanguageFromElementLanguage(element.language), + }, + }; + } + async convertImage(element) { + // Check if it's a local image path + if (this.isLocalImagePath(element.url)) { + return this.convertLocalImage(element); + } + else { + return this.convertExternalImage(element); + } + } + /** + * Convert local image using file upload service + */ + async convertLocalImage(element) { + if (!this.fileUploadService || !element.url) { + this.logger.warn('File upload service not available or no image URL provided, converting to paragraph'); + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: `[Image: ${element.caption || element.url || 'unknown'}]`, + }, + }, + ], + color: 'default', + }, + }; + } + try { + // Determine the base path for resolving relative image paths + // Use the filepath from the ImageElement (set during parsing), fallback to basePath + const imageBasePath = element.filepath + ? path.dirname(element.filepath) + : this.basePath; + this.logger.info(`Uploading local image: ${element.url}`); + const uploadResult = await this.fileUploadService.uploadFile({ + filePath: element.url, + basePath: imageBasePath, + }); + return { + type: 'image', + object: 'block', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + image: { + type: 'file_upload', + file_upload: { + id: uploadResult.id, + }, + caption: element.caption + ? [{ type: 'text', text: { content: element.caption } }] + : [], + }, // eslint-disable-line @typescript-eslint/no-explicit-any -- Notion file upload block structure not in types yet + }; + } + catch (error) { + this.logger.error(`Failed to upload local image ${element.url}:`, error); + // Fallback to paragraph with image reference + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: `[Failed to upload image: ${element.caption || element.url}]`, + }, + }, + ], + color: 'default', + }, + }; } - return elements; } - parse({ content }) { - const tokens = this.preParseMarkdown(content); - const elements = []; - for (const token of tokens) { - elements.push(...this.parseToken(token)); + /** + * Convert external image using external URL (original behavior) + */ + convertExternalImage(element) { + if (element.url && + !SUPPORTED_IMAGE_URL_EXTENSIONS.some((extension) => element.url?.endsWith(extension))) { + this.logger.warn(`Unsupported image URL extension: ${element.url}`); + return { + type: 'paragraph', + object: 'block', + paragraph: { + rich_text: [], + color: 'default', + }, + }; } - const result = { - content: elements, + return { + type: 'image', + object: 'block', + image: { + type: 'external', + external: { + url: element.url || '', + }, + }, }; - const fileMetadata = this.getMetadata(content); - if (fileMetadata.id) { - result.mkNotesInternalId = fileMetadata.id; - } - if (fileMetadata.title) { - result.title = fileMetadata.title; + } + convertHtml(element) { + return { + type: 'code', + object: 'block', + code: { + language: 'html', + rich_text: this.convertRichText(element.html), + }, + }; + } + convertEquation(element) { + return { + type: 'equation', + object: 'block', + equation: { expression: element.equation }, + }; + } + convertRichText(content) { + if (content === undefined) { + return []; } - if (fileMetadata.icon) { - result.icon = fileMetadata.icon; + if (typeof content === 'string') { + // Split string into chunks of 2000 characters + const MAX_LENGTH = 2000; + const chunks = []; + for (let i = 0; i < content.length; i += MAX_LENGTH) { + chunks.push(content.slice(i, i + MAX_LENGTH)); + } + return chunks.map((chunk) => ({ + type: 'text', + text: { + content: chunk, + }, + })); } - if (fileMetadata.properties && Array.isArray(fileMetadata.properties)) { - result.properties = fileMetadata.properties; + if (Array.isArray(content)) { + return content.reduce((acc, element) => { + if (element.type === elements_1.ElementType.Text) { + acc.push({ + type: 'text', + text: { + content: element.text, + }, + annotations: { + bold: element.styles.bold, + italic: element.styles.italic, + strikethrough: element.styles.strikethrough, + underline: element.styles.underline, + code: element.styles.code, + }, + }); + } + if (element instanceof elements_1.LinkElement) { + acc.push({ + type: 'text', + text: { + content: element.text, + link: element.url.startsWith('http') + ? { url: element.url } + : null, + }, + }); + } + if (element instanceof elements_1.EquationElement) { + acc.push({ + type: 'equation', + equation: { + expression: element.equation, + }, + annotations: { + bold: element.styles.bold, + italic: element.styles.italic, + strikethrough: element.styles.strikethrough, + underline: element.styles.underline, + code: element.styles.code, + }, + }); + } + this.logger.warn(`Unsupported element type: ${element.type}`); + return acc; + }, []); } - return result; + throw new Error(`Unsupported content type: ${typeof content}`); + } + convertTableOfContents() { + return { + type: 'table_of_contents', + object: 'block', + table_of_contents: {}, + }; + } + convertToElement() { + throw new Error('Method not implemented.'); } } -exports.MarkdownParser = MarkdownParser; - - -/***/ }), - -/***/ 6772: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NotionConverterRepository = NotionConverterRepository; /***/ }), -/***/ 2792: +/***/ 1294: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -77766,7 +78007,6 @@ class NotionFileUploadService { const fileUpload = await this.createFileUpload(fileName, fileSize, resolvedPath); // Step 2: Send file content await this.sendFileContent(fileUpload.upload_url, resolvedPath); - // No step 3 needed - file is ready to use after step 2 this.logger.info(`Successfully uploaded file: ${fileName} with ID: ${fileUpload.id}`); return { id: fileUpload.id, @@ -77955,37 +78195,534 @@ exports.NotionFileUploadService = NotionFileUploadService; /***/ }), -/***/ 9717: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { +/***/ 1488: +/***/ ((__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(exports, "__esModule", ({ value: true })); +exports.NotionDestinationRepository = void 0; +const Element_1 = __nccwpck_require__(7672); +const error_1 = __nccwpck_require__(8109); +class NotionDestinationRepository { + notionClient; + logger; + notionConverter; + constructor({ logger, notionClient, notionConverter, }) { + this.notionClient = notionClient; + this.logger = logger; + this.notionConverter = notionConverter; + } + /** + * Delete all child blocks from a parent page + */ + async deleteChildBlocks({ parentPageId, }) { + try { + // Get all blocks in the parent page + const blocks = await this.notionClient.getBlockChildren({ + blockId: parentPageId, + }); + await this.notionClient.deleteBlocks({ + blockIds: blocks.map((block) => block.id), + }); + } + catch (error) { + // Deletion failed - throw the error to be handled upstream + throw error instanceof Error ? error : new Error(String(error)); + } + } + 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 the last match (closest to the end of the URL path) + return matches[matches.length - 1]; + } + async destinationIsAccessible({ parentObjectId, }) { + let page = null; + try { + page = await this.notionClient.getPage({ pageId: parentObjectId }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (_err) { + // Discard error, we'll check if it's a database + } + if (page) { + return true; + } + let database = null; + try { + database = await this.notionClient.getDatabaseById({ + databaseId: parentObjectId, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (_err) { + // Discard error, we'll check if it's a page + } + if (database) { + return true; + } + return false; + } + async getPage({ pageId }) { + const notionPage = await this.notionClient.getPage({ + pageId, + }); + if (!notionPage) { + return null; + } + const blocks = await this.notionClient.getPageBlocks({ pageId: pageId }); + notionPage.children = blocks; + return notionPage; + } + async createPage({ parentObjectId, parentObjectType, 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 datasourceId = await this.notionClient.getDataSourceIdFromDatabaseId({ + databaseId: parentObjectId, + }); + if (!datasourceId) { + throw new Error('Failed to get Datasource'); + } + const datasource = await this.notionClient.getDataSourceById({ + dataSourceId: datasourceId, + }); + if (!datasource) { + throw new Error('Failed to get Datasource'); + } + parent = { type: 'data_source_id', data_source_id: datasourceId }; + availableProperties.push(...Object.entries(datasource.properties).map(([name, property]) => ({ + name, + definition: property, + type: property.type, + }))); + } + const notionPage = await this.notionConverter.convertFromElement(pageElement, availableProperties); + // First create the page without children + const createdPage = await this.notionClient.createPage({ + parent, + properties: notionPage.properties ?? {}, + icon: notionPage.icon, + children: [], + }); + if (!createdPage.pageId) { + throw new Error('Failed to create Notion Page'); + } + // If there are children blocks, append them in chunks + if (notionPage.children && notionPage.children.length > 0) { + const children = notionPage.children; + const createdBlocks = await this.notionClient.appendChildToBlock({ + blockId: createdPage.pageId, + children: children, + }); + createdPage.children = createdBlocks; + } + const page = await this.getPage({ + pageId: createdPage.pageId, + }); + if (!page) { + throw new Error('Failed to create Notion Page'); + } + return page; + } + async updatePage({ pageId, pageElement, }) { + const notionPageId = pageId; + const notionPage = await this.notionConverter.convertFromElement(pageElement); + await this.notionClient.updatePage({ + pageId: notionPageId, + icon: notionPage.icon, + properties: notionPage.properties, + archived: false, + }); + let existingBlocks = await this.notionClient.getBlockChildren({ + blockId: notionPageId, + }); + let afterBlockId; + if (existingBlocks.length >= 2 && + existingBlocks[0].type === 'table_of_contents' && + existingBlocks[1].type === 'divider') { + this.logger.warn('First two blocks are TOC & Divider, appending to page after Divider'); + afterBlockId = existingBlocks[1]?.id; + existingBlocks = existingBlocks.slice(2); + } + // Remove all non-page blocks + await this.removeNonPageBlocks({ blocks: existingBlocks }); + if (notionPage.children && notionPage.children?.length > 0) { + let blocks = notionPage.children; + if (blocks.length >= 2 && + blocks[0]?.type === 'table_of_contents' && + blocks[1]?.type === 'divider') { + blocks = blocks.slice(2); + } + await this.notionClient.appendChildToBlock({ + blockId: notionPageId, + children: blocks, + afterBlockId: afterBlockId, + }); + } + await this.removeUnusedPageBlocks({ pageElement, blocks: existingBlocks }); + const page = await this.getPage({ pageId: notionPageId }); + if (!page) { + throw new Error('Failed to update Notion Page'); + } + return page; + } + async removeNonPageBlocks({ blocks, }) { + const blockIdsToDelete = blocks + .filter((block) => block.type !== 'child_page') + .map((block) => block.id); + await this.notionClient.deleteBlocks({ + blockIds: blockIdsToDelete, + }); + } + async removeUnusedPageBlocks({ pageElement, blocks, }) { + const pageBlocks = blocks.filter((block) => block.type === 'child_page'); + const newPageBlocksIds = pageElement.content + .filter((element) => element instanceof Element_1.PageElement) + .map((element) => element.id); + const unusedPageBlocks = pageBlocks + .filter((block) => !newPageBlocksIds.includes(block.id)) + .map((block) => block.id); + await this.notionClient.deleteBlocks({ + blockIds: unusedPageBlocks, + }); + } + // Used for root level index.md where the page is already present + async appendToPage({ pageId, pageElement, }) { + const notionPage = await this.notionConverter.convertFromElement(pageElement); + // Update page properties (title/icon) if specified in metadata + await this.updatePageProperties({ pageId, pageElement }); + if (notionPage.children && notionPage.children.length > 0) { + // Append blocks to the existing page + try { + await this.notionClient.appendChildToBlock({ + blockId: pageId, + children: notionPage.children, + }); + } + catch (error) { + if ((0, error_1.isNotionNestingValidationError)(error)) { + throw new error_1.NotionNestingValidationError({ message: 'Nesting error' }); + } + this.logger.debug(`Failed to append block to page ${pageId}:`, { + error, + block: notionPage.children, + }); + throw error; + } + } + } + async updatePageProperties({ pageId, pageElement, }) { + const notionPage = await this.notionConverter.convertFromElement(pageElement); + // Only update if there are properties to update + if (notionPage.properties || notionPage.icon) { + await this.notionClient.updatePage({ + pageId, + icon: notionPage.icon, + properties: notionPage.properties, + }); + } + } + async setPageLockedStatus({ pageId, lockStatus, }) { + const isLocked = lockStatus === 'locked'; + await this.notionClient.updatePage({ + pageId, + isLocked, + }); + } + async getPageLockedStatus({ pageId, }) { + const page = await this.notionClient.getPage({ pageId }); + if (!page) { + throw new Error('Page not found'); + } + const isLocked = page.isLocked ?? false; + if (isLocked === undefined) { + return 'unlocked'; + } + return isLocked ? 'locked' : 'unlocked'; + } + async getObjectType({ id, }) { + try { + await this.notionClient.getPage({ pageId: id }); + return 'page'; + } + catch { + try { + await this.notionClient.getDatabaseById({ databaseId: id }); + return 'database'; + } + catch { + return 'unknown'; + } + } + } +} +exports.NotionDestinationRepository = NotionDestinationRepository; + + +/***/ }), + +/***/ 947: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getInfrastructureInstances = void 0; +// Infrastructure imports +const file_converter_1 = __nccwpck_require__(2378); +const notion_converter_1 = __nccwpck_require__(5900); +const file_upload_service_1 = __nccwpck_require__(1294); +const notion_destination_1 = __nccwpck_require__(1488); +const notion_client_repository_1 = __nccwpck_require__(5530); +const html_1 = __nccwpck_require__(40); +const markdown_1 = __nccwpck_require__(456); +const fileSystem_source_1 = __nccwpck_require__(2718); +let infraInstances; +const buildInstances = ({ logger, notionApiKey, }) => { + const fileUploadService = new file_upload_service_1.NotionFileUploadService({ + apiKey: notionApiKey, + logger, + }); + const notionClient = new notion_client_repository_1.NotionClientRepository({ + apiKey: notionApiKey, + }); + const notionConverter = new notion_converter_1.NotionConverterRepository({ + logger, + fileUploadService, + }); + const htmlParser = new html_1.HtmlParser({ logger }); + const markdownParser = new markdown_1.MarkdownParser({ htmlParser, logger }); + return { + fileSystemSource: new fileSystem_source_1.FileSystemSourceRepository(), + fileConverter: new file_converter_1.FileConverter({ + logger, + htmlParser, + markdownParser, + }), + htmlParser, + markdownParser: new markdown_1.MarkdownParser({ + htmlParser, + logger, + }), + notionDestination: new notion_destination_1.NotionDestinationRepository({ + logger, + notionConverter, + notionClient, + }), + notionConverter, + }; +}; +const getInfrastructureInstances = (args) => { + if (!infraInstances) { + infraInstances = buildInstances(args); + } + return infraInstances; +}; +exports.getInfrastructureInstances = getInfrastructureInstances; + + +/***/ }), + +/***/ 5530: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NotionClientRepository = void 0; +const client_1 = __nccwpck_require__(8342); +const NotionPage_1 = __nccwpck_require__(9749); +class NotionClientRepository { + client; + constructor({ apiKey }) { + this.client = new client_1.Client({ + auth: apiKey, + logLevel: client_1.LogLevel.ERROR, + }); + } + /** + * ------------------------------------------------------------ + * GENERAL METHODS + * ------------------------------------------------------------ + */ + async search({ filter, }) { + return this.client.search({ filter }); + } + /** + * ------------------------------------------------------------ + * DATABASES METHODS + * ------------------------------------------------------------ + */ + async getDatabaseById({ databaseId, }) { + const response = await this.client.databases.retrieve({ + database_id: databaseId, + }); + if (!response) { + return null; + } + return response; + } + /** + * ------------------------------------------------------------ + * DATA SOURCES METHODS + * ------------------------------------------------------------ + */ + async getDataSourceById({ dataSourceId, }) { + const response = await this.client.dataSources.retrieve({ + data_source_id: dataSourceId, + }); + if (!response) { + return null; + } + return response; + } + async getDataSourceIdFromDatabaseId({ databaseId, }) { + const database = await this.getDatabaseById({ databaseId }); + if (!database || !('data_sources' in database)) { + throw new Error('Database does not have any datasources'); + } + return database.data_sources[0].id; + } + /** + * ------------------------------------------------------------ + * PAGES METHODS + * ------------------------------------------------------------ + */ + async getPage({ pageId, }) { + const response = await this.client.pages.retrieve({ page_id: pageId }); + if (!(0, client_1.isFullPage)(response)) { + throw new Error('Not able to retrieve Notion Page'); + } + return response + ? this.toNotionPage({ page: response, children: [] }) + : null; + } + async createPage({ parent, properties, icon, children, }) { + const response = await this.client.pages.create({ + parent, + properties: properties, + icon, + children, + }); + return this.toNotionPage({ + page: response, + children: [], + }); + } + async getPageBlocks({ pageId, }) { + return this.getBlockChildren({ blockId: pageId }); + } + async updatePage({ pageId, icon, properties, archived, isLocked, }) { + const updateBody = { + page_id: pageId, + properties: {}, + archived, + is_locked: isLocked, + }; + if (icon) { + updateBody.icon = icon; + } + if (properties?.title) { + updateBody.properties['title'] = properties.title; + } + const response = await this.client.pages.update(updateBody); + return this.toNotionPage({ + page: response, + children: [], + }); + } + async deletePage({ pageId }) { + await this.deleteBlock({ blockId: pageId }); + } + toNotionPage({ page, children, }) { + if (!page.id) { + throw new Error('Page ID is required'); + } + return new NotionPage_1.NotionPage({ + pageId: page.id, + children, + createdAt: new Date(page.created_time), + updatedAt: new Date(page.last_edited_time), + isLocked: page.is_locked ?? false, + }); + } + /** + * ------------------------------------------------------------ + * BLOCKS METHODS + * ------------------------------------------------------------ + */ + /** + * There is a limit of 100 block children that can be appended by a single API request. + * Arrays of block children longer than 100 will result in an error. + * + * see: https://developers.notion.com/reference/patch-block-children + */ + APPEND_BLOCK_CHILDREN_CHUNK_SIZE = 100; + async appendChildToBlock({ blockId, children, afterBlockId, }) { + const createdBlocks = []; + // Split children into chunks of 100 blocks + for (let i = 0; i < children.length; i += this.APPEND_BLOCK_CHILDREN_CHUNK_SIZE) { + const chunk = children.slice(i, i + this.APPEND_BLOCK_CHILDREN_CHUNK_SIZE); + const response = await this.client.blocks.children.append({ + block_id: blockId, + children: chunk, + after: afterBlockId, + }); + if (response.results.length > 0) { + createdBlocks.push(...response.results); + } + afterBlockId = createdBlocks[createdBlocks.length - 1]?.id; + } + return createdBlocks; + } + async deleteBlock({ blockId }) { + await this.client.blocks.delete({ block_id: blockId }); + } + DELETE_BLOCKS_CHUNK_SIZE = 50; + async deleteBlocks({ blockIds, }) { + for (let i = 0; i < blockIds.length; i += this.DELETE_BLOCKS_CHUNK_SIZE) { + const chunk = blockIds.slice(i, i + this.DELETE_BLOCKS_CHUNK_SIZE); + await Promise.all(chunk.map(async (blockId) => this.client.blocks.delete({ block_id: blockId }))); + } + } + async getBlock({ blockId, }) { + const response = await this.client.blocks.retrieve({ block_id: blockId }); + return response; + } + async updateBlock({ blockId, block, }) { + const response = await this.client.blocks.update({ + block_id: blockId, + ...block, + }); + return response; + } + async getBlockChildren({ blockId, }) { + const response = await this.client.blocks.children.list({ + block_id: blockId, + }); + return response.results; } - 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__(3913), exports); -__exportStar(__nccwpck_require__(8188), exports); -__exportStar(__nccwpck_require__(2792), exports); -__exportStar(__nccwpck_require__(6918), exports); -__exportStar(__nccwpck_require__(3942), exports); -__exportStar(__nccwpck_require__(7100), exports); +} +exports.NotionClientRepository = NotionClientRepository; /***/ }), -/***/ 6918: +/***/ 3126: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; @@ -78024,1271 +78761,647 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.NotionConverterRepository = void 0; -const path = __importStar(__nccwpck_require__(6928)); +exports.HtmlParser = void 0; +const DomSerializer = __importStar(__nccwpck_require__(9943)); +const domelementtype_1 = __nccwpck_require__(1108); +const htmlparser2_1 = __nccwpck_require__(3231); const elements_1 = __nccwpck_require__(7591); -const constants_1 = __nccwpck_require__(8642); -const NotionPage_1 = __nccwpck_require__(3913); -const SUPPORTED_IMAGE_URL_EXTENSIONS = [ - '.bmp', - '.gif', - '.heic', - '.jpeg', - '.jpg', - '.png', - '.svg', - '.tif', - '.tiff', -]; -class NotionConverterRepository { - logger; - fileUploadService; - currentFilePath; - basePath; - constructor({ logger, fileUploadService, }) { - this.logger = logger; - this.fileUploadService = fileUploadService; - } - setCurrentFilePath(filePath) { - this.currentFilePath = filePath; - } - setBasePath(basePath) { - this.basePath = basePath; - } - /** - * Determine if an image URL is a local file path (relative or absolute local path) - */ - isLocalImagePath(url) { - if (!url) - return false; - // External URLs (http/https) - if (url.startsWith('http://') || url.startsWith('https://')) { - return false; - } - // Data URLs - if (url.startsWith('data:')) { - return false; - } - // Relative paths or absolute local paths - return true; - } - // ============================================ - // 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', - title: [ - { - type: 'text', - text: { - content: element.title, - link: null, - }, - }, - ], - }; - 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) { - const convertedElement = await this.convertElement(contentElement); - if (convertedElement) { - result.children?.push(convertedElement); - } - } - const icon = element.getIcon(); - if (icon) { - result.icon = { type: 'emoji', emoji: icon }; - } - return result; - } - async convertElement(element) { - switch (element.type) { - case elements_1.ElementType.Page: - return null; // Pages should not be converted as child blocks - case elements_1.ElementType.Text: - return this.convertText(element); - case elements_1.ElementType.Quote: - return this.convertQuote(element); - case elements_1.ElementType.Callout: - return this.convertCallout(element); - case elements_1.ElementType.ListItem: - return this.convertListItem(element); - case elements_1.ElementType.Table: - return this.convertTable(element); - case elements_1.ElementType.Toggle: - return await this.convertToggle(element); - case elements_1.ElementType.Link: - return this.convertLink(element); - case elements_1.ElementType.Divider: - return this.convertDivider(); - case elements_1.ElementType.Code: - return this.convertCodeBlock(element); - case elements_1.ElementType.Image: - return await this.convertImage(element); - case elements_1.ElementType.Html: - return this.convertHtml(element); - case elements_1.ElementType.TableOfContents: - return this.convertTableOfContents(); - case elements_1.ElementType.Equation: - return this.convertEquation(element); - default: - this.logger.warn(`Unsupported element type: ${element.type}`); - return null; - } - } - async convertFromElement(element, availableProperties = []) { - const notionPageInput = await this.convertPageElement(element, availableProperties); - return NotionPage_1.NotionPage.fromPartialCreatePageBodyParameters(notionPageInput); +class HtmlParser extends elements_1.ParserRepository { + constructor({ logger }) { + super({ logger }); } - convertText(element) { - switch (element.level) { - case elements_1.TextElementLevel.Heading1: - return { - type: 'heading_1', - object: 'block', - heading_1: { - rich_text: this.convertRichText(element.text), - color: 'default', - is_toggleable: false, // Set based on your requirements - }, - }; - case elements_1.TextElementLevel.Heading2: - return { - type: 'heading_2', - object: 'block', - heading_2: { - rich_text: this.convertRichText(element.text), - color: 'default', - is_toggleable: false, - }, - }; - case elements_1.TextElementLevel.Heading3: - return { - type: 'heading_3', - object: 'block', - heading_3: { - rich_text: this.convertRichText(element.text), - color: 'default', - is_toggleable: false, - }, - }; - case elements_1.TextElementLevel.Paragraph: - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: this.convertRichText(element.text), - color: 'default', - }, - }; - default: - this.logger.warn(`Unsupported text level ${element.level} - using paragraph`); - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: this.convertRichText(element.text), - color: 'default', - }, - }; + parse({ content }) { + const document = (0, htmlparser2_1.parseDocument)(content); + const elements = []; + for (const node of document.children) { + if (node.type === domelementtype_1.ElementType.Tag) { + switch (node.name) { + case 'details': { + const summaryNode = htmlparser2_1.DomUtils.findOne((n) => n.name === 'summary', node.children); + const detailsContent = DomSerializer.render(node); + elements.push(new elements_1.ToggleElement({ + title: summaryNode ? htmlparser2_1.DomUtils.textContent(summaryNode) : '', + children: [new elements_1.TextElement({ text: detailsContent })], + })); + break; + } + case 'kbd': + case 'samp': + const codeElement = new elements_1.CodeElement({ + text: htmlparser2_1.DomUtils.textContent(node), + language: elements_1.ElementCodeLanguage.PlainText, + }); + elements.push(codeElement); + break; + case 'sub': + this.logger.warn(' tag is not supported'); + break; + case 'sup': + this.logger.warn(' tag is not supported'); + break; + case 'ins': + elements.push(new elements_1.TextElement({ + text: htmlparser2_1.DomUtils.textContent(node), + styles: { underline: true }, + })); + break; + case 'del': + elements.push(new elements_1.TextElement({ + text: htmlparser2_1.DomUtils.textContent(node), + styles: { strikethrough: true }, + })); + break; + case 'var': + elements.push(new elements_1.TextElement({ + text: htmlparser2_1.DomUtils.textContent(node), + styles: { italic: true }, + })); + break; + case 'q': + elements.push(new elements_1.QuoteElement({ + text: htmlparser2_1.DomUtils.textContent(node), + })); + break; + case 'div': + elements.push(new elements_1.DividerElement()); + break; + default: + break; + } + } } - } - convertQuote(element) { return { - type: 'quote', - object: 'block', - quote: { - rich_text: this.convertRichText(element.text), - }, + content: elements, }; } - convertCallout(element) { - const icon = element.getIcon(); - const calloutParams = { - rich_text: this.convertRichText(element.text), - icon: undefined, - }; - if (icon) { - // @ts-expect-error - Notion API types are incorrect - calloutParams.icon = { type: 'emoji', emoji: icon }; - } - return { - type: 'callout', - object: 'block', - callout: calloutParams, - }; +} +exports.HtmlParser = HtmlParser; + + +/***/ }), + +/***/ 40: +/***/ (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]; } }; } - async convertListItem(element) { - let item; - if (element.listType === 'unordered') { - item = await this.convertBulletedListItem(element); - } - else { - item = await this.convertNumberedListItem(element); - } - return item; + 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__(3126), exports); + + +/***/ }), + +/***/ 456: +/***/ (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]; } }; } - async convertBulletedListItem(element) { - return { - type: 'bulleted_list_item', - object: 'block', - bulleted_list_item: { - rich_text: this.convertRichText(element.text), - children: await this.convertListItemChildren(element.children), - }, - }; + 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__(5974), exports); +__exportStar(__nccwpck_require__(1521), exports); + + +/***/ }), + +/***/ 5974: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.MarkdownParser = void 0; +const front_matter_1 = __importDefault(__nccwpck_require__(2247)); +const marked_1 = __nccwpck_require__(9257); +const marked_katex_extension_1 = __importDefault(__nccwpck_require__(7647)); +const elements_1 = __nccwpck_require__(7591); +class MarkdownParser extends elements_1.ParserRepository { + htmlParser; + // Used during synchronous parse() call to provide context for image paths + parsingFilePath; + constructor({ htmlParser, logger, }) { + super({ logger }); + this.htmlParser = htmlParser; + marked_1.marked.use((0, marked_katex_extension_1.default)({ throwOnError: false, nonStandard: true })); } - async convertNumberedListItem(element) { - return { - type: 'numbered_list_item', - object: 'block', - numbered_list_item: { - rich_text: this.convertRichText(element.text), - children: await this.convertListItemChildren(element.children), - }, - }; + preParseMarkdown(src) { + const { body } = (0, front_matter_1.default)(src); + return marked_1.marked.lexer(body); } - async convertListItemChildren(children) { - const convertedChildren = (await Promise.all(children?.map(async (child) => this.convertElement(child)) ?? [])).filter((child) => child !== null); - if (convertedChildren.length === 0) { - return undefined; + getMetadata(src) { + const { attributes } = (0, front_matter_1.default)(src); + if (!attributes || typeof attributes !== 'object') { + return {}; } - return convertedChildren; + return attributes; } - convertTable(element) { - return { - type: 'table', - object: 'block', - table: { - table_width: element.rows[0]?.length || 0, - has_column_header: false, // Customize as needed - has_row_header: false, // Customize as needed - children: element.rows.map((row) => this.convertTableRow(row)), - }, + getTextLevelFromDepth(depth) { + const mapping = { + 1: elements_1.TextElementLevel.Heading1, + 2: elements_1.TextElementLevel.Heading2, + 3: elements_1.TextElementLevel.Heading3, + 4: elements_1.TextElementLevel.Heading4, + 5: elements_1.TextElementLevel.Heading5, + 6: elements_1.TextElementLevel.Heading6, }; + if (depth < 1 || depth > 6) { + return elements_1.TextElementLevel.Paragraph; + } + return mapping[depth]; } - convertTableRow(row) { - return { - type: 'table_row', - object: 'block', - table_row: { - cells: row.map((cell) => this.convertRichText(cell)), - }, - }; + /** + * Parse a heading token + */ + parseHeadingToken(token) { + const level = this.getTextLevelFromDepth(token.depth); + return new elements_1.TextElement({ + text: token.text, + level, + }); } - async convertToggle(element) { - const children = []; - for (const contentElement of element.children) { - const convertedElement = await this.convertElement(contentElement); - if (convertedElement) { - children.push(convertedElement); + parseListToken(token) { + return token.items.map((item) => { + let text = []; + const children = []; + const paragraph = item.tokens.shift(); + if (paragraph && paragraph.type === 'text') { + text = this.parseParagraphToken(paragraph); + } + // Check if the list item has nested tokens (like nested lists) + if (item.tokens) { + for (const nestedToken of item.tokens) { + const contentItem = this.parseToken(nestedToken); + children.push(...contentItem); + } } + return new elements_1.ListItemElement({ + listType: token.ordered ? 'ordered' : 'unordered', + text, + children: children.length > 0 ? children : undefined, + }); + }); + } + parseBlockQuoteToken(token) { + const text = token.text.trim(); + if (text.startsWith('[!NOTE]')) { + return new elements_1.CalloutElement({ + text: text.replace('[!NOTE]', '').trim(), + icon: '💡', + }); } - return { - type: 'toggle', - object: 'block', - toggle: { - rich_text: this.convertRichText(element.title), - children, - }, - }; + return new elements_1.QuoteElement({ + text: text, + }); } - convertLink(element) { - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [ - { - text: { - content: element.text, - link: element.url.startsWith('http') - ? { url: element.url } - : null, - }, - }, - ], - color: 'default', - }, - }; + parseCodeToken(token) { + const language = token.lang || elements_1.ElementCodeLanguage.PlainText; + if (language === 'js') { + return new elements_1.CodeElement({ + text: token.text, + language: elements_1.ElementCodeLanguage.JavaScript, + }); + } + const isSupportedLanguage = (0, elements_1.isElementCodeLanguage)(language); + if (!isSupportedLanguage) { + return new elements_1.CodeElement({ + text: token.text, + language: elements_1.ElementCodeLanguage.PlainText, + }); + } + return new elements_1.CodeElement({ + text: token.text, + language, + }); } - convertDivider() { - return { - type: 'divider', - object: 'block', - divider: {}, - }; + parseCalloutToken(token) { + if (!token.callout || typeof token.callout !== 'string') { + throw new Error('Callout token does not have a callout property'); + } + return new elements_1.CalloutElement({ + text: token.callout, + icon: '💡', + }); } - getNotionLanguageFromElementLanguage(language) { - const languageMap = { - [elements_1.ElementCodeLanguage.JavaScript]: 'javascript', - [elements_1.ElementCodeLanguage.TypeScript]: 'typescript', - [elements_1.ElementCodeLanguage.Python]: 'python', - [elements_1.ElementCodeLanguage.Java]: 'java', - [elements_1.ElementCodeLanguage.CSharp]: 'c#', - [elements_1.ElementCodeLanguage.CPlusPlus]: 'c++', - [elements_1.ElementCodeLanguage.Go]: 'go', - [elements_1.ElementCodeLanguage.Ruby]: 'ruby', - [elements_1.ElementCodeLanguage.Swift]: 'swift', - [elements_1.ElementCodeLanguage.Kotlin]: 'kotlin', - [elements_1.ElementCodeLanguage.Rust]: 'rust', - [elements_1.ElementCodeLanguage.Scala]: 'scala', - [elements_1.ElementCodeLanguage.Shell]: 'bash', // Mapping to 'bash' as Notion supports bash/shell - [elements_1.ElementCodeLanguage.SQL]: 'sql', - [elements_1.ElementCodeLanguage.HTML]: 'html', - [elements_1.ElementCodeLanguage.CSS]: 'css', - [elements_1.ElementCodeLanguage.JSON]: 'json', - [elements_1.ElementCodeLanguage.YAML]: 'yaml', - [elements_1.ElementCodeLanguage.Markdown]: 'markdown', - [elements_1.ElementCodeLanguage.Mermaid]: 'mermaid', - [elements_1.ElementCodeLanguage.PlainText]: 'plain text', // Mapping to 'plain text' as Notion supports this - }; - // Return the mapped Notion language, or 'plain text' as a fallback - return languageMap[language] || 'plain text'; + parseTableToken(token) { + const headers = token.header.map((cell) => cell.text); + const rows = token.rows.map((row) => row.map((cell) => cell.text)); + return new elements_1.TableElement({ + rows: [headers, ...rows], + }); } - convertCodeBlock(element) { - return { - type: 'code', - object: 'block', - code: { - rich_text: this.convertRichText(element.text), - language: this.getNotionLanguageFromElementLanguage(element.language), - }, - }; + parseImageToken(token) { + return new elements_1.ImageElement({ + url: token.href, + caption: token.text, + filepath: this.parsingFilePath, + }); } - async convertImage(element) { - // Check if it's a local image path - if (this.isLocalImagePath(element.url)) { - return this.convertLocalImage(element); - } - else { - return this.convertExternalImage(element); - } + parseHtmlToken(token) { + const { content } = this.htmlParser.parse({ content: token.text }); + return content; } - /** - * Convert local image using file upload service - */ - async convertLocalImage(element) { - if (!this.fileUploadService || !element.url) { - this.logger.warn('File upload service not available or no image URL provided, converting to paragraph'); - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [ - { - type: 'text', - text: { - content: `[Image: ${element.caption || element.url || 'unknown'}]`, - }, - }, - ], - color: 'default', + parseLinkToken(token) { + return new elements_1.LinkElement({ + text: token.text, + url: token.href, + filepath: this.parsingFilePath, + }); + } + parseTextToken(token) { + if (token.type === 'strong') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: true, + italic: false, + strikethrough: false, + underline: false, + code: false, }, - }; - } - try { - // Determine the base path for resolving relative image paths - // Prefer the filepath from the ImageElement, fallback to currentFilePath, then basePath - let imageBasePath; - if (element.filepath) { - imageBasePath = path.dirname(element.filepath); - } - else if (this.currentFilePath) { - imageBasePath = path.dirname(this.currentFilePath); - } - else { - imageBasePath = this.basePath; - } - this.logger.info(`Uploading local image: ${element.url}`); - const uploadResult = await this.fileUploadService.uploadFile({ - filePath: element.url, - basePath: imageBasePath, }); - return { - type: 'image', - object: 'block', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - image: { - type: 'file_upload', - file_upload: { - id: uploadResult.id, - }, - caption: element.caption - ? [{ type: 'text', text: { content: element.caption } }] - : [], - }, // eslint-disable-line @typescript-eslint/no-explicit-any -- Notion file upload block structure not in types yet - }; } - catch (error) { - this.logger.error(`Failed to upload local image ${element.url}:`, error); - // Fallback to paragraph with image reference - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [ - { - type: 'text', - text: { - content: `[Failed to upload image: ${element.caption || element.url}]`, - }, - }, - ], - color: 'default', + if (token.type === 'em') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: false, + italic: true, + strikethrough: false, + underline: false, + code: false, }, - }; + }); } - } - /** - * Convert external image using external URL (original behavior) - */ - convertExternalImage(element) { - if (element.url && - !SUPPORTED_IMAGE_URL_EXTENSIONS.some((extension) => element.url?.endsWith(extension))) { - this.logger.warn(`Unsupported image URL extension: ${element.url}`); - return { - type: 'paragraph', - object: 'block', - paragraph: { - rich_text: [], - color: 'default', + if (token.type === 'del') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: false, + italic: false, + strikethrough: true, + underline: false, + code: false, }, - }; + }); } - return { - type: 'image', - object: 'block', - image: { - type: 'external', - external: { - url: element.url || '', + if (token.type === 'codespan') { + return new elements_1.TextElement({ + text: token.text, + styles: { + bold: false, + italic: false, + strikethrough: false, + underline: false, + code: true, }, + }); + } + return new elements_1.TextElement({ + text: token.text, + }); + } + parseBlockKatexToken(token) { + return new elements_1.EquationElement({ + equation: token.text, + styles: { + italic: false, + bold: false, + strikethrough: false, + underline: false, }, - }; + }); } - convertHtml(element) { - return { - type: 'code', - object: 'block', - code: { - language: 'html', - rich_text: this.convertRichText(element.html), - }, - }; + parseRawText(text) { + const tokens = this.preParseMarkdown(text); + const elements = []; + for (const t of tokens) { + switch (t.type) { + case 'paragraph': + elements.push(...this.parseParagraphToken(t)); + break; + case 'text': + elements.push(this.parseTextToken(t)); + break; + } + } + return elements; } - convertEquation(element) { - return { - type: 'equation', - object: 'block', - equation: { expression: element.equation }, - }; + parseParagraphToken(token) { + const elements = []; + token.tokens.forEach((t) => { + switch (t.type) { + case 'text': + elements.push(this.parseTextToken(t)); + break; + case 'inlineKatex': + elements.push(this.parseBlockKatexToken(t)); + break; + case 'strong': + elements.push(this.parseTextToken(t)); + break; + case 'em': + elements.push(this.parseTextToken(t)); + break; + case 'del': + elements.push(this.parseTextToken(t)); + break; + case 'codespan': + elements.push(this.parseTextToken(t)); + break; + case 'link': + elements.push(this.parseLinkToken(t)); + break; + case 'image': + elements.push(this.parseImageToken(t)); + break; + } + }); + return elements; } - convertRichText(content) { - if (content === undefined) { - return []; - } - if (typeof content === 'string') { - // Split string into chunks of 2000 characters - const MAX_LENGTH = 2000; - const chunks = []; - for (let i = 0; i < content.length; i += MAX_LENGTH) { - chunks.push(content.slice(i, i + MAX_LENGTH)); + parseToken(token) { + const elements = []; + switch (token.type) { + case 'heading': { + elements.push(this.parseHeadingToken(token)); + break; } - return chunks.map((chunk) => ({ - type: 'text', - text: { - content: chunk, - }, - })); - } - if (Array.isArray(content)) { - return content.reduce((acc, element) => { - if (element.type === elements_1.ElementType.Text) { - acc.push({ - type: 'text', - text: { - content: element.text, - }, - annotations: { - bold: element.styles.bold, - italic: element.styles.italic, - strikethrough: element.styles.strikethrough, - underline: element.styles.underline, - code: element.styles.code, - }, - }); - } - if (element instanceof elements_1.LinkElement) { - acc.push({ - type: 'text', - text: { - content: element.text, - link: element.url.startsWith('http') - ? { url: element.url } - : null, - }, - }); + case 'paragraph': { + if (token.tokens?.length === 1 && token.tokens[0].type === 'image') { + elements.push(this.parseImageToken(token.tokens[0])); } - if (element instanceof elements_1.EquationElement) { - acc.push({ - type: 'equation', - equation: { - expression: element.equation, - }, - annotations: { - bold: element.styles.bold, - italic: element.styles.italic, - strikethrough: element.styles.strikethrough, - underline: element.styles.underline, - code: element.styles.code, - }, - }); + else { + elements.push(new elements_1.TextElement({ + text: this.parseParagraphToken(token), + level: elements_1.TextElementLevel.Paragraph, + })); } - this.logger.warn(`Unsupported element type: ${element.type}`); - return acc; - }, []); + break; + } + case 'text': { + elements.push(this.parseTextToken(token)); + break; + } + case 'list': + const listItems = this.parseListToken(token); + elements.push(...listItems); + break; + case 'blockquote': { + elements.push(this.parseBlockQuoteToken(token)); + break; + } + case 'code': + elements.push(this.parseCodeToken(token)); + break; + case 'callout': + elements.push(this.parseCalloutToken(token)); + break; + case 'table': { + elements.push(this.parseTableToken(token)); + break; + } + case 'hr': + elements.push(new elements_1.DividerElement()); + break; + case 'image': + elements.push(this.parseImageToken(token)); + break; + case 'html': + elements.push(...this.parseHtmlToken(token)); + break; + case 'link': + elements.push(this.parseLinkToken(token)); + break; + case 'strong': + case 'em': + case 'del': + elements.push(this.parseTextToken(token)); + break; + case 'blockKatex': + elements.push(this.parseBlockKatexToken(token)); + break; + case 'inlineKatex': + elements.push(this.parseBlockKatexToken(token)); + break; + default: + break; } - throw new Error(`Unsupported content type: ${typeof content}`); + return elements; } - convertTableOfContents() { - return { - type: 'table_of_contents', - object: 'block', - table_of_contents: {}, + parse({ content, filepath, }) { + // Set the filepath context for use during this synchronous parse operation + this.parsingFilePath = filepath; + const tokens = this.preParseMarkdown(content); + const elements = []; + for (const token of tokens) { + elements.push(...this.parseToken(token)); + } + const result = { + content: elements, }; - } - convertToElement() { - throw new Error('Method not implemented.'); + const fileMetadata = this.getMetadata(content); + if (fileMetadata.id) { + result.id = 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; + } + // Clear the filepath context after parsing + this.parsingFilePath = undefined; + return result; } } -exports.NotionConverterRepository = NotionConverterRepository; +exports.MarkdownParser = MarkdownParser; + + +/***/ }), + +/***/ 1521: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); /***/ }), -/***/ 3942: +/***/ 2718: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; 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); -class NotionDestinationRepository { - client; - logger; - notionConverter; - constructor({ apiKey, logger, notionConverter, }) { - this.client = new client_1.Client({ - auth: apiKey, - logLevel: client_1.LogLevel.ERROR, - }); - this.logger = logger; - this.notionConverter = notionConverter; - } - /** - * Delete all child blocks from a parent page - */ - async deleteChildBlocks({ parentPageId, }) { +exports.FileSystemSourceRepository = void 0; +const fs_1 = __nccwpck_require__(9896); +const path_1 = __nccwpck_require__(6928); +const synchronization_1 = __nccwpck_require__(1230); +class FileSystemSourceRepository { + isFile(path) { try { - // Get all blocks in the parent page - const blocks = await this.getBlocksFromPage({ - notionPageId: parentPageId, - }); - // Delete each block - for (const block of blocks) { - await this.client.blocks.delete({ block_id: block.id }); - } + const stats = (0, fs_1.statSync)(path); + return stats.isFile(); } - catch (error) { - // Deletion failed - throw the error to be handled upstream - throw error instanceof Error ? error : new Error(String(error)); + catch { + return false; } } - 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'); + isDirectory(path) { + try { + const stats = (0, fs_1.statSync)(path); + return stats.isDirectory(); + } + catch { + return false; } - // Return the last match (closest to the end of the URL path) - return matches[matches.length - 1]; } - async destinationIsAccessible({ parentObjectId, }) { + isReadableRecursiveSync(path) { try { - await this.getPage({ pageId: parentObjectId }); + // Check if the path is readable + (0, fs_1.accessSync)(path, fs_1.constants.R_OK); + // Get directory contents + const entries = (0, fs_1.readdirSync)(path, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = (0, path_1.join)(path, entry.name); + if (entry.isDirectory()) { + // Recursively check subdirectory readability + if (!this.isReadableRecursiveSync(fullPath)) + return false; + } + else { + // Check if the file is readable + try { + (0, fs_1.accessSync)(fullPath, fs_1.constants.R_OK); + } + catch { + return false; + } + } + } return true; // eslint-disable-next-line @typescript-eslint/no-unused-vars } - catch (err) { - try { - await this.getDatabaseById({ databaseId: parentObjectId }); - return true; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } - catch (_err) { - return false; - } - } - } - async getPageById({ notionPageId, }) { - const pageObjectResponse = await this.client.pages.retrieve({ - page_id: notionPageId, - }); - if (!(0, client_1.isFullPage)(pageObjectResponse)) { - throw new Error('Not able to retrieve Notion Page'); + catch (error) { + return false; } - const blocks = await this.getBlocksFromPage({ notionPageId }); - return new NotionPage_1.NotionPage({ - pageId: pageObjectResponse.id, - children: blocks, - createdAt: new Date(pageObjectResponse.created_time), - updatedAt: new Date(pageObjectResponse.last_edited_time), - isLocked: pageObjectResponse.is_locked ?? false, - }); } - async createPage({ parentObjectId, parentObjectType, pageElement, filePath, }) { - // Set the current file path for image resolution - if (filePath) { - this.notionConverter.setCurrentFilePath(filePath); - } - 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, - }))); + isReadableFile(path) { + try { + (0, fs_1.accessSync)(path, fs_1.constants.R_OK); + return true; } - 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, - properties: notionPage.properties, - icon: notionPage.icon, - children: [], // Create page without children initially - }); - // If there are children blocks, append them in chunks - if (notionPage.children && notionPage.children.length > 0) { - const children = notionPage.children; - // Split children into chunks of 100 blocks - for (let i = 0; i < children.length; i += NOTION_BLOCK_LIMIT) { - const chunk = children.slice(i, i + NOTION_BLOCK_LIMIT); - await this.client.blocks.children.append({ - block_id: notionPageId, - children: chunk, - }); - } + catch { + return false; } - return this.getPageById({ notionPageId }); - } - async updateBlock({ blockId, block, }) { - return this.client.blocks.update({ - block_id: blockId, - ...block, - }); - } - async getPage({ pageId }) { - const page = await this.client.pages.retrieve({ page_id: pageId }); - return page; - } - async getChildBlocksFromBlock({ blockId, }) { - const response = await this.client.blocks.children.list({ - block_id: blockId, - }); - return response.results; - } - async getBlocksFromPage({ notionPageId, }) { - const blocks = await this.client.blocks.children.list({ - block_id: notionPageId, - }); - return blocks.results; } - async updatePage({ pageId, pageElement, filePath, }) { - const notionPageId = pageId; - // Set the current file path for image resolution - if (filePath) { - this.notionConverter.setCurrentFilePath(filePath); - } - const notionPage = await this.notionConverter.convertFromElement(pageElement); - const updateBody = { - page_id: notionPageId, - properties: {}, - }; - if (notionPage.icon) { - updateBody.icon = notionPage.icon; - } - if (notionPage?.properties?.Name) { - updateBody.properties['Title'] = notionPage.properties - .Title; - } - await this.client.pages.update({ - page_id: notionPageId, - icon: notionPage.icon, - properties: updateBody.properties, - }); - const existingBlocks = await this.getChildBlocksFromBlock({ - blockId: notionPageId, - }); - const pageBlocks = existingBlocks; - if (notionPage.children && notionPage.children?.length > 0) { - const blocks = notionPage.children; - const promises = existingBlocks - .filter((existingBlock, index) => { - // @ts-expect-error - We know that the blocks are not equal - return !(0, utils_1.isBlockEquals)(blocks[index], existingBlock); - }) - .map(async (existingBlock, index) => this.client.blocks - .update({ - block_id: existingBlock.id, - ...blocks[index], - }) - .then((block) => { - pageBlocks[index] = block; - })); - await Promise.all(promises); + // eslint-disable-next-line @typescript-eslint/require-await + async sourceIsAccessible({ path }) { + if (this.isFile(path)) { + return this.isReadableFile(path); } - // Now it's time to compare the existing blocks with the new blocks - // and update the existing blocks with the new ones - return this.getPageById({ notionPageId }); - } - // Used for root level index.md where the page is already present - async appendToPage({ pageId, pageElement, }) { - const notionPage = await this.notionConverter.convertFromElement(pageElement); - // Update page properties (title/icon) if specified in metadata - await this.updatePageProperties({ pageId, pageElement }); - if (notionPage.children && notionPage.children.length > 0) { - // Append blocks to the existing page - try { - await this.client.blocks.children.append({ - block_id: pageId, - children: notionPage.children, - }); - } - catch (error) { - if ((0, error_1.isNotionNestingValidationError)(error)) { - throw new error_1.NotionNestingValidationError({ message: 'Nesting error' }); - } - this.logger.debug(`Failed to append block to page ${pageId}:`, { - error, - block: notionPage.children, - }); - throw error; - } + else if (this.isDirectory(path)) { + return this.isReadableRecursiveSync(path); } + return false; } - async updatePageProperties({ pageId, pageElement, }) { - const notionPage = await this.notionConverter.convertFromElement(pageElement); - // Only update if there are properties to update - if (notionPage.properties || notionPage.icon) { - // Update page properties and icon separately to avoid type conflicts - const updatePayload = { - page_id: pageId, - }; - if (notionPage.properties) { - updatePayload.properties = notionPage.properties; - } - if (notionPage.icon) { - updatePayload.icon = notionPage.icon; + // eslint-disable-next-line @typescript-eslint/require-await + async getFilePathList({ path }) { + // If it's a single file, return it as a single-item array + if (this.isFile(path)) { + if (!path.endsWith('.md')) { + throw new Error(`File ${path} is not a markdown file. Only .md files are supported.`); } - await this.client.pages.update(updatePayload); - } - } - async search({ filter, }) { - return this.client.search({ - filter, - }); - } - async setPageLockedStatus({ pageId, lockStatus, }) { - const isLocked = lockStatus === 'locked'; - await this.client.pages.update({ - page_id: pageId, - is_locked: isLocked, - }); - } - async getPageLockedStatus({ pageId, }) { - const page = await this.client.pages.retrieve({ page_id: pageId }); - const isLocked = page.properties?.is_locked; - if (isLocked === undefined) { - return 'unlocked'; + return [path]; } - 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'; + // If it's a directory, collect all markdown files recursively + if (!this.isDirectory(path)) { + throw new Error(`Path ${path} is neither a file nor a directory.`); } - catch { + const markdownFiles = []; + const collectMarkdownFiles = (dirPath) => { try { - await this.client.databases.retrieve({ database_id: id }); - return 'database'; + const entries = (0, fs_1.readdirSync)(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = (0, path_1.join)(dirPath, entry.name); + if (entry.isDirectory()) { + // Recursively process subdirectories + collectMarkdownFiles(fullPath); + } + else if (entry.isFile() && fullPath.endsWith('.md')) { + // Store markdown file path + markdownFiles.push(fullPath); + } + } } - catch { - return 'unknown'; + catch (error) { + throw new Error(`Error reading directory ${dirPath}`, { cause: error }); } - } - } - 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; + }; + collectMarkdownFiles(path); + return markdownFiles; } - async getDatasourceByDatasourceId({ datasourceId, }) { - return this.client.dataSources.retrieve({ - data_source_id: datasourceId, - }); + getLastUpdatedDate(filePath) { + const stats = (0, fs_1.statSync)(filePath); + return stats.mtime; // mtime (modification time) represents last updated date } - 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, - }, - }, + // eslint-disable-next-line @typescript-eslint/require-await + async getFile({ path }) { + // Determine the display name for the Notion page + const base = (0, path_1.basename)(path); + let name = base; + if (base.toLowerCase().endsWith('.md')) { + // Remove .md extension for all other files + name = base.slice(0, -3); + } + return new synchronization_1.File({ + name, + content: (0, fs_1.readFileSync)(path, 'utf-8'), + extension: (0, path_1.extname)(path).slice(1), + lastUpdated: this.getLastUpdatedDate(path), + path, }); - return items.results.map((item) => item.id); } - async deleteObjectById({ objectId }) { - await this.client.blocks.delete({ block_id: objectId }); + // eslint-disable-next-line @typescript-eslint/require-await + async updateFile(file) { + return (0, fs_1.writeFileSync)(file.path, file.content, 'utf-8'); } } -exports.NotionDestinationRepository = NotionDestinationRepository; - - -/***/ }), - -/***/ 7100: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isBlockEquals = exports.normalizeBlock = void 0; -const normalizeBlock = (block) => { - let normalizedContent = ''; - if (block === undefined) { - return normalizedContent; - } - if ('paragraph' in block) { - normalizedContent = block.paragraph.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('heading_1' in block) { - normalizedContent = block.heading_1.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('heading_2' in block) { - normalizedContent = block.heading_2.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('heading_3' in block) { - normalizedContent = block.heading_3.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('bulleted_list_item' in block) { - normalizedContent = block.bulleted_list_item.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('numbered_list_item' in block) { - normalizedContent = block.numbered_list_item.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('to_do' in block) { - normalizedContent = block.to_do.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('toggle' in block) { - normalizedContent = block.toggle.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - else if ('callout' in block) { - normalizedContent = block.callout.rich_text - .map((rich_text) => rich_text.type === 'text' && rich_text.text.content) - .join(' '); - } - // Add other block types as needed - return normalizedContent; -}; -exports.normalizeBlock = normalizeBlock; -const isBlockEquals = (blockRequest, blockResponse) => { - const newNormalizedContent = (0, exports.normalizeBlock)(blockRequest); - const existingNormalizedContent = (0, exports.normalizeBlock)(blockResponse); - return newNormalizedContent === existingNormalizedContent; -}; -exports.isBlockEquals = isBlockEquals; +exports.FileSystemSourceRepository = FileSystemSourceRepository; /***/ }), @@ -83387,7 +83500,7 @@ module.exports = index; /***/ }), -/***/ 1638: +/***/ 9257: /***/ ((module) => { "use strict"; diff --git a/tsconfig.json b/tsconfig.json index 05f8521..2410e31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "strict": true, "strictFunctionTypes": true, "rootDir": ".", - "typeRoots": ["./api/types", "./node_modules/@types"], + "typeRoots": ["./node_modules/@types"], "esModuleInterop": true, "types": ["jest", "node"], "exactOptionalPropertyTypes": false, @@ -26,7 +26,6 @@ "dist", "__mocks__", "docs", - "__tests__", "node_modules", "jest.config.ts" ]