notion_to_md 2.5.1 → 3.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ class Database
5
+ # === NotionToMd::Database::Builder
6
+ #
7
+ # Responsible for fetching and building all pages of a Notion database
8
+ # into {NotionToMd::Page} instances. Handles pagination via `has_more`
9
+ # and `next_cursor` automatically.
10
+ #
11
+ # @example Build pages for a database
12
+ # notion_client = Notion::Client.new(token: ENV["NOTION_TOKEN"])
13
+ # pages = NotionToMd::Database::Builder.call(
14
+ # database_id: "xxxx-xxxx",
15
+ # notion_client: notion_client,
16
+ # filter: { property: "Year", number: { equals: 2023 } },
17
+ # sorts: [{ property: "Title", direction: "ascending" }],
18
+ # frontmatter: true
19
+ # )
20
+ #
21
+ # pages.each { |page| puts page.to_s }
22
+ #
23
+ # @see NotionToMd::Database
24
+ # @see NotionToMd::Page
25
+ class Builder
26
+ include Support::Pagination
27
+
28
+ class << self
29
+ # Build pages from a database.
30
+ #
31
+ # @param database_id [String] The Notion database ID.
32
+ # @param notion_client [Notion::Client] The Notion API client.
33
+ # @param filter [Hash, nil] Optional filter to pass to the Notion API.
34
+ # @param sorts [Array<Hash>, nil] Optional sort criteria.
35
+ # @param frontmatter [Boolean] Whether to include frontmatter in page Markdown output.
36
+ #
37
+ # @return [Array<NotionToMd::Page>] An array of page objects.
38
+ def call(database_id:, notion_client:, filter: nil, sorts: nil, frontmatter: false)
39
+ new(
40
+ database_id: database_id,
41
+ notion_client: notion_client,
42
+ filter: filter,
43
+ sorts: sorts,
44
+ frontmatter: frontmatter
45
+ ).call
46
+ end
47
+
48
+ # @!method build(...)
49
+ # Alias of {.call}.
50
+ alias build call
51
+ end
52
+
53
+ # @return [String] The database ID.
54
+ attr_reader :database_id
55
+
56
+ # @return [Notion::Client] The Notion API client.
57
+ attr_reader :notion_client
58
+
59
+ # @return [Hash, nil] Filter criteria passed to Notion API.
60
+ attr_reader :filter
61
+
62
+ # @return [Array<Hash>, nil] Sort criteria passed to Notion API.
63
+ attr_reader :sorts
64
+
65
+ # @return [Hash] Options for building pages (e.g. `{ frontmatter: true }`).
66
+ attr_reader :page_options
67
+
68
+ # Initialize a new builder.
69
+ #
70
+ # @param database_id [String] The Notion database ID.
71
+ # @param notion_client [Notion::Client] The Notion API client.
72
+ # @param filter [Hash, nil] Optional filter to pass to the Notion API.
73
+ # @param sorts [Array<Hash>, nil] Optional sort criteria.
74
+ # @param frontmatter [Boolean] Whether to include frontmatter in page Markdown output.
75
+ def initialize(database_id:, notion_client:, filter: nil, sorts: nil, frontmatter: false)
76
+ @database_id = database_id
77
+ @notion_client = notion_client
78
+ @filter = filter
79
+ @sorts = sorts
80
+ @page_options = { frontmatter: frontmatter }
81
+ end
82
+
83
+ # Fetch and build all pages of the database.
84
+ #
85
+ # @return [Array<NotionToMd::Page>]
86
+ def call
87
+ fetch_pages.map do |page|
88
+ NotionToMd::Page.call(id: page.id, notion_client: notion_client, **page_options)
89
+ end
90
+ end
91
+
92
+ # Fetch all raw page records from the Notion database.
93
+ #
94
+ # @return [Array<Notion::Messages::Message>] Raw page messages from the Notion API.
95
+ def fetch_pages
96
+ params = {}
97
+
98
+ paginate do |cursor|
99
+ params = { database_id: database_id, filter: filter, sorts: sorts }.compact
100
+ params[:start_cursor] = cursor if cursor
101
+
102
+ notion_client.database_query(params)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ # === NotionToMd::Database
5
+ #
6
+ # Represents a Notion database and allows converting its pages
7
+ # into Markdown documents.
8
+ #
9
+ # A `Database` object encapsulates the database metadata and
10
+ # the collection of {NotionToMd::Page} children it contains.
11
+ #
12
+ # @example Convert a Notion database to Markdown
13
+ # notion_client = Notion::Client.new(token: ENV["NOTION_TOKEN"])
14
+ # db = NotionToMd::Database.call(id: "xxxx-xxxx", notion_client: notion_client, frontmatter: true)
15
+ #
16
+ # db.to_s.each_with_index do |page_md, idx|
17
+ # File.write("page_#{idx}.md", page_md)
18
+ # end
19
+ #
20
+ # @see NotionToMd::Page
21
+ # @see NotionToMd::Database::Builder
22
+ class Database
23
+ include Support::MetadataProperties
24
+
25
+ class << self
26
+ # Build a new {Database} from the Notion API.
27
+ #
28
+ # @param id [String] The Notion database ID.
29
+ # @param notion_client [Notion::Client] The Notion API client.
30
+ # @param filter [Hash, nil] Optional filter criteria to pass to the Notion API.
31
+ # @param sorts [Array<Hash>, nil] Optional sort criteria.
32
+ # @param frontmatter [Boolean] Whether to include frontmatter in each page’s Markdown output.
33
+ #
34
+ # @return [NotionToMd::Database] a new database instance.
35
+ #
36
+ # @see .build
37
+ def call(id:, notion_client:, filter: nil, sorts: nil, frontmatter: false)
38
+ metadata = notion_client.database(database_id: id)
39
+ pages = Builder.call(
40
+ database_id: id,
41
+ notion_client: notion_client,
42
+ filter: filter,
43
+ sorts: sorts,
44
+ frontmatter: frontmatter
45
+ )
46
+
47
+ new(metadata: metadata, children: pages)
48
+ end
49
+
50
+ # @!method build(...)
51
+ # Alias of {.call}.
52
+ alias build call
53
+ end
54
+
55
+ # @return [Object] The metadata associated with the database.
56
+ attr_reader :metadata
57
+
58
+ # @return [Array<NotionToMd::Page>] The pages contained in the database.
59
+ attr_reader :children
60
+
61
+ # @!method pages
62
+ # Alias for {#children}.
63
+ alias pages children
64
+
65
+ # Initialize a new database representation.
66
+ #
67
+ # @param metadata [Object] The Notion database metadata.
68
+ # @param children [Array<NotionToMd::Page>] The pages belonging to the database.
69
+ def initialize(metadata:, children:)
70
+ @metadata = metadata
71
+ @children = children
72
+ end
73
+
74
+ # Convert all database pages into Markdown.
75
+ #
76
+ # @return [Array<String>] Markdown documents for each page in the database.
77
+ def to_s
78
+ pages.map(&:to_s)
79
+ end
80
+
81
+ # @!method to_md
82
+ # Alias for {#to_s}.
83
+ alias to_md to_s
84
+ end
85
+ end
@@ -6,6 +6,7 @@ class NotionToMd
6
6
 
