mddir 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 28e9c0901404556fd2aeeef6ccd427029da4d3f50f7fe0e5a55fa66b118cce1e
4
+ data.tar.gz: f74a0a254c16cf555c14497a36cc2925e791dcdf3bb5ebcb189cb6b0d2a1fdf1
5
+ SHA512:
6
+ metadata.gz: a593cff246076103d9348d5281063f63abd56fde7540d5596fd97adae0384f9e7a128546fc22ada3c1349ab1557ac5b4190ceb9ff64ce0c6f68befb1cb629997
7
+ data.tar.gz: ce4d88969d6e2054670b32780cd6492d3fb90d41e456322869754d993f8bd74d0934e88b6d6d68c780e5f8e3ac9fc17ef901499d354613e88a118dbcbc2bc112
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-02-28
4
+
5
+ - Fetch web pages and convert to clean markdown
6
+ - Organize saved pages into named collections
7
+ - Full-text search across all collections or within a single collection
8
+ - Built-in web UI for browsing, reading, and searching
9
+ - YAML frontmatter with metadata and token counts on every entry
10
+ - Cookie support for fetching pages behind authentication
11
+ - Configurable via `~/.mddir.yml` (base directory, port, editor, user agent)
12
+ - SQLite-backed search index with automatic reindexing
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ali Hamdi Ali Fadel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/AliOsm/mddir/main/mddir-logo.png" alt="mddir logo" width="200">
3
+ </p>
4
+
5
+ # mddir
6
+
7
+ Your web, saved locally — a markdown knowledge base for humans and agents.
8
+
9
+ [![Gem Version](https://img.shields.io/gem/v/mddir)](https://rubygems.org/gems/mddir)
10
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2.0-red)](https://www.ruby-lang.org)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://opensource.org/licenses/MIT)
12
+
13
+ mddir fetches web pages, converts them to clean markdown, and organizes them into local collections. It includes full-text search, a built-in web UI, and works with AI agents out of the box. Everything is stored as plain markdown files you own.
14
+
15
+ ![Home](https://raw.githubusercontent.com/AliOsm/mddir/main/screenshots/home.png)
16
+ ![Reader](https://raw.githubusercontent.com/AliOsm/mddir/main/screenshots/reader.png)
17
+
18
+ ## Installation
19
+
20
+ Requires Ruby >= 3.2.0. If you don't have Ruby installed, [mise](https://mise.jdx.dev) is the easiest way to get it:
21
+
22
+ ```bash
23
+ mise use --global ruby@3
24
+ ```
25
+
26
+ Then install the gem:
27
+
28
+ ```bash
29
+ gem install mddir
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ ```bash
35
+ # Save a page to a collection
36
+ mddir add ruby https://docs.ruby-lang.org/en/3.3/String.html
37
+
38
+ # List your collections
39
+ mddir ls
40
+
41
+ # Search across everything
42
+ mddir search "freeze"
43
+
44
+ # Open the web UI
45
+ mddir open
46
+ ```
47
+
48
+ ## CLI Reference
49
+
50
+ ### Core Commands
51
+
52
+ | Command | Description |
53
+ |---|---|
54
+ | `mddir add COLLECTION URL [URL...]` | Fetch web pages and save to a collection |
55
+ | `mddir search [COLLECTION] QUERY` | Search entries for a query string |
56
+ | `mddir ls [COLLECTION]` | List collections or entries in a collection |
57
+ | `mddir open` | Start the web UI and open in browser |
58
+ | `mddir serve` | Start the web UI server |
59
+
60
+ ### Management Commands
61
+
62
+ | Command | Description |
63
+ |---|---|
64
+ | `mddir rm COLLECTION [ENTRY]` | Remove a collection or entry |
65
+ | `mddir collection create NAME` | Create a new empty collection |
66
+ | `mddir reindex` | Rebuild the global index |
67
+ | `mddir config` | Open configuration file in editor |
68
+
69
+ ### Flags
70
+
71
+ | Flag | Applies to | Description |
72
+ |---|---|---|
73
+ | `--cookies PATH` | `add` | Path to a cookies file for authenticated fetching |
74
+
75
+ ## Web UI
76
+
77
+ Run `mddir open` to launch the built-in web UI at `http://localhost:7768`. Browse collections, read saved pages, and search your knowledge base from the browser.
78
+
79
+ ## Agent / LLM Integration
80
+
81
+ mddir is designed to work as a knowledge retrieval tool for AI agents. All content lives under `~/.mddir/` as plain markdown files with YAML frontmatter:
82
+
83
+ ```yaml
84
+ ---
85
+ url: https://example.com/article
86
+ title: Article Title
87
+ token_count: 1523
88
+ token_estimated: true
89
+ ---
90
+ ```
91
+
92
+ Agents can search the knowledge base directly:
93
+
94
+ ```bash
95
+ mddir search "concurrency patterns"
96
+ mddir search ruby "freeze"
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ Settings live in `~/.mddir.yml`. Run `mddir config` to open it in your editor.
102
+
103
+ | Option | Default | Description |
104
+ |---|---|---|
105
+ | `base_dir` | `~/.mddir` | Where collections are stored |
106
+ | `port` | `7768` | Web UI port |
107
+ | `editor` | `$EDITOR` or `vi` | Editor for `mddir config` |
108
+ | `user_agent` | Chrome UA string | User agent for fetching pages |
109
+
110
+ ## Cookie Support
111
+
112
+ Pass a cookies file to fetch pages behind authentication:
113
+
114
+ ```bash
115
+ mddir add docs https://private.example.com/page --cookies ~/cookies.txt
116
+ ```
117
+
118
+ ## Data Storage
119
+
120
+ ```
121
+ ~/.mddir/
122
+ ├── search.db
123
+ ├── index.yml
124
+ ├── ruby/
125
+ │ ├── index.yml
126
+ │ ├── string-a1b2c3.md
127
+ │ └── array-d4e5f6.md
128
+ └── golang/
129
+ ├── index.yml
130
+ └── concurrency-patterns-f7a8b9.md
131
+ ```
132
+
133
+ ## Todos
134
+
135
+ - [ ] Add local HTML and markdown files to collections
136
+ - [ ] Headless browser rendering for JavaScript-heavy pages
137
+ - [ ] Download and embed images locally instead of linking to remote URLs
138
+ - [ ] Re-fetch command to update stale entries
139
+ - [ ] Export a collection to a single combined markdown file
140
+ - [ ] Merge collections into one
141
+ - [ ] Fuzzy search and ranking improvements
142
+ - [ ] Archive mode to save raw HTML alongside markdown
143
+ - [ ] Migration command to relocate the base directory
144
+ - [ ] Collection pinning and sorting in the web UI
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ bundle install
150
+ rake test
151
+ rubocop
152
+ ```
153
+
154
+ ## Contributing
155
+
156
+ Bug reports and pull requests are welcome on GitHub at https://github.com/AliOsm/mddir. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/AliOsm/mddir/blob/main/CODE_OF_CONDUCT.md).
157
+
158
+ ## License
159
+
160
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/mddir ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "mddir"
5
+
6
+ Mddir::CLI.start(ARGV)
data/lib/mddir/cli.rb ADDED
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Mddir
6
+ module CLIConfig
7
+ private
8
+
9
+ def config
10
+ @config ||= Config.new
11
+ end
12
+ end
13
+
14
+ class CollectionCLI < Thor
15
+ include CLIConfig
16
+
17
+ namespace "collection"
18
+
19
+ desc "create NAME", "Create a new empty collection"
20
+ def create(name)
21
+ collection = Collection.new(name, config)
22
+
23
+ if collection.exist?
24
+ say "Collection '#{collection.name}' already exists"
25
+ return
26
+ end
27
+
28
+ collection.create!
29
+ say "Created collection '#{collection.name}'"
30
+ end
31
+ end
32
+
33
+ class CLI < Thor # rubocop:disable Metrics/ClassLength
34
+ include CLIConfig
35
+
36
+ desc "collection SUBCOMMAND", "Manage collections"
37
+ subcommand "collection", CollectionCLI
38
+
39
+ desc "add COLLECTION URL [URL...]", "Fetch web pages and save to a collection"
40
+ method_option :cookies, type: :string, desc: "Path to a cookies file"
41
+ def add(collection_name, *urls)
42
+ if urls.empty?
43
+ say_error "Error: provide at least one URL"
44
+ return
45
+ end
46
+
47
+ collection = Collection.new(collection_name, config)
48
+ collection.create! unless collection.exist?
49
+
50
+ fetcher = Fetcher.new(config, cookies_path: options[:cookies])
51
+ urls.each { |url| fetch_and_save(url, collection, fetcher) }
52
+ end
53
+
54
+ desc "ls [COLLECTION]", "List collections or entries in a collection"
55
+ def ls(collection_name = nil)
56
+ if collection_name
57
+ list_collection_entries(collection_name)
58
+ else
59
+ list_collections
60
+ end
61
+ end
62
+
63
+ desc "rm COLLECTION [ENTRY]", "Remove a collection or entry"
64
+ def rm(collection_name, entry_identifier = nil)
65
+ collection = Collection.new(collection_name, config)
66
+
67
+ unless collection.exist?
68
+ say_error "Error: collection '#{collection.name}' not found"
69
+ return
70
+ end
71
+
72
+ if entry_identifier
73
+ remove_entry(collection, entry_identifier)
74
+ else
75
+ remove_collection(collection)
76
+ end
77
+ end
78
+
79
+ desc "search [COLLECTION] QUERY", "Search entries for a query string"
80
+ def search(*args)
81
+ if args.empty?
82
+ say "Usage: mddir search [collection] <query>"
83
+ return
84
+ end
85
+
86
+ collection_name = args.length >= 2 ? args[0] : nil
87
+ query = args.length >= 2 ? args[1..].join(" ") : args[0]
88
+
89
+ results = Search.new(config).search(query, collection_name:)
90
+ results.empty? ? say("No matches found") : print_search_results(results)
91
+ end
92
+
93
+ desc "config", "Open configuration file in editor"
94
+ map "config" => :edit_config
95
+ def edit_config
96
+ config.create_default_config!
97
+ system(config.editor, config.path)
98
+ end
99
+
100
+ desc "reindex", "Rebuild the global index from per-collection indexes"
101
+ def reindex
102
+ data = GlobalIndex.update!(config)
103
+ collection_count = data["collections"]&.length || 0
104
+ total = data["total_entries"] || 0
105
+ say "Reindexed #{collection_count} collections, #{total} entries"
106
+ end
107
+
108
+ desc "serve", "Start the web UI server"
109
+ def serve
110
+ require_relative "server"
111
+ Server.start(config)
112
+ end
113
+
114
+ desc "open", "Start the web UI server and open in browser"
115
+ def open
116
+ require_relative "server"
117
+
118
+ url = "http://localhost:#{config.port}"
119
+ Thread.new do
120
+ sleep 1
121
+ open_browser(url)
122
+ end
123
+
124
+ Server.start(config)
125
+ end
126
+
127
+ private
128
+
129
+ def fetch_and_save(url, collection, fetcher)
130
+ if collection.entries.any? { |entry| entry["url"] == url }
131
+ say "Skipped (duplicate): #{url}"
132
+ return
133
+ end
134
+
135
+ entry = fetcher.fetch(url)
136
+ entry.save_to(collection.path)
137
+ collection.add_entry(entry.to_index_entry)
138
+ say "Saved: #{entry.filename} → #{collection.name} (#{entry.conversion})"
139
+ rescue FetchError, StandardError => e
140
+ say_error "Error fetching #{url}: #{e.message}"
141
+ end
142
+
143
+ def list_collections
144
+ collections = GlobalIndex.load(config)["collections"] || {}
145
+
146
+ if collections.empty?
147
+ say "No collections"
148
+ return
149
+ end
150
+
151
+ collections.each do |name, info|
152
+ count = info["entry_count"] || 0
153
+ label = count == 1 ? "entry" : "entries"
154
+ say "#{name.ljust(16)} | #{count} #{label}"
155
+ end
156
+ end
157
+
158
+ def list_collection_entries(collection_name)
159
+ collection = Collection.new(collection_name, config)
160
+
161
+ unless collection.exist?
162
+ say_error "Error: collection '#{collection.name}' not found"
163
+ return
164
+ end
165
+
166
+ entries = collection.entries
167
+ count = entries.length
168
+ label = count == 1 ? "entry" : "entries"
169
+ say "#{collection.name} (#{count} #{label})\n\n"
170
+
171
+ entries.each_with_index { |entry, idx| print_entry(entry, idx + 1) }
172
+ end
173
+
174
+ def print_entry(entry, number)
175
+ say " #{number}. #{entry["title"]}"
176
+ say " #{entry["description"]}" unless entry["description"].to_s.empty?
177
+ say " #{entry["filename"]}"
178
+ say " #{entry["url"]}"
179
+ say
180
+ end
181
+
182
+ def remove_entry(collection, identifier)
183
+ entry = collection.remove_entry(identifier)
184
+
185
+ if entry
186
+ say "Removed: #{entry["title"]}"
187
+ else
188
+ say_error "Error: entry not found"
189
+ end
190
+ end
191
+
192
+ def remove_collection(collection)
193
+ count = collection.entry_count
194
+ label = count == 1 ? "entry" : "entries"
195
+
196
+ if yes?("Remove collection '#{collection.name}' with #{count} #{label}? [y/N]")
197
+ collection.remove!
198
+ say "Removed collection '#{collection.name}'"
199
+ else
200
+ say "Cancelled"
201
+ end
202
+ end
203
+
204
+ def print_search_results(results)
205
+ total_matches = results.sum { |result| result.matches.length }
206
+ say "\nFound #{total_matches} matches in #{results.length} files\n\n"
207
+
208
+ results.each { |result| print_search_result(result) }
209
+ end
210
+
211
+ def print_search_result(result)
212
+ say "[#{result.collection_name}] #{result.entry["title"]}"
213
+ say " #{result.entry["filename"]}"
214
+ say " #{result.entry["url"]}"
215
+ result.matches.each { |match| say " Line #{match.line_number}: #{match.snippet}" }
216
+ say
217
+ end
218
+
219
+ def open_browser(url)
220
+ case RUBY_PLATFORM
221
+ when /darwin/
222
+ system("open", url)
223
+ when /linux/
224
+ system("xdg-open", url)
225
+ when /mswin|mingw/
226
+ system("start", url)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Mddir
7
+ class Collection
8
+ attr_reader :name, :config, :path
9
+
10
+ def initialize(name, config)
11
+ @name = Utils.slugify(name)
12
+ @config = config
13
+ @path = File.join(config.base_dir, @name)
14
+ end
15
+
16
+ def self.all(config)
17
+ base = config.base_dir
18
+ return [] unless Dir.exist?(base)
19
+
20
+ Dir.children(base)
21
+ .select { |child| File.directory?(File.join(base, child)) }
22
+ .sort
23
+ .map { |folder| new(folder, config) }
24
+ end
25
+
26
+ def exist?
27
+ Dir.exist?(@path)
28
+ end
29
+
30
+ def entries
31
+ load_index
32
+ end
33
+
34
+ def entry_count
35
+ entries.length
36
+ end
37
+
38
+ def last_added
39
+ entries_list = entries
40
+ return nil if entries_list.empty?
41
+
42
+ entries_list.map { |entry| entry["saved_at"] }.compact.max
43
+ end
44
+
45
+ def find_entry(identifier)
46
+ entries_list = entries
47
+
48
+ if identifier.match?(/\A\d+\z/)
49
+ index = identifier.to_i - 1
50
+ entries_list[index] if index >= 0 && index < entries_list.length
51
+ else
52
+ slug = identifier.delete_suffix(".md")
53
+ entries_list.find { |entry| entry["slug"] == slug }
54
+ end
55
+ end
56
+
57
+ def index_path
58
+ File.join(@path, "index.yml")
59
+ end
60
+
61
+ def create!
62
+ FileUtils.mkdir_p(@path)
63
+ write_index([]) unless File.exist?(index_path)
64
+ GlobalIndex.update!(config)
65
+ self
66
+ end
67
+
68
+ def add_entry(entry_data) # rubocop:disable Naming/PredicateMethod
69
+ entries_list = entries
70
+ return false if entries_list.any? { |entry| entry["url"] == entry_data["url"] }
71
+
72
+ entries_list << entry_data
73
+ write_index(entries_list)
74
+ GlobalIndex.update!(config)
75
+ true
76
+ end
77
+
78
+ def remove_entry(identifier)
79
+ entry = find_entry(identifier)
80
+ return nil unless entry
81
+
82
+ file_path = File.join(@path, entry["filename"])
83
+ FileUtils.rm_f(file_path)
84
+
85
+ entries_list = entries.reject { |list_entry| list_entry["filename"] == entry["filename"] }
86
+ write_index(entries_list)
87
+ GlobalIndex.update!(config)
88
+ entry
89
+ end
90
+
91
+ def remove!
92
+ FileUtils.rm_rf(@path)
93
+ GlobalIndex.update!(config)
94
+ SearchIndex.open(config) { |index| index.remove_collection!(name) }
95
+ end
96
+
97
+ private
98
+
99
+ def load_index
100
+ return [] unless File.exist?(index_path)
101
+
102
+ data = YAML.safe_load_file(index_path, permitted_classes: [Time])
103
+ data.is_a?(Array) ? data : []
104
+ rescue Psych::SyntaxError
105
+ warn "Warning: corrupted index.yml in collection '#{@name}', treating as empty"
106
+ []
107
+ end
108
+
109
+ def write_index(entries_list)
110
+ File.write(index_path, YAML.dump(entries_list))
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Mddir
6
+ class Config
7
+ DEFAULT_PATH = File.expand_path("~/.mddir.yml")
8
+
9
+ DEFAULTS = {
10
+ "base_dir" => "~/.mddir",
11
+ "port" => 7768,
12
+ "editor" => ENV.fetch("EDITOR", "vi"),
13
+ "user_agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" # rubocop:disable Layout/LineLength
14
+ }.freeze
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(path: DEFAULT_PATH)
19
+ @path = path
20
+ @data = DEFAULTS.dup
21
+ load_config if File.exist?(path)
22
+ end
23
+
24
+ def base_dir
25
+ File.expand_path(@data["base_dir"])
26
+ end
27
+
28
+ def port
29
+ @data["port"].to_i
30
+ end
31
+
32
+ def editor
33
+ @data["editor"]
34
+ end
35
+
36
+ def user_agent
37
+ @data["user_agent"]
38
+ end
39
+
40
+ def create_default_config!
41
+ return if File.exist?(path)
42
+
43
+ File.write(path, YAML.dump(DEFAULTS))
44
+ end
45
+
46
+ private
47
+
48
+ def load_config
49
+ loaded = YAML.safe_load_file(path)
50
+ @data.merge!(loaded) if loaded.is_a?(Hash)
51
+ rescue Psych::SyntaxError
52
+ # Use defaults if config is corrupted
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+ require "yaml"
6
+
7
+ module Mddir
8
+ class Entry
9
+ attr_reader :url,
10
+ :title,
11
+ :description,
12
+ :slug,
13
+ :filename,
14
+ :markdown,
15
+ :conversion,
16
+ :token_count,
17
+ :token_estimated,
18
+ :saved_at
19
+
20
+ def initialize(url:, title:, description:, markdown:, conversion:, token_count:, token_estimated:) # rubocop:disable Metrics/ParameterLists
21
+ @url = url
22
+ @title = title.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
23
+ @description = description.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
24
+ @markdown = markdown.encode("UTF-8", invalid: :replace, undef: :replace)
25
+ @conversion = conversion
26
+ @token_count = token_count
27
+ @token_estimated = token_estimated
28
+ @saved_at = Time.now.utc.iso8601
29
+ @slug = generate_slug
30
+ @filename = "#{@slug}.md"
31
+ end
32
+
33
+ def to_index_entry
34
+ {
35
+ "url" => @url,
36
+ "title" => @title,
37
+ "description" => @description,
38
+ "filename" => @filename,
39
+ "slug" => @slug,
40
+ "saved_at" => @saved_at,
41
+ "conversion" => @conversion,
42
+ "token_count" => @token_count,
43
+ "token_estimated" => @token_estimated
44
+ }
45
+ end
46
+
47
+ def to_markdown_with_frontmatter
48
+ frontmatter = {
49
+ "url" => @url,
50
+ "title" => @title,
51
+ "description" => @description,
52
+ "slug" => @slug,
53
+ "saved_at" => @saved_at,
54
+ "conversion" => @conversion,
55
+ "token_count" => @token_count,
56
+ "token_estimated" => @token_estimated
57
+ }
58
+
59
+ content = Utils.strip_frontmatter(@markdown)
60
+ yaml_str = YAML.dump(frontmatter).delete_prefix("---\n").chomp
61
+ "---\n#{yaml_str}\n---\n\n#{content}"
62
+ end
63
+
64
+ def save_to(collection_path)
65
+ file_path = File.join(collection_path, @filename)
66
+ File.write(file_path, to_markdown_with_frontmatter)
67
+ end
68
+
69
+ private
70
+
71
+ def generate_slug
72
+ title_slug = Utils.slugify(@title.empty? ? "untitled" : @title)
73
+ hash = Digest::SHA256.hexdigest(@url)[0, 6]
74
+ "#{title_slug}-#{hash}"
75
+ end
76
+ end
77
+ end