notion_to_md 2.5.1 → 3.0.0.beta2

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,114 @@
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
+ new(id: id, notion_client: notion_client, filter: filter, sorts: sorts, frontmatter: frontmatter).call
39
+ end
40
+
41
+ # @!method build(...)
42
+ # Alias of {.call}.
43
+ alias build call
44
+ end
45
+
46
+ # @return [String] The Notion id database.
47
+ attr_reader :id
48
+
49
+ # @param jNotion::Client] The Notion API client.
50
+ attr_reader :notion_client
51
+
52
+ # @return [Hash] The database configuration options.
53
+ attr_reader :config
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 id [String] The Notion database ID.
68
+ # @param notion_client [Notion::Client] The Notion API client.
69
+ # @param filter [Hash, nil] Optional filter criteria to pass to the Notion API.
70
+ # @param sorts [Array<Hash>, nil] Optional sort criteria.
71
+ # @param frontmatter [Boolean] Whether to include frontmatter in each page’s Markdown output.
72
+ def initialize(id:, notion_client:, filter:, sorts:, frontmatter: false)
73
+ @id = id
74
+ @notion_client = notion_client
75
+ @config = {
76
+ frontmatter: frontmatter,
77
+ filter: filter,
78
+ sorts: sorts
79
+ }
80
+ end
81
+
82
+ # Fetch database metadata and contained pages from the Notion API.
83
+ #
84
+ # This method populates the database's metadata and children by making API calls
85
+ # to retrieve the database structure and all pages contained within it, applying
86
+ # any configured filters and sorting criteria.
87
+ #
88
+ # @return [NotionToMd::Database] returns self to allow method chaining.
89
+ def call
90
+ @metadata = notion_client.database(database_id: id)
91
+ @children = Builder.call(
92
+ database_id: id,
93
+ notion_client: notion_client,
94
+ filter: config[:filter],
95
+ sorts: config[:sorts],
96
+ frontmatter: config[:frontmatter]
97
+ )
98
+ self
99
+ end
100
+
101
+ alias build call
102
+
103
+ # Convert all database pages into Markdown.
104
+ #
105
+ # @return [Array<String>] Markdown documents for each page in the database.
106
+ def to_s
107
+ pages.map(&:to_s)
108
+ end
109
+
110
+ # @!method to_md
111
+ # Alias for {#to_s}.
112
+ alias to_md to_s
113
+ end
114
+ 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