tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
build: tests for publication handler
desertthunder.dev
3 months ago
02361b92
502828dd
+357
-16
4 changed files
expand all
collapse all
unified
split
internal
handlers
articles.go
publication.go
publication_test.go
ui
common.go
+1
internal/handlers/articles.go
···
0
1
package handlers
2
3
import (
···
1
+
// TODO: more article sanitizing
2
package handlers
3
4
import (
+6
-16
internal/handlers/publication.go
···
1
// Package handlers provides command handlers for leaflet publication operations.
2
//
3
-
// Pull command:
4
-
// 1. Authenticates with AT Protocol
5
-
// 2. Fetches all pub.leaflet.document records
6
-
// 3. Creates new notes for documents not seen before
7
-
// 4. Updates existing notes (matched by leaflet_rkey)
8
-
// 5. Shows summary of pulled documents
9
-
//
10
-
// List command:
11
-
// 1. Query notes where leaflet_rkey IS NOT NULL
12
-
// 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all)
13
-
// 3. Static output (TUI viewing marked as TODO)
14
-
//
15
// TODO: Add TUI viewing for document details
16
package handlers
17
···
160
if err == nil && existing != nil {
161
content, err := documentToMarkdown(doc)
162
if err != nil {
163
-
ui.Warningln("โ Skipping document %s: %v", doc.Document.Title, err)
164
continue
165
}
166
···
177
}
178
179
if err := h.repos.Notes.Update(ctx, existing); err != nil {
180
-
ui.Warningln("โ Failed to update note for document %s: %v", doc.Document.Title, err)
181
continue
182
}
183
···
186
} else {
187
content, err := documentToMarkdown(doc)
188
if err != nil {
189
-
ui.Warningln("โ Skipping document %s: %v", doc.Document.Title, err)
190
continue
191
}
192
···
207
208
_, err = h.repos.Notes.Create(ctx, note)
209
if err != nil {
210
-
ui.Warningln("โ Failed to create note for document %s: %v", doc.Document.Title, err)
211
continue
212
}
213
···
1
// Package handlers provides command handlers for leaflet publication operations.
2
//
3
+
// TODO: Post (create 1)
4
+
// TODO: Push (create or update - more than 1)
0
0
0
0
0
0
0
0
0
0
5
// TODO: Add TUI viewing for document details
6
package handlers
7
···
150
if err == nil && existing != nil {
151
content, err := documentToMarkdown(doc)
152
if err != nil {
153
+
ui.Warningln("Skipping document %s: %v", doc.Document.Title, err)
154
continue
155
}
156
···
167
}
168
169
if err := h.repos.Notes.Update(ctx, existing); err != nil {
170
+
ui.Warningln("Failed to update note for document %s: %v", doc.Document.Title, err)
171
continue
172
}
173
···
176
} else {
177
content, err := documentToMarkdown(doc)
178
if err != nil {
179
+
ui.Warningln("Skipping document %s: %v", doc.Document.Title, err)
180
continue
181
}
182
···
197
198
_, err = h.repos.Notes.Create(ctx, note)
199
if err != nil {
200
+
ui.Warningln("Failed to create note for document %s: %v", doc.Document.Title, err)
201
continue
202
}
203
+339
internal/handlers/publication_test.go
···
2
3
import (
4
"context"
0
5
"testing"
6
"time"
7
0
0
8
"github.com/stormlightlabs/noteleaf/internal/services"
9
"github.com/stormlightlabs/noteleaf/internal/store"
10
)
···
280
if err != nil {
281
t.Errorf("Expected no error on close, got %v", err)
282
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
283
})
284
})
285
}
···
2
3
import (
4
"context"
5
+
"strings"
6
"testing"
7
"time"
8
9
+
"github.com/stormlightlabs/noteleaf/internal/models"
10
+
"github.com/stormlightlabs/noteleaf/internal/public"
11
"github.com/stormlightlabs/noteleaf/internal/services"
12
"github.com/stormlightlabs/noteleaf/internal/store"
13
)
···
283
if err != nil {
284
t.Errorf("Expected no error on close, got %v", err)
285
}
286
+
})
287
+
})
288
+
289
+
t.Run("documentToMarkdown", func(t *testing.T) {
290
+
t.Run("converts simple document with text blocks", func(t *testing.T) {
291
+
doc := services.DocumentWithMeta{
292
+
Document: public.Document{
293
+
Pages: []public.LinearDocument{
294
+
{
295
+
Blocks: []public.BlockWrap{
296
+
{
297
+
Type: "pub.leaflet.pages.linearDocument#block",
298
+
Block: public.TextBlock{
299
+
Type: "pub.leaflet.pages.linearDocument#textBlock",
300
+
Plaintext: "Hello world",
301
+
},
302
+
},
303
+
},
304
+
},
305
+
},
306
+
},
307
+
}
308
+
309
+
markdown, err := documentToMarkdown(doc)
310
+
if err != nil {
311
+
t.Fatalf("Expected no error, got %v", err)
312
+
}
313
+
314
+
if markdown != "Hello world" {
315
+
t.Errorf("Expected 'Hello world', got '%s'", markdown)
316
+
}
317
+
})
318
+
319
+
t.Run("converts document with headers", func(t *testing.T) {
320
+
doc := services.DocumentWithMeta{
321
+
Document: public.Document{
322
+
Pages: []public.LinearDocument{
323
+
{
324
+
Blocks: []public.BlockWrap{
325
+
{
326
+
Type: "pub.leaflet.pages.linearDocument#block",
327
+
Block: public.HeaderBlock{
328
+
Type: "pub.leaflet.pages.linearDocument#headerBlock",
329
+
Level: 1,
330
+
Plaintext: "Main Title",
331
+
},
332
+
},
333
+
{
334
+
Type: "pub.leaflet.pages.linearDocument#block",
335
+
Block: public.TextBlock{
336
+
Type: "pub.leaflet.pages.linearDocument#textBlock",
337
+
Plaintext: "Content here",
338
+
},
339
+
},
340
+
},
341
+
},
342
+
},
343
+
},
344
+
}
345
+
346
+
markdown, err := documentToMarkdown(doc)
347
+
if err != nil {
348
+
t.Fatalf("Expected no error, got %v", err)
349
+
}
350
+
351
+
expected := "# Main Title\n\nContent here"
352
+
if markdown != expected {
353
+
t.Errorf("Expected '%s', got '%s'", expected, markdown)
354
+
}
355
+
})
356
+
357
+
t.Run("converts document with code blocks", func(t *testing.T) {
358
+
doc := services.DocumentWithMeta{
359
+
Document: public.Document{
360
+
Pages: []public.LinearDocument{
361
+
{
362
+
Blocks: []public.BlockWrap{
363
+
{
364
+
Type: "pub.leaflet.pages.linearDocument#block",
365
+
Block: public.CodeBlock{
366
+
Type: "pub.leaflet.pages.linearDocument#codeBlock",
367
+
Plaintext: "fmt.Println(\"hello\")",
368
+
Language: "go",
369
+
},
370
+
},
371
+
},
372
+
},
373
+
},
374
+
},
375
+
}
376
+
377
+
markdown, err := documentToMarkdown(doc)
378
+
if err != nil {
379
+
t.Fatalf("Expected no error, got %v", err)
380
+
}
381
+
382
+
expected := "```go\nfmt.Println(\"hello\")\n```"
383
+
if markdown != expected {
384
+
t.Errorf("Expected '%s', got '%s'", expected, markdown)
385
+
}
386
+
})
387
+
388
+
t.Run("converts document with multiple pages", func(t *testing.T) {
389
+
doc := services.DocumentWithMeta{
390
+
Document: public.Document{
391
+
Pages: []public.LinearDocument{
392
+
{
393
+
Blocks: []public.BlockWrap{
394
+
{
395
+
Type: "pub.leaflet.pages.linearDocument#block",
396
+
Block: public.TextBlock{
397
+
Type: "pub.leaflet.pages.linearDocument#textBlock",
398
+
Plaintext: "Page one",
399
+
},
400
+
},
401
+
},
402
+
},
403
+
{
404
+
Blocks: []public.BlockWrap{
405
+
{
406
+
Type: "pub.leaflet.pages.linearDocument#block",
407
+
Block: public.TextBlock{
408
+
Type: "pub.leaflet.pages.linearDocument#textBlock",
409
+
Plaintext: "Page two",
410
+
},
411
+
},
412
+
},
413
+
},
414
+
},
415
+
},
416
+
}
417
+
418
+
markdown, err := documentToMarkdown(doc)
419
+
if err != nil {
420
+
t.Fatalf("Expected no error, got %v", err)
421
+
}
422
+
423
+
expected := "Page one\n\nPage two"
424
+
if markdown != expected {
425
+
t.Errorf("Expected '%s', got '%s'", expected, markdown)
426
+
}
427
+
})
428
+
429
+
t.Run("handles empty document", func(t *testing.T) {
430
+
doc := services.DocumentWithMeta{
431
+
Document: public.Document{
432
+
Pages: []public.LinearDocument{},
433
+
},
434
+
}
435
+
436
+
markdown, err := documentToMarkdown(doc)
437
+
if err != nil {
438
+
t.Fatalf("Expected no error, got %v", err)
439
+
}
440
+
441
+
if markdown != "" {
442
+
t.Errorf("Expected empty string, got '%s'", markdown)
443
+
}
444
+
})
445
+
})
446
+
447
+
t.Run("Pull", func(t *testing.T) {
448
+
t.Run("returns error when not authenticated", func(t *testing.T) {
449
+
suite := NewHandlerTestSuite(t)
450
+
defer suite.Cleanup()
451
+
452
+
handler := CreateHandler(t, NewPublicationHandler)
453
+
ctx := context.Background()
454
+
455
+
err := handler.Pull(ctx)
456
+
if err == nil {
457
+
t.Error("Expected error when not authenticated")
458
+
}
459
+
460
+
expectedMsg := "not authenticated"
461
+
if err != nil && !strings.Contains(err.Error(), expectedMsg) {
462
+
t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error())
463
+
}
464
+
})
465
+
})
466
+
467
+
t.Run("List", func(t *testing.T) {
468
+
t.Run("lists all leaflet notes", func(t *testing.T) {
469
+
suite := NewHandlerTestSuite(t)
470
+
defer suite.Cleanup()
471
+
472
+
handler := CreateHandler(t, NewPublicationHandler)
473
+
ctx := context.Background()
474
+
475
+
rkey1 := "test_rkey_1"
476
+
cid1 := "test_cid_1"
477
+
publishedAt := time.Now()
478
+
479
+
note1 := &models.Note{
480
+
Title: "Published Note",
481
+
Content: "Content 1",
482
+
LeafletRKey: &rkey1,
483
+
LeafletCID: &cid1,
484
+
PublishedAt: &publishedAt,
485
+
IsDraft: false,
486
+
}
487
+
488
+
_, err := handler.repos.Notes.Create(ctx, note1)
489
+
suite.AssertNoError(err, "create published note")
490
+
491
+
rkey2 := "test_rkey_2"
492
+
cid2 := "test_cid_2"
493
+
note2 := &models.Note{
494
+
Title: "Draft Note",
495
+
Content: "Content 2",
496
+
LeafletRKey: &rkey2,
497
+
LeafletCID: &cid2,
498
+
IsDraft: true,
499
+
}
500
+
501
+
_, err = handler.repos.Notes.Create(ctx, note2)
502
+
suite.AssertNoError(err, "create draft note")
503
+
504
+
err = handler.List(ctx, "all")
505
+
suite.AssertNoError(err, "list all notes")
506
+
507
+
err = handler.List(ctx, "")
508
+
suite.AssertNoError(err, "list with empty filter")
509
+
})
510
+
511
+
t.Run("lists only published notes", func(t *testing.T) {
512
+
suite := NewHandlerTestSuite(t)
513
+
defer suite.Cleanup()
514
+
515
+
handler := CreateHandler(t, NewPublicationHandler)
516
+
ctx := context.Background()
517
+
518
+
rkey := "published_rkey"
519
+
cid := "published_cid"
520
+
publishedAt := time.Now()
521
+
522
+
note := &models.Note{
523
+
Title: "Published Note",
524
+
Content: "Content",
525
+
LeafletRKey: &rkey,
526
+
LeafletCID: &cid,
527
+
PublishedAt: &publishedAt,
528
+
IsDraft: false,
529
+
}
530
+
531
+
_, err := handler.repos.Notes.Create(ctx, note)
532
+
suite.AssertNoError(err, "create published note")
533
+
534
+
err = handler.List(ctx, "published")
535
+
suite.AssertNoError(err, "list published notes")
536
+
})
537
+
538
+
t.Run("lists only draft notes", func(t *testing.T) {
539
+
suite := NewHandlerTestSuite(t)
540
+
defer suite.Cleanup()
541
+
542
+
handler := CreateHandler(t, NewPublicationHandler)
543
+
ctx := context.Background()
544
+
545
+
rkey := "draft_rkey"
546
+
cid := "draft_cid"
547
+
548
+
note := &models.Note{
549
+
Title: "Draft Note",
550
+
Content: "Content",
551
+
LeafletRKey: &rkey,
552
+
LeafletCID: &cid,
553
+
IsDraft: true,
554
+
}
555
+
556
+
_, err := handler.repos.Notes.Create(ctx, note)
557
+
suite.AssertNoError(err, "create draft note")
558
+
559
+
err = handler.List(ctx, "draft")
560
+
suite.AssertNoError(err, "list draft notes")
561
+
})
562
+
563
+
t.Run("handles empty results gracefully", func(t *testing.T) {
564
+
suite := NewHandlerTestSuite(t)
565
+
defer suite.Cleanup()
566
+
567
+
handler := CreateHandler(t, NewPublicationHandler)
568
+
ctx := context.Background()
569
+
570
+
err := handler.List(ctx, "all")
571
+
suite.AssertNoError(err, "list with no notes")
572
+
})
573
+
574
+
t.Run("returns error for invalid filter", func(t *testing.T) {
575
+
suite := NewHandlerTestSuite(t)
576
+
defer suite.Cleanup()
577
+
578
+
handler := CreateHandler(t, NewPublicationHandler)
579
+
ctx := context.Background()
580
+
581
+
err := handler.List(ctx, "invalid_filter")
582
+
if err == nil {
583
+
t.Error("Expected error for invalid filter")
584
+
}
585
+
586
+
expectedMsg := "invalid filter"
587
+
if err != nil && !strings.Contains(err.Error(), expectedMsg) {
588
+
t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error())
589
+
}
590
+
})
591
+
592
+
t.Run("only lists notes with leaflet metadata", func(t *testing.T) {
593
+
suite := NewHandlerTestSuite(t)
594
+
defer suite.Cleanup()
595
+
596
+
handler := CreateHandler(t, NewPublicationHandler)
597
+
ctx := context.Background()
598
+
599
+
regularNote := &models.Note{
600
+
Title: "Regular Note",
601
+
Content: "No leaflet data",
602
+
}
603
+
604
+
_, err := handler.repos.Notes.Create(ctx, regularNote)
605
+
suite.AssertNoError(err, "create regular note")
606
+
607
+
rkey := "leaflet_rkey"
608
+
cid := "leaflet_cid"
609
+
leafletNote := &models.Note{
610
+
Title: "Leaflet Note",
611
+
Content: "Has leaflet data",
612
+
LeafletRKey: &rkey,
613
+
LeafletCID: &cid,
614
+
IsDraft: false,
615
+
}
616
+
617
+
_, err = handler.repos.Notes.Create(ctx, leafletNote)
618
+
suite.AssertNoError(err, "create leaflet note")
619
+
620
+
err = handler.List(ctx, "all")
621
+
suite.AssertNoError(err, "list all leaflet notes")
622
})
623
})
624
}
+11
internal/ui/common.go
···
19
func errorMsg(msg string) string { return ErrorStyle.Render("โ " + msg) }
20
func warning(msg string) string { return WarningStyle.Render("โ " + msg) }
21
func info(msg string) string { return InfoStyle.Render("โน " + msg) }
0
22
func title(msg string) string { return TitleStyle.Render(msg) }
23
func subtitle(msg string) string { return SubtitleStyle.Render(msg) }
24
func box(content string) string { return BoxStyle.Render(content) }
···
63
64
// Infoln prints a formatted info message with a newline
65
func Infoln(format string, a ...any) {
0
0
0
0
0
0
0
0
0
0
66
fmt.Println(info(fmt.Sprintf(format, a...)))
67
}
68
···
19
func errorMsg(msg string) string { return ErrorStyle.Render("โ " + msg) }
20
func warning(msg string) string { return WarningStyle.Render("โ " + msg) }
21
func info(msg string) string { return InfoStyle.Render("โน " + msg) }
22
+
func infop(msg string) string { return InfoStyle.Render(msg) }
23
func title(msg string) string { return TitleStyle.Render(msg) }
24
func subtitle(msg string) string { return SubtitleStyle.Render(msg) }
25
func box(content string) string { return BoxStyle.Render(content) }
···
64
65
// Infoln prints a formatted info message with a newline
66
func Infoln(format string, a ...any) {
67
+
fmt.Println(infop(fmt.Sprintf(format, a...)))
68
+
}
69
+
70
+
// Infop prints a formatted info message, sans icon
71
+
func Infop(format string, a ...any) {
72
+
fmt.Print(infop(fmt.Sprintf(format, a...)))
73
+
}
74
+
75
+
// Infopln prints a formatted info message with a newline, sans icon
76
+
func Infopln(format string, a ...any) {
77
fmt.Println(info(fmt.Sprintf(format, a...)))
78
}
79