7
7
  class << self
8
8
  extend Forwardable
9
+
9
10
  def_delegators :@logger, :debug, :info, :warn, :error, :fatal, :level, :level=
10
11
  end
11
12
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ # === NotionToMd::MetadataType
5
+ #
6
+ # Utility class responsible for extracting and sanitizing values
7
+ # from Notion database/page property objects. Each class method
8
+ # corresponds to a Notion property type and returns a normalized
9
+ # Ruby value suitable for use in YAML frontmatter or Markdown.
10
+ #
11
+ # This class is typically used indirectly via
12
+ # {NotionToMd::Support::MetadataProperties}.
13
+ #
14
+ # @example Extract a multi-select property
15
+ # prop = { multi_select: [{ name: "Action" }, { name: "Drama" }] }
16
+ # NotionToMd::MetadataType.multi_select(prop)
17
+ # # => ["Action", "Drama"]
18
+ #
19
+ # @example Extract a date property
20
+ # prop = { date: { start: "2024-01-01T00:00:00Z" } }
21
+ # NotionToMd::MetadataType.date(prop)
22
+ # # => 2024-01-01 00:00:00 +0000
23
+ #
24
+ # @see NotionToMd::Support::YamlSanitizer
25
+ class MetadataType
26
+ class << self
27
+ include Support::YamlSanitizer
28
+
29
+ # Extract file URL.
30
+ # @param prop [Hash]
31
+ # @return [String, nil]
32
+ def file(prop)
33
+ prop.dig(:file, :url)
34
+ rescue NoMethodError
35
+ nil
36
+ end
37
+
38
+ # Extract external file URL.
39
+ # @param prop [Hash]
40
+ # @return [String, nil]
41
+ def external(prop)
42
+ prop.dig(:external, :url)
43
+ rescue NoMethodError
44
+ nil
45
+ end
46
+
47
+ # Extract emoji character.
48
+ # @param prop [Hash]
49
+ # @return [String, nil]
50
+ def emoji(prop)
51
+ prop[:emoji]
52
+ rescue NoMethodError
53
+ nil
54
+ end
55
+
56
+ # Extract multi-select values as names.
57
+ # @param prop [Hash]
58
+ # @return [Array<String>, nil]
59
+ def multi_select(prop)
60
+ prop[:multi_select].map { |sel| sel[:name] }
61
+ rescue NoMethodError
62
+ nil
63
+ end
64
+
65
+ # Extract selected option name.
66
+ # Escapes YAML-sensitive characters.
67
+ # @param prop [Hash]
68
+ # @return [String, nil]
69
+ def select(prop)
70
+ escape_frontmatter_value(prop.dig(:select, :name))
71
+ rescue NoMethodError
72
+ nil
73
+ end
74
+
75
+ # Extract people names.
76
+ # @param prop [Hash]
77
+ # @return [Array<String>, nil]
78
+ def people(prop)
79
+ prop[:people].map { |sel| sel[:name] }
80
+ rescue NoMethodError
81
+ nil
82
+ end
83
+
84
+ # Extract file or external URLs from files list.
85
+ # @param prop [Hash]
86
+ # @return [Array<String>, nil]
87
+ def files(prop)
88
+ prop[:files].map { |f| file(f) || external(f) }
89
+ rescue NoMethodError
90
+ nil
91
+ end
92
+
93
+ # Extract phone number.
94
+ # @param prop [Hash]
95
+ # @return [String, nil]
96
+ def phone_number(prop)
97
+ prop[:phone_number]
98
+ rescue NoMethodError
99
+ nil
100
+ end
101
+
102
+ # Extract number.
103
+ # @param prop [Hash]
104
+ # @return [Numeric, nil]
105
+ def number(prop)
106
+ prop[:number]
107
+ rescue NoMethodError
108
+ nil
109
+ end
110
+
111
+ # Extract email.
112
+ # @param prop [Hash]
113
+ # @return [String, nil]
114
+ def email(prop)
115
+ prop[:email]
116
+ rescue NoMethodError
117
+ nil
118
+ end
119
+
120
+ # Extract checkbox (as string "true"/"false").
121
+ # @param prop [Hash]
122
+ # @return [String, nil]
123
+ def checkbox(prop)
124
+ prop[:checkbox]&.to_s
125
+ rescue NoMethodError
126
+ nil
127
+ end
128
+
129
+ # Extract date value.
130
+ # Converts strings to {Time}, {Date} to {Time}, leaves Time unchanged.
131
+ # End date and time zone are not supported.
132
+ #
133
+ # @param prop [Hash]
134
+ # @return [Time, String, nil] Parsed start date, raw value, or nil.
135
+ def date(prop)
136
+ date = prop.dig(:date, :start)
137
+
138
+ case date
139
+ when Date
140
+ date.to_time
141
+ when String
142
+ Time.parse(date)
143
+ else
144
+ date # Time or nil
145
+ end
146
+ rescue NoMethodError
147
+ nil
148
+ end
149
+
150
+ # Extract URL.
151
+ # @param prop [Hash]
152
+ # @return [String, nil]
153
+ def url(prop)
154
+ prop[:url]
155
+ rescue NoMethodError
156
+ nil
157
+ end
158
+
159
+ # Extract rich text as plain string, escaped for YAML if necessary.
160
+ # @param prop [Hash]
161
+ # @return [String, nil]
162
+ def rich_text(prop)
163
+ text = prop[:rich_text].map { |text| text[:plain_text] }.join
164
+ text.blank? ? nil : escape_frontmatter_value(text)
165
+ rescue NoMethodError
166
+ nil
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ class NotionToMd
4
+ class Page
5
+ # === NotionToMd::Page::Builder
6
+ #
7
+ # Builds a tree of Notion blocks for a given block (or page) ID by
8
+ # fetching children from the Notion API and recursively traversing
9
+ # blocks that are allowed to contain nested children.
10
+ #
11
+ # @example Build all blocks for a page
12
+ # notion = Notion::Client.new(token: ENV["NOTION_TOKEN"])
13
+ # blocks = NotionToMd::Page::Builder.call(block_id: "xxxx-xxxx", notion_client: notion)
14
+ # blocks # => [NotionToMd::Blocks::Block, ...]
15
+ #
16
+ # @see NotionToMd::Blocks::Factory
17
+ # @see NotionToMd::Blocks::Normalizer
18
+ class Builder
19
+ include Support::Pagination
20
+
21
+ # Block types allowed to have nested blocks (children).
22
+ #
23
+ # @return [Array<Symbol>]
24
+ BLOCKS_WITH_PERMITTED_CHILDREN = %i[
25
+ bulleted_list_item
26
+ numbered_list_item
27
+ paragraph
28
+ to_do
29
+ table
30
+ ].freeze
31
+
32
+ class << self
33
+ # Build the block tree from a starting block ID.
34
+ #
35
+ # @param block_id [String] The Notion block (or page) ID to expand.
36
+ # @param notion_client [Notion::Client] Initialized Notion client.
37
+ #
38
+ # @return [Array<NotionToMd::Blocks::Block>] Normalized, possibly nested blocks.
39
+ #
40
+ # @see .build
41
+ def call(block_id:, notion_client:)
42
+ new(block_id: block_id, notion_client: notion_client).call
43
+ end
44
+
45
+ # @!method build(...)
46
+ # Alias of {.call}.
47
+ alias build call
48
+ end
49
+
50
+ # @return [String] The root block (or page) ID to expand.
51
+ attr_reader :block_id
52
+
53
+ # @return [Notion::Client] The Notion API client.
54
+ attr_reader :notion_client
55
+
56
+ # Create a new builder.
57
+ #
58
+ # @param block_id [String] The Notion block (or page) ID to expand.
59
+ # @param notion_client [Notion::Client] Initialized Notion client.
60
+ def initialize(block_id:, notion_client:)
61
+ @block_id = block_id
62
+ @notion_client = notion_client
63
+ end
64
+
65
+ # Fetch, build, and normalize the full block tree.
66
+ #
67
+ # Recursively expands children for block types listed in
68
+ # {BLOCKS_WITH_PERMITTED_CHILDREN}. Uses {#fetch_blocks} to page through
69
+ # results via `has_more` / `next_cursor`.
70
+ #
71
+ # @return [Array<NotionToMd::Blocks::Block>] Normalized blocks ready for rendering.
72
+ def call
73
+ notion_blocks = fetch_blocks
74
+ blocks = notion_blocks.map do |block|
75
+ children = if permitted_children_for?(block: block)
76
+ self.class.call(block_id: block.id, notion_client: notion_client)
77
+ else
78
+ []
79
+ end
80
+ Blocks::Factory.build(block: block, children: children)
81
+ end
82
+
83
+ Blocks::Normalizer.call(blocks: blocks)
84
+ end
85
+
86
+ # Whether a block can have (and actually has) children to be traversed.
87
+ #
88
+ # @param block [Notion::Messages::Message] A Notion block message.
89
+ #
90
+ # @return [Boolean] True if the block type allows children and `has_children` is truthy.
91
+ def permitted_children_for?(block:)
92
+ BLOCKS_WITH_PERMITTED_CHILDREN.include?(block.type.to_sym) && block.has_children
93
+ end
94
+
95
+ # Fetch all direct children for {#block_id}, handling Notion pagination.
96
+ #
97
+ # @note This method loops until `has_more` is false, following `next_cursor`.
98
+ #
99
+ # @return [Array<Notion::Messages::Message>] Raw Notion block messages.
100
+ def fetch_blocks
101
+ params = {}
102
+
103
+ paginate do |cursor|
104
+ params[:block_id] = block_id
105
+ params[:start_cursor] = cursor if cursor
106
+
107
+ notion_client.block_children(params)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -3,135 +3,89 @@
3
3
  class NotionToMd
4
4
  # === NotionToMd::Page
5
5
  #
6
- # This class is responsible for representing a Notion page.
6
+ # Represents a Notion page and allows conversion to Markdown.
7
+ #
8
+ # A `Page` object encapsulates the page metadata, its content blocks,
9
+ # and optionally the frontmatter section.
10
+ #
11
+ # @example Convert a Notion page to Markdown
12
+ # notion_client = Notion::Client.new(token: ENV["NOTION_TOKEN"])
13
+ # page = NotionToMd::Page.call(id: "xxxx-xxxx", notion_client: notion_client, frontmatter: true)
14
+ # File.write("page.md", page.to_s)
15
+ #
7
16
  class Page
8
- include Helpers::YamlSanitizer
9
-
10
- attr_reader :page, :blocks
11
-
12
- def initialize(page:, blocks:)
13
- @page = page
14
- @blocks = blocks
15
- end
16
-
17
- def title
18
- title_list = page.dig(:properties, :Name, :title) || page.dig(:properties, :title, :title)
19
- title_list.inject('') do |acc, slug|
20
- acc + slug[:plain_text]
17
+ include Support::MetadataProperties
18
+ include Support::Frontmatter
19
+
20
+ class << self
21
+ # Build a new {Page} from the Notion API.
22
+ #
23
+ # @param id [String] The Notion page ID.
24
+ # @param notion_client [Notion::Client] The Notion API client.
25
+ # @param frontmatter [Boolean] Whether to include frontmatter in the output.
26
+ #
27
+ # @return [NotionToMd::Page] a new page instance.
28
+ #
29
+ # @see .build
30
+ def call(id:, notion_client:, frontmatter: false)
31
+ metadata = notion_client.page(page_id: id)
32
+ blocks = Builder.call(block_id: id, notion_client: notion_client)
33
+
34
+ new(metadata: metadata, children: blocks, frontmatter: frontmatter)
21
35
  end
22
- end
23
-
24
- def cover
25
- PageProperty.external(page[:cover]) || PageProperty.file(page[:cover])
26
- end
27
36
 
28
- def icon
29
- PageProperty.emoji(page[:icon]) || PageProperty.external(page[:icon]) || PageProperty.file(page[:icon])
37
+ # @!method build(...)
38
+ # Alias of {.call}.
39
+ alias build call
30
40
  end
31
41
 
32
- def id
33
- page[:id]
34
- end
35
-
36
- def created_time
37
- page['created_time']
38
- end
39
-
40
- def created_by_object
41
- page.dig(:created_by, :object)
42
- end
42
+ # @return [Object] The metadata associated with the page.
43
+ attr_reader :metadata
43
44
 
44
- def created_by_id
45
- page.dig(:created_by, :id)
46
- end
45
+ # @return [Array<#to_md>] The list of child blocks belonging to the page.
46
+ attr_reader :children
47
47
 
48
- def last_edited_time
49
- page['last_edited_time']
50
- end
48
+ # @!method blocks
49
+ # Alias for {#children}.
50
+ alias blocks children
51
51
 
52
- def last_edited_by_object
53
- page.dig(:last_edited_by, :object)
54
- end
55
-
56
- def last_edited_by_id
57
- page.dig(:last_edited_by, :id)
58
- end
59
-
60
- def url
61
- page[:url]
62
- end
63
-
64
- def archived
65
- page[:archived]
52
+ # Initialize a new Page.
53
+ #
54
+ # @param metadata [Object] The Notion page metadata.
55
+ # @param children [Array<#to_md>] The block children belonging to the page.
56
+ # @param frontmatter [Boolean] Whether to include frontmatter in the Markdown output.
57
+ def initialize(metadata:, children:, frontmatter: false)
58
+ @metadata = metadata
59
+ @children = children
60
+ @config = { frontmatter: frontmatter }
66
61
  end
67
62
 
63
+ # Render the body of the page (Markdown representation of its blocks).
64
+ #
65
+ # @return [String] The Markdown content of the page.
68
66
  def body
69
67
  @body ||= blocks.map(&:to_md).compact.join
70
68
  end
71
69
 
72
- def frontmatter
73
- @frontmatter ||= <<~CONTENT
74
- ---
75
- #{props.to_a.map do |k, v|
76
- "#{k}: #{v}"
77
- end.join("\n")}
78
- ---
79
- CONTENT
80
- end
81
-
82
- def props
83
- @props ||= custom_props.deep_merge(default_props)
70
+ # Whether the page includes frontmatter.
71
+ #
72
+ # @return [Boolean]
73
+ def frontmatter?
74
+ @config[:frontmatter]
84
75
  end
85
76
 
86
- def custom_props
87
- @custom_props ||= filtered_custom_properties
77
+ # Render the page as a Markdown string, including optional frontmatter.
78
+ #
79
+ # @return [String] The Markdown document.
80
+ def to_s
81
+ <<~MD
82
+ #{frontmatter if frontmatter?}
83
+ #{body}
84
+ MD
88
85
  end
89
86
 
90
- # rubocop:disable Metrics/MethodLength
91
- def default_props
92
- @default_props ||= {
93
- 'id' => id,
94
- 'title' => escape_frontmatter_value(title),
95
- 'created_time' => created_time,
96
- 'cover' => cover,
97
- 'icon' => icon,
98
- 'last_edited_time' => last_edited_time,
99
- 'archived' => archived,
100
- 'created_by_object' => created_by_object,
101
- 'created_by_id' => created_by_id,
102
- 'last_edited_by_object' => last_edited_by_object,
103
- 'last_edited_by_id' => last_edited_by_id
104
- }
105
- end
106
- # rubocop:enable Metrics/MethodLength
107
-
108
- # This class is kept for retro compatibility reasons.
109
- # Use instead the PageProperty class.
110
- class CustomProperty < PageProperty
111
- end
112
-
113
- private
114
-
115
- def filtered_custom_properties
116
- build_custom_properties.reject { |_k, v| v.presence.nil? }
117
- end
118
-
119
- def build_custom_properties
120
- page.properties.each_with_object({}) do |(name, value), memo|
121
- type = value.type
122
- next unless valid_custom_property_type?(type)
123
-
124
- key = name.parameterize.underscore
125
- memo[key] = build_custom_property(type, value)
126
- end
127
- end
128
-
129
- def valid_custom_property_type?(type)
130
- CustomProperty.respond_to?(type.to_sym)
131
- end
132
-
133
- def build_custom_property(type, value)
134
- CustomProperty.send(type, value)
135
- end
87
+ # @!method to_md
88
+ # Alias for {#to_s}.
89
+ alias to_md to_s
136
90
  end
137
91
  end