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