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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/exe/mddir +6 -0
- data/lib/mddir/cli.rb +230 -0
- data/lib/mddir/collection.rb +113 -0
- data/lib/mddir/config.rb +55 -0
- data/lib/mddir/entry.rb +77 -0
- data/lib/mddir/fetcher.rb +213 -0
- data/lib/mddir/global_index.rb +51 -0
- data/lib/mddir/search.rb +73 -0
- data/lib/mddir/search_index.rb +107 -0
- data/lib/mddir/server.rb +152 -0
- data/lib/mddir/utils.rb +46 -0
- data/lib/mddir/version.rb +5 -0
- data/lib/mddir.rb +16 -0
- data/public/style.css +435 -0
- data/views/collection.erb +43 -0
- data/views/home.erb +21 -0
- data/views/layout.erb +44 -0
- data/views/reader.erb +24 -0
- data/views/search.erb +30 -0
- metadata +282 -0
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
|
+
[](https://rubygems.org/gems/mddir)
|
|
10
|
+
[](https://www.ruby-lang.org)
|
|
11
|
+
[](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
|
+

|
|
16
|
+

|
|
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
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
|
data/lib/mddir/config.rb
ADDED
|
@@ -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
|
data/lib/mddir/entry.rb
ADDED
|
@@ -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
|