things-mcp 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/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/Rakefile +6 -0
- data/bin/test_connection +118 -0
- data/bin/things_mcp_server +8 -0
- data/lib/things_mcp/database.rb +500 -0
- data/lib/things_mcp/formatters.rb +152 -0
- data/lib/things_mcp/handlers.rb +257 -0
- data/lib/things_mcp/server.rb +120 -0
- data/lib/things_mcp/tools.rb +463 -0
- data/lib/things_mcp/url_scheme.rb +156 -0
- data/lib/things_mcp.rb +24 -0
- metadata +97 -0
@@ -0,0 +1,500 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sqlite3"
|
4
|
+
require "pathname"
|
5
|
+
require "date"
|
6
|
+
require "uri"
|
7
|
+
|
8
|
+
module ThingsMcp
|
9
|
+
# Database access layer for Things 3 SQLite database
|
10
|
+
#
|
11
|
+
# This class provides read-only access to the Things 3 database, enabling retrieval of todos, projects, areas, and
|
12
|
+
# tags. It handles dynamic database path resolution and provides formatted data structures.
|
13
|
+
class Database
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def find_database_path
|
17
|
+
group_containers_dir = "#{Dir.home}/Library/Group Containers"
|
18
|
+
|
19
|
+
# Find Things-specific directories to avoid permission issues
|
20
|
+
things_dirs = Dir.glob("#{group_containers_dir}/*").select do |dir|
|
21
|
+
File.basename(dir).include?("culturedcode.ThingsMac")
|
22
|
+
end
|
23
|
+
|
24
|
+
things_dirs.each do |things_dir|
|
25
|
+
# Try new format first (Things 3.15.16+)
|
26
|
+
new_pattern = "#{things_dir}/ThingsData-*/Things Database.thingsdatabase/main.sqlite"
|
27
|
+
matches = Dir.glob(new_pattern)
|
28
|
+
return matches.first unless matches.empty?
|
29
|
+
|
30
|
+
# Fall back to old format
|
31
|
+
old_pattern = "#{things_dir}/Things Database.thingsdatabase/main.sqlite"
|
32
|
+
return old_pattern if File.exist?(old_pattern)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check environment variable
|
36
|
+
ENV["THINGSDB"] if ENV["THINGSDB"] && File.exist?(ENV["THINGSDB"])
|
37
|
+
end
|
38
|
+
|
39
|
+
def database_path
|
40
|
+
@database_path ||= find_database_path
|
41
|
+
end
|
42
|
+
|
43
|
+
def things_app_available?
|
44
|
+
system('pgrep -x "Things3" > /dev/null 2>&1')
|
45
|
+
end
|
46
|
+
|
47
|
+
def with_database(&block)
|
48
|
+
db_path = database_path
|
49
|
+
unless db_path
|
50
|
+
$stderr.puts "DEBUG: Database path search failed"
|
51
|
+
$stderr.puts "DEBUG: Home directory: #{Dir.home}"
|
52
|
+
$stderr.puts "DEBUG: Group Containers exists: #{File.exist?("#{Dir.home}/Library/Group Containers")}"
|
53
|
+
if File.exist?("#{Dir.home}/Library/Group Containers")
|
54
|
+
containers = Dir.glob("#{Dir.home}/Library/Group Containers/*")
|
55
|
+
$stderr.puts "DEBUG: Found containers: #{containers.size}"
|
56
|
+
things_containers = containers.select { |c| File.basename(c).include?("culturedcode") }
|
57
|
+
$stderr.puts "DEBUG: Things containers: #{things_containers}"
|
58
|
+
end
|
59
|
+
raise "Things database not found. Please ensure Things 3 is installed and has been launched at least once."
|
60
|
+
end
|
61
|
+
|
62
|
+
db = SQLite3::Database.new(db_path, readonly: true)
|
63
|
+
db.results_as_hash = true
|
64
|
+
yield db
|
65
|
+
ensure
|
66
|
+
db&.close
|
67
|
+
end
|
68
|
+
|
69
|
+
# Get all todos
|
70
|
+
def get_todos(project_uuid: nil, include_items: true)
|
71
|
+
with_database do |db|
|
72
|
+
query = build_todo_query(project_uuid: project_uuid)
|
73
|
+
results = db.execute(query)
|
74
|
+
|
75
|
+
todos = results.map { |row| format_todo(row) }
|
76
|
+
|
77
|
+
if include_items
|
78
|
+
todos.each do |todo|
|
79
|
+
todo[:checklist_items] = get_checklist_items(db, todo[:uuid])
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Always fetch tags for todos
|
84
|
+
todos.each do |todo|
|
85
|
+
todo[:tags] = get_tags_for_task(db, todo[:uuid])
|
86
|
+
end
|
87
|
+
|
88
|
+
todos
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get todos from specific lists
|
93
|
+
def get_inbox
|
94
|
+
get_todos_by_start(0)
|
95
|
+
end
|
96
|
+
|
97
|
+
def get_today
|
98
|
+
get_todos_by_start(1)
|
99
|
+
end
|
100
|
+
|
101
|
+
def get_upcoming
|
102
|
+
with_database do |db|
|
103
|
+
query = <<~SQL
|
104
|
+
SELECT #{todo_columns}
|
105
|
+
FROM TMTask
|
106
|
+
WHERE type = 0
|
107
|
+
AND status = 0
|
108
|
+
AND trashed = 0
|
109
|
+
AND start = 2
|
110
|
+
AND (startDate IS NOT NULL OR deadline IS NOT NULL)
|
111
|
+
ORDER BY COALESCE(startDate, deadline)
|
112
|
+
SQL
|
113
|
+
|
114
|
+
db.execute(query).map { |row| format_todo(row) }
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def get_anytime
|
119
|
+
get_todos_by_start(2).reject { |t| t[:start_date] || t[:deadline] }
|
120
|
+
end
|
121
|
+
|
122
|
+
def get_someday
|
123
|
+
get_todos_by_start(3)
|
124
|
+
end
|
125
|
+
|
126
|
+
def get_logbook(period: "7d", limit: 50)
|
127
|
+
days = parse_period(period)
|
128
|
+
cutoff = Date.today - days
|
129
|
+
|
130
|
+
with_database do |db|
|
131
|
+
query = <<~SQL
|
132
|
+
SELECT #{todo_columns}
|
133
|
+
FROM TMTask
|
134
|
+
WHERE type = 0
|
135
|
+
AND status IN (3, 2)
|
136
|
+
AND trashed = 0
|
137
|
+
AND stopDate >= julianday('#{cutoff}')
|
138
|
+
ORDER BY stopDate DESC
|
139
|
+
LIMIT #{limit}
|
140
|
+
SQL
|
141
|
+
|
142
|
+
db.execute(query).map { |row| format_todo(row) }
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def get_trash
|
147
|
+
with_database do |db|
|
148
|
+
query = <<~SQL
|
149
|
+
SELECT #{todo_columns}
|
150
|
+
FROM TMTask
|
151
|
+
WHERE type = 0
|
152
|
+
AND trashed = 1
|
153
|
+
ORDER BY userModificationDate DESC
|
154
|
+
SQL
|
155
|
+
|
156
|
+
db.execute(query).map { |row| format_todo(row) }
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Get projects
|
161
|
+
def get_projects(include_items: false)
|
162
|
+
with_database do |db|
|
163
|
+
query = <<~SQL
|
164
|
+
SELECT uuid, title, notes, status, area, start, startDate, deadline,
|
165
|
+
creationDate, userModificationDate
|
166
|
+
FROM TMTask
|
167
|
+
WHERE type = 1
|
168
|
+
AND trashed = 0
|
169
|
+
ORDER BY userModificationDate DESC
|
170
|
+
SQL
|
171
|
+
|
172
|
+
projects = db.execute(query).map { |row| format_project(row) }
|
173
|
+
|
174
|
+
if include_items
|
175
|
+
projects.each do |project|
|
176
|
+
project[:todos] = get_todos(project_uuid: project[:uuid])
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
projects
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Get areas
|
185
|
+
def get_areas(include_items: false)
|
186
|
+
with_database do |db|
|
187
|
+
query = <<~SQL
|
188
|
+
SELECT uuid, title, tags
|
189
|
+
FROM TMArea
|
190
|
+
ORDER BY "index"
|
191
|
+
SQL
|
192
|
+
|
193
|
+
areas = db.execute(query).map { |row| format_area(row) }
|
194
|
+
|
195
|
+
if include_items
|
196
|
+
areas.each do |area|
|
197
|
+
# Get projects in this area
|
198
|
+
project_query = <<~SQL
|
199
|
+
SELECT uuid, title
|
200
|
+
FROM TMTask
|
201
|
+
WHERE type = 1
|
202
|
+
AND trashed = 0
|
203
|
+
AND area = '#{area[:uuid]}'
|
204
|
+
SQL
|
205
|
+
|
206
|
+
area[:projects] = db.execute(project_query).map do |proj|
|
207
|
+
{ uuid: proj["uuid"], title: proj["title"] }
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
areas
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# Get tags
|
217
|
+
def get_tags(include_items: false)
|
218
|
+
with_database do |db|
|
219
|
+
query = "SELECT uuid, title FROM TMTag ORDER BY title"
|
220
|
+
|
221
|
+
tags = db.execute(query).map { |row| format_tag(row) }
|
222
|
+
|
223
|
+
if include_items
|
224
|
+
tags.each do |tag|
|
225
|
+
tag[:items] = get_tagged_items(tag[:title])
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
tags
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def get_tagged_items(tag_title)
|
234
|
+
with_database do |db|
|
235
|
+
# Find tag UUID
|
236
|
+
tag_result = db.execute("SELECT uuid FROM TMTag WHERE title = ?", [tag_title]).first
|
237
|
+
return [] unless tag_result
|
238
|
+
|
239
|
+
tag_uuid = tag_result["uuid"]
|
240
|
+
|
241
|
+
query = <<~SQL
|
242
|
+
SELECT #{todo_columns}
|
243
|
+
FROM TMTask
|
244
|
+
WHERE type = 0
|
245
|
+
AND status = 0
|
246
|
+
AND trashed = 0
|
247
|
+
AND tags LIKE '%#{tag_uuid}%'
|
248
|
+
ORDER BY userModificationDate DESC
|
249
|
+
SQL
|
250
|
+
|
251
|
+
db.execute(query).map { |row| format_todo(row) }
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# Search todos
|
256
|
+
def search_todos(query)
|
257
|
+
with_database do |db|
|
258
|
+
search_query = <<~SQL
|
259
|
+
SELECT #{todo_columns}
|
260
|
+
FROM TMTask
|
261
|
+
WHERE type = 0
|
262
|
+
AND trashed = 0
|
263
|
+
AND (title LIKE '%#{query}%' OR notes LIKE '%#{query}%')
|
264
|
+
ORDER BY userModificationDate DESC
|
265
|
+
SQL
|
266
|
+
|
267
|
+
db.execute(search_query).map { |row| format_todo(row) }
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
def search_advanced(filters = {})
|
272
|
+
with_database do |db|
|
273
|
+
conditions = ["type = 0", "trashed = 0"]
|
274
|
+
|
275
|
+
# Status filter
|
276
|
+
if filters[:status]
|
277
|
+
status_map = { "incomplete" => 0, "completed" => 3, "canceled" => 2 }
|
278
|
+
conditions << "status = #{status_map[filters[:status]]}"
|
279
|
+
end
|
280
|
+
|
281
|
+
# Date filters
|
282
|
+
if filters[:start_date]
|
283
|
+
conditions << "startDate >= julianday('#{filters[:start_date]}')"
|
284
|
+
end
|
285
|
+
|
286
|
+
if filters[:deadline]
|
287
|
+
conditions << "deadline <= julianday('#{filters[:deadline]}')"
|
288
|
+
end
|
289
|
+
|
290
|
+
# Tag filter
|
291
|
+
if filters[:tag]
|
292
|
+
tag_result = db.execute("SELECT uuid FROM TMTag WHERE title = ?", [filters[:tag]]).first
|
293
|
+
conditions << "tags LIKE '%#{tag_result["uuid"]}%'" if tag_result
|
294
|
+
end
|
295
|
+
|
296
|
+
# Area filter
|
297
|
+
if filters[:area]
|
298
|
+
conditions << "area = '#{filters[:area]}'"
|
299
|
+
end
|
300
|
+
|
301
|
+
# Type filter
|
302
|
+
if filters[:type]
|
303
|
+
type_map = { "to-do" => 0, "project" => 1, "heading" => 2 }
|
304
|
+
conditions << "type = #{type_map[filters[:type]]}"
|
305
|
+
end
|
306
|
+
|
307
|
+
query = <<~SQL
|
308
|
+
SELECT #{todo_columns}
|
309
|
+
FROM TMTask
|
310
|
+
WHERE #{conditions.join(" AND ")}
|
311
|
+
ORDER BY userModificationDate DESC
|
312
|
+
SQL
|
313
|
+
|
314
|
+
db.execute(query).map { |row| format_todo(row) }
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def get_recent(period)
|
319
|
+
days = parse_period(period)
|
320
|
+
cutoff = Date.today - days
|
321
|
+
|
322
|
+
with_database do |db|
|
323
|
+
query = <<~SQL
|
324
|
+
SELECT #{todo_columns}
|
325
|
+
FROM TMTask
|
326
|
+
WHERE type = 0
|
327
|
+
AND trashed = 0
|
328
|
+
AND creationDate >= julianday('#{cutoff}')
|
329
|
+
ORDER BY creationDate DESC
|
330
|
+
SQL
|
331
|
+
|
332
|
+
db.execute(query).map { |row| format_todo(row) }
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
private
|
337
|
+
|
338
|
+
def todo_columns
|
339
|
+
"uuid, title, notes, status, project, area, start, startDate, " \
|
340
|
+
"deadline, creationDate, userModificationDate"
|
341
|
+
end
|
342
|
+
|
343
|
+
def build_todo_query(project_uuid: nil)
|
344
|
+
base_query = <<~SQL
|
345
|
+
SELECT #{todo_columns}
|
346
|
+
FROM TMTask
|
347
|
+
WHERE type = 0
|
348
|
+
AND trashed = 0
|
349
|
+
SQL
|
350
|
+
|
351
|
+
base_query += " AND project = '#{project_uuid}'" if project_uuid
|
352
|
+
base_query + ' ORDER BY "index"'
|
353
|
+
end
|
354
|
+
|
355
|
+
def get_todos_by_start(start_value)
|
356
|
+
with_database do |db|
|
357
|
+
query = <<~SQL
|
358
|
+
SELECT #{todo_columns}
|
359
|
+
FROM TMTask
|
360
|
+
WHERE type = 0
|
361
|
+
AND status = 0
|
362
|
+
AND trashed = 0
|
363
|
+
AND start = #{start_value}
|
364
|
+
ORDER BY "index"
|
365
|
+
SQL
|
366
|
+
|
367
|
+
db.execute(query).map { |row| format_todo(row) }
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
def get_checklist_items(db, task_uuid)
|
372
|
+
query = <<~SQL
|
373
|
+
SELECT uuid, title, status
|
374
|
+
FROM TMChecklistItem
|
375
|
+
WHERE task = '#{task_uuid}'
|
376
|
+
ORDER BY "index"
|
377
|
+
SQL
|
378
|
+
|
379
|
+
db.execute(query).map do |item|
|
380
|
+
{
|
381
|
+
uuid: item["uuid"],
|
382
|
+
title: item["title"],
|
383
|
+
completed: item["status"] == 3,
|
384
|
+
}
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
def get_tags_for_task(db, task_uuid)
|
389
|
+
query = <<~SQL
|
390
|
+
SELECT TAG.title
|
391
|
+
FROM TMTaskTag AS TASK_TAG
|
392
|
+
LEFT OUTER JOIN TMTag TAG ON TAG.uuid = TASK_TAG.tags
|
393
|
+
WHERE TASK_TAG.tasks = ?
|
394
|
+
ORDER BY TAG."index"
|
395
|
+
SQL
|
396
|
+
|
397
|
+
db.execute(query, [task_uuid]).map { |row| row["title"] }.compact
|
398
|
+
end
|
399
|
+
|
400
|
+
def format_todo(row)
|
401
|
+
{
|
402
|
+
uuid: row["uuid"],
|
403
|
+
title: decode_title(row["title"]),
|
404
|
+
notes: decode_title(row["notes"]),
|
405
|
+
status: format_status(row["status"]),
|
406
|
+
project: row["project"],
|
407
|
+
area: row["area"],
|
408
|
+
tags: [], # Tags will be populated separately
|
409
|
+
when: format_when(row["start"]),
|
410
|
+
start_date: julian_to_date(row["startDate"]),
|
411
|
+
deadline: julian_to_date(row["deadline"]),
|
412
|
+
created: julian_to_date(row["creationDate"]),
|
413
|
+
modified: julian_to_date(row["userModificationDate"]),
|
414
|
+
}
|
415
|
+
end
|
416
|
+
|
417
|
+
def format_project(row)
|
418
|
+
{
|
419
|
+
uuid: row["uuid"],
|
420
|
+
title: decode_title(row["title"]),
|
421
|
+
notes: decode_title(row["notes"]),
|
422
|
+
status: format_status(row["status"]),
|
423
|
+
area: row["area"],
|
424
|
+
tags: [], # Tags will be populated separately for projects too
|
425
|
+
when: format_when(row["start"]),
|
426
|
+
start_date: julian_to_date(row["startDate"]),
|
427
|
+
deadline: julian_to_date(row["deadline"]),
|
428
|
+
created: julian_to_date(row["creationDate"]),
|
429
|
+
modified: julian_to_date(row["userModificationDate"]),
|
430
|
+
}
|
431
|
+
end
|
432
|
+
|
433
|
+
def format_area(row)
|
434
|
+
{
|
435
|
+
uuid: row["uuid"],
|
436
|
+
title: row["title"],
|
437
|
+
tags: [], # Areas can have tags but we'll implement that separately if needed
|
438
|
+
}
|
439
|
+
end
|
440
|
+
|
441
|
+
def format_tag(row)
|
442
|
+
{
|
443
|
+
uuid: row["uuid"],
|
444
|
+
title: row["title"],
|
445
|
+
}
|
446
|
+
end
|
447
|
+
|
448
|
+
def format_status(status)
|
449
|
+
case status
|
450
|
+
when 0 then "incomplete"
|
451
|
+
when 2 then "canceled"
|
452
|
+
when 3 then "completed"
|
453
|
+
else "unknown"
|
454
|
+
end
|
455
|
+
end
|
456
|
+
|
457
|
+
def format_when(start)
|
458
|
+
case start
|
459
|
+
when 0 then "inbox"
|
460
|
+
when 1 then "today"
|
461
|
+
when 2 then "anytime"
|
462
|
+
when 3 then "someday"
|
463
|
+
else "unknown"
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
def julian_to_date(julian_days)
|
468
|
+
return unless julian_days
|
469
|
+
|
470
|
+
# SQLite Julian day starts from noon on November 24, 4714 BC
|
471
|
+
Date.jd(julian_days.to_i + 2400001).to_s
|
472
|
+
end
|
473
|
+
|
474
|
+
def parse_period(period)
|
475
|
+
match = period.match(/^(\d+)([dwmy])$/)
|
476
|
+
raise ArgumentError, "Invalid period format: #{period}" unless match
|
477
|
+
|
478
|
+
number = match[1].to_i
|
479
|
+
unit = match[2]
|
480
|
+
|
481
|
+
case unit
|
482
|
+
when "d" then number
|
483
|
+
when "w" then number * 7
|
484
|
+
when "m" then number * 30
|
485
|
+
when "y" then number * 365
|
486
|
+
end
|
487
|
+
end
|
488
|
+
|
489
|
+
def decode_title(title)
|
490
|
+
return unless title
|
491
|
+
|
492
|
+
# Decode URL-encoded titles that may come from URL scheme operations
|
493
|
+
URI.decode_www_form_component(title.to_s)
|
494
|
+
rescue
|
495
|
+
# If decoding fails, return original title
|
496
|
+
title
|
497
|
+
end
|
498
|
+
end
|
499
|
+
end
|
500
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
module ThingsMcp
|
6
|
+
# Output formatters for Things 3 data
|
7
|
+
#
|
8
|
+
# This module provides formatting methods to convert raw database records into human-readable text for MCP responses.
|
9
|
+
module Formatters
|
10
|
+
extend self
|
11
|
+
|
12
|
+
def format_todo(todo)
|
13
|
+
lines = []
|
14
|
+
|
15
|
+
# Status indicator
|
16
|
+
status_icon = case todo[:status]
|
17
|
+
when "completed" then "✅"
|
18
|
+
when "canceled" then "❌"
|
19
|
+
else "⭕"
|
20
|
+
end
|
21
|
+
|
22
|
+
lines << "#{status_icon} **#{todo[:title]}**"
|
23
|
+
lines << " UUID: #{todo[:uuid]}" if todo[:uuid]
|
24
|
+
|
25
|
+
# Notes
|
26
|
+
if todo[:notes] && !todo[:notes].empty?
|
27
|
+
lines << " Notes: #{todo[:notes].gsub("\n", " ")}"
|
28
|
+
end
|
29
|
+
|
30
|
+
# When/scheduling
|
31
|
+
if todo[:when] && todo[:when] != "unknown"
|
32
|
+
lines << " When: #{todo[:when]}"
|
33
|
+
end
|
34
|
+
|
35
|
+
if todo[:start_date]
|
36
|
+
lines << " Start: #{todo[:start_date]}"
|
37
|
+
end
|
38
|
+
|
39
|
+
if todo[:deadline]
|
40
|
+
lines << " Deadline: #{todo[:deadline]}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Tags
|
44
|
+
if todo[:tags] && !todo[:tags].empty?
|
45
|
+
lines << " Tags: #{todo[:tags].join(", ")}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Checklist items
|
49
|
+
if todo[:checklist_items] && !todo[:checklist_items].empty?
|
50
|
+
lines << " Checklist:"
|
51
|
+
todo[:checklist_items].each do |item|
|
52
|
+
check = item[:completed] ? "✓" : "○"
|
53
|
+
lines << " #{check} #{item[:title]}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Metadata
|
58
|
+
if todo[:created]
|
59
|
+
lines << " Created: #{todo[:created]}"
|
60
|
+
end
|
61
|
+
|
62
|
+
if todo[:modified]
|
63
|
+
lines << " Modified: #{todo[:modified]}"
|
64
|
+
end
|
65
|
+
|
66
|
+
lines.join("\n")
|
67
|
+
end
|
68
|
+
|
69
|
+
def format_project(project)
|
70
|
+
lines = []
|
71
|
+
|
72
|
+
# Status indicator
|
73
|
+
status_icon = case project[:status]
|
74
|
+
when "completed" then "✅"
|
75
|
+
when "canceled" then "❌"
|
76
|
+
else "📁"
|
77
|
+
end
|
78
|
+
|
79
|
+
lines << "#{status_icon} **#{project[:title]}** (Project)"
|
80
|
+
lines << " UUID: #{project[:uuid]}" if project[:uuid]
|
81
|
+
|
82
|
+
# Notes
|
83
|
+
if project[:notes] && !project[:notes].empty?
|
84
|
+
lines << " Notes: #{project[:notes].gsub("\n", " ")}"
|
85
|
+
end
|
86
|
+
|
87
|
+
# When/scheduling
|
88
|
+
if project[:when] && project[:when] != "unknown"
|
89
|
+
lines << " When: #{project[:when]}"
|
90
|
+
end
|
91
|
+
|
92
|
+
if project[:start_date]
|
93
|
+
lines << " Start: #{project[:start_date]}"
|
94
|
+
end
|
95
|
+
|
96
|
+
if project[:deadline]
|
97
|
+
lines << " Deadline: #{project[:deadline]}"
|
98
|
+
end
|
99
|
+
|
100
|
+
# Tags
|
101
|
+
if project[:tags] && !project[:tags].empty?
|
102
|
+
lines << " Tags: #{project[:tags].join(", ")}"
|
103
|
+
end
|
104
|
+
|
105
|
+
# Todos within project
|
106
|
+
if project[:todos] && !project[:todos].empty?
|
107
|
+
lines << " Todos:"
|
108
|
+
project[:todos].each do |todo|
|
109
|
+
status = todo[:status] == "completed" ? "✓" : "○"
|
110
|
+
lines << " #{status} #{todo[:title]}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
lines.join("\n")
|
115
|
+
end
|
116
|
+
|
117
|
+
def format_area(area)
|
118
|
+
lines = []
|
119
|
+
|
120
|
+
lines << "🏷️ **#{area[:title]}** (Area)"
|
121
|
+
lines << " UUID: #{area[:uuid]}" if area[:uuid]
|
122
|
+
|
123
|
+
# Tags
|
124
|
+
if area[:tags] && !area[:tags].empty?
|
125
|
+
lines << " Tags: #{area[:tags].join(", ")}"
|
126
|
+
end
|
127
|
+
|
128
|
+
# Projects within area
|
129
|
+
if area[:projects] && !area[:projects].empty?
|
130
|
+
lines << " Projects:"
|
131
|
+
area[:projects].each do |project|
|
132
|
+
lines << " 📁 #{project[:title]}"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
lines.join("\n")
|
137
|
+
end
|
138
|
+
|
139
|
+
def format_tag(tag)
|
140
|
+
lines = []
|
141
|
+
|
142
|
+
lines << "🏷️ #{tag[:title]}"
|
143
|
+
|
144
|
+
if tag[:items] && !tag[:items].empty?
|
145
|
+
lines << " Items: #{tag[:items].size}"
|
146
|
+
end
|
147
|
+
|
148
|
+
lines.join("\n")
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
152
|
+
end
|