cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat(pub): update publication commands with output options and plaintext support

+231 -110
+24 -4
cmd/publication_commands.go
··· 214 isDraft, _ := cmd.Flags().GetBool("draft") 215 preview, _ := cmd.Flags().GetBool("preview") 216 validate, _ := cmd.Flags().GetBool("validate") 217 218 defer c.handler.Close() 219 220 if preview { 221 - return c.handler.PostPreview(cmd.Context(), noteID, isDraft) 222 } 223 224 if validate { 225 - return c.handler.PostValidate(cmd.Context(), noteID, isDraft) 226 } 227 228 return c.handler.Post(cmd.Context(), noteID, isDraft) ··· 231 postCmd.Flags().Bool("draft", false, "Create as draft instead of publishing") 232 postCmd.Flags().Bool("preview", false, "Show what would be posted without actually posting") 233 postCmd.Flags().Bool("validate", false, "Validate markdown conversion without posting") 234 root.AddCommand(postCmd) 235 236 patchCmd := &cobra.Command{ ··· 257 258 preview, _ := cmd.Flags().GetBool("preview") 259 validate, _ := cmd.Flags().GetBool("validate") 260 261 defer c.handler.Close() 262 263 if preview { 264 - return c.handler.PatchPreview(cmd.Context(), noteID) 265 } 266 267 if validate { 268 - return c.handler.PatchValidate(cmd.Context(), noteID) 269 } 270 271 return c.handler.Patch(cmd.Context(), noteID) ··· 273 } 274 patchCmd.Flags().Bool("preview", false, "Show what would be updated without actually patching") 275 patchCmd.Flags().Bool("validate", false, "Validate markdown conversion without patching") 276 root.AddCommand(patchCmd) 277 278 pushCmd := &cobra.Command{
··· 214 isDraft, _ := cmd.Flags().GetBool("draft") 215 preview, _ := cmd.Flags().GetBool("preview") 216 validate, _ := cmd.Flags().GetBool("validate") 217 + output, _ := cmd.Flags().GetString("output") 218 + plaintext, _ := cmd.Flags().GetBool("plaintext") 219 + txt, _ := cmd.Flags().GetBool("txt") 220 + 221 + if txt { 222 + plaintext = true 223 + } 224 225 defer c.handler.Close() 226 227 if preview { 228 + return c.handler.PostPreview(cmd.Context(), noteID, isDraft, output, plaintext) 229 } 230 231 if validate { 232 + return c.handler.PostValidate(cmd.Context(), noteID, isDraft, output, plaintext) 233 } 234 235 return c.handler.Post(cmd.Context(), noteID, isDraft) ··· 238 postCmd.Flags().Bool("draft", false, "Create as draft instead of publishing") 239 postCmd.Flags().Bool("preview", false, "Show what would be posted without actually posting") 240 postCmd.Flags().Bool("validate", false, "Validate markdown conversion without posting") 241 + postCmd.Flags().StringP("output", "o", "", "Write document to file (defaults to JSON format)") 242 + postCmd.Flags().Bool("plaintext", false, "Use plaintext format for output file") 243 + postCmd.Flags().Bool("txt", false, "Alias for --plaintext") 244 root.AddCommand(postCmd) 245 246 patchCmd := &cobra.Command{ ··· 267 268 preview, _ := cmd.Flags().GetBool("preview") 269 validate, _ := cmd.Flags().GetBool("validate") 270 + output, _ := cmd.Flags().GetString("output") 271 + plaintext, _ := cmd.Flags().GetBool("plaintext") 272 + txt, _ := cmd.Flags().GetBool("txt") 273 + 274 + if txt { 275 + plaintext = true 276 + } 277 278 defer c.handler.Close() 279 280 if preview { 281 + return c.handler.PatchPreview(cmd.Context(), noteID, output, plaintext) 282 } 283 284 if validate { 285 + return c.handler.PatchValidate(cmd.Context(), noteID, output, plaintext) 286 } 287 288 return c.handler.Patch(cmd.Context(), noteID) ··· 290 } 291 patchCmd.Flags().Bool("preview", false, "Show what would be updated without actually patching") 292 patchCmd.Flags().Bool("validate", false, "Validate markdown conversion without patching") 293 + patchCmd.Flags().StringP("output", "o", "", "Write document to file (defaults to JSON format)") 294 + patchCmd.Flags().Bool("plaintext", false, "Use plaintext format for output file") 295 + patchCmd.Flags().Bool("txt", false, "Alias for --plaintext") 296 root.AddCommand(patchCmd) 297 298 pushCmd := &cobra.Command{
+99 -4
internal/handlers/publication.go
··· 3 4 import ( 5 "context" 6 "fmt" 7 "path/filepath" 8 "time" 9 ··· 549 return note, doc, nil 550 } 551 552 // PostPreview shows what would be posted without actually posting 553 - func (h *PublicationHandler) PostPreview(ctx context.Context, noteID int64, isDraft bool) error { 554 if !h.atproto.IsAuthenticated() { 555 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 556 } ··· 574 ui.Infoln(" PublishedAt: %s", doc.PublishedAt) 575 } 576 ui.Infoln(" Note ID: %d", note.ID) 577 ui.Successln("Preview complete - no changes made") 578 579 return nil 580 } 581 582 // PostValidate validates markdown conversion without posting 583 - func (h *PublicationHandler) PostValidate(ctx context.Context, noteID int64, isDraft bool) error { 584 if !h.atproto.IsAuthenticated() { 585 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 586 } ··· 595 ui.Infoln(" Title: %s", doc.Title) 596 ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks)) 597 598 return nil 599 } 600 601 // PatchPreview shows what would be patched without actually patching 602 - func (h *PublicationHandler) PatchPreview(ctx context.Context, noteID int64) error { 603 if !h.atproto.IsAuthenticated() { 604 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 605 } ··· 628 if doc.PublishedAt != "" { 629 ui.Infoln(" PublishedAt: %s", doc.PublishedAt) 630 } 631 ui.Successln("Preview complete - no changes made") 632 633 return nil 634 } 635 636 // PatchValidate validates markdown conversion without patching 637 - func (h *PublicationHandler) PatchValidate(ctx context.Context, noteID int64) error { 638 if !h.atproto.IsAuthenticated() { 639 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 640 } ··· 654 ui.Infoln(" Title: %s", doc.Title) 655 ui.Infoln(" RKey: %s", *note.LeafletRKey) 656 ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks)) 657 658 return nil 659 }
··· 3 4 import ( 5 "context" 6 + "encoding/json" 7 "fmt" 8 + "os" 9 "path/filepath" 10 "time" 11 ··· 551 return note, doc, nil 552 } 553 554 + // writeDocumentOutput writes document to a file in JSON or plaintext format 555 + func writeDocumentOutput(doc *public.Document, note *models.Note, outputPath string, plaintext bool) error { 556 + var content []byte 557 + var err error 558 + 559 + if plaintext { 560 + status := "published" 561 + if note != nil && note.IsDraft { 562 + status = "draft" 563 + } 564 + 565 + output := "Document Preview\n" 566 + output += "================\n\n" 567 + output += fmt.Sprintf("Title: %s\n", doc.Title) 568 + output += fmt.Sprintf("Status: %s\n", status) 569 + if note != nil { 570 + output += fmt.Sprintf("Note ID: %d\n", note.ID) 571 + if note.LeafletRKey != nil { 572 + output += fmt.Sprintf("RKey: %s\n", *note.LeafletRKey) 573 + } 574 + } 575 + output += fmt.Sprintf("Pages: %d\n", len(doc.Pages)) 576 + if len(doc.Pages) > 0 { 577 + output += fmt.Sprintf("Blocks: %d\n", len(doc.Pages[0].Blocks)) 578 + } 579 + if doc.PublishedAt != "" { 580 + output += fmt.Sprintf("PublishedAt: %s\n", doc.PublishedAt) 581 + } 582 + if doc.Author != "" { 583 + output += fmt.Sprintf("Author: %s\n", doc.Author) 584 + } 585 + 586 + content = []byte(output) 587 + } else { 588 + content, err = json.MarshalIndent(doc, "", " ") 589 + if err != nil { 590 + return fmt.Errorf("failed to marshal document to JSON: %w", err) 591 + } 592 + } 593 + 594 + if err := os.WriteFile(outputPath, content, 0644); err != nil { 595 + return fmt.Errorf("failed to write output to %s: %w", outputPath, err) 596 + } 597 + 598 + return nil 599 + } 600 + 601 // PostPreview shows what would be posted without actually posting 602 + func (h *PublicationHandler) PostPreview(ctx context.Context, noteID int64, isDraft bool, outputPath string, plaintext bool) error { 603 if !h.atproto.IsAuthenticated() { 604 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 605 } ··· 623 ui.Infoln(" PublishedAt: %s", doc.PublishedAt) 624 } 625 ui.Infoln(" Note ID: %d", note.ID) 626 + 627 + if outputPath != "" { 628 + if err := writeDocumentOutput(doc, note, outputPath, plaintext); err != nil { 629 + return err 630 + } 631 + format := "JSON" 632 + if plaintext { 633 + format = "plaintext" 634 + } 635 + ui.Successln("Output written to %s (%s format)", outputPath, format) 636 + } 637 + 638 ui.Successln("Preview complete - no changes made") 639 640 return nil 641 } 642 643 // PostValidate validates markdown conversion without posting 644 + func (h *PublicationHandler) PostValidate(ctx context.Context, noteID int64, isDraft bool, outputPath string, plaintext bool) error { 645 if !h.atproto.IsAuthenticated() { 646 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 647 } ··· 656 ui.Infoln(" Title: %s", doc.Title) 657 ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks)) 658 659 + if outputPath != "" { 660 + if err := writeDocumentOutput(doc, note, outputPath, plaintext); err != nil { 661 + return err 662 + } 663 + format := "JSON" 664 + if plaintext { 665 + format = "plaintext" 666 + } 667 + ui.Successln("Output written to %s (%s format)", outputPath, format) 668 + } 669 + 670 return nil 671 } 672 673 // PatchPreview shows what would be patched without actually patching 674 + func (h *PublicationHandler) PatchPreview(ctx context.Context, noteID int64, outputPath string, plaintext bool) error { 675 if !h.atproto.IsAuthenticated() { 676 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 677 } ··· 700 if doc.PublishedAt != "" { 701 ui.Infoln(" PublishedAt: %s", doc.PublishedAt) 702 } 703 + 704 + if outputPath != "" { 705 + if err := writeDocumentOutput(doc, note, outputPath, plaintext); err != nil { 706 + return err 707 + } 708 + format := "JSON" 709 + if plaintext { 710 + format = "plaintext" 711 + } 712 + ui.Successln("Output written to %s (%s format)", outputPath, format) 713 + } 714 + 715 ui.Successln("Preview complete - no changes made") 716 717 return nil 718 } 719 720 // PatchValidate validates markdown conversion without patching 721 + func (h *PublicationHandler) PatchValidate(ctx context.Context, noteID int64, outputPath string, plaintext bool) error { 722 if !h.atproto.IsAuthenticated() { 723 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 724 } ··· 738 ui.Infoln(" Title: %s", doc.Title) 739 ui.Infoln(" RKey: %s", *note.LeafletRKey) 740 ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks)) 741 + 742 + if outputPath != "" { 743 + if err := writeDocumentOutput(doc, note, outputPath, plaintext); err != nil { 744 + return err 745 + } 746 + format := "JSON" 747 + if plaintext { 748 + format = "plaintext" 749 + } 750 + ui.Successln("Output written to %s (%s format)", outputPath, format) 751 + } 752 753 return nil 754 }
+13 -13
internal/handlers/publication_test.go
··· 1019 handler := CreateHandler(t, NewPublicationHandler) 1020 ctx := context.Background() 1021 1022 - err := handler.PostPreview(ctx, 1, false) 1023 if err == nil { 1024 t.Error("Expected error when not authenticated") 1025 } ··· 1051 t.Fatalf("Failed to restore session: %v", err) 1052 } 1053 1054 - err = handler.PostPreview(ctx, 999, false) 1055 if err == nil { 1056 t.Error("Expected error when note does not exist") 1057 } ··· 1095 t.Fatalf("Failed to restore session: %v", err) 1096 } 1097 1098 - err = handler.PostPreview(ctx, id, false) 1099 if err == nil { 1100 t.Error("Expected error when note already published") 1101 } ··· 1135 t.Fatalf("Failed to restore session: %v", err) 1136 } 1137 1138 - err = handler.PostPreview(ctx, id, false) 1139 suite.AssertNoError(err, "preview should succeed") 1140 }) 1141 ··· 1169 t.Fatalf("Failed to restore session: %v", err) 1170 } 1171 1172 - err = handler.PostPreview(ctx, id, true) 1173 suite.AssertNoError(err, "preview draft should succeed") 1174 }) 1175 }) ··· 1182 handler := CreateHandler(t, NewPublicationHandler) 1183 ctx := context.Background() 1184 1185 - err := handler.PostValidate(ctx, 1, false) 1186 if err == nil { 1187 t.Error("Expected error when not authenticated") 1188 } ··· 1222 t.Fatalf("Failed to restore session: %v", err) 1223 } 1224 1225 - err = handler.PostValidate(ctx, id, false) 1226 suite.AssertNoError(err, "validation should succeed") 1227 }) 1228 }) ··· 1235 handler := CreateHandler(t, NewPublicationHandler) 1236 ctx := context.Background() 1237 1238 - err := handler.PatchPreview(ctx, 1) 1239 if err == nil { 1240 t.Error("Expected error when not authenticated") 1241 } ··· 1267 t.Fatalf("Failed to restore session: %v", err) 1268 } 1269 1270 - err = handler.PatchPreview(ctx, 999) 1271 if err == nil { 1272 t.Error("Expected error when note does not exist") 1273 } ··· 1307 t.Fatalf("Failed to restore session: %v", err) 1308 } 1309 1310 - err = handler.PatchPreview(ctx, id) 1311 if err == nil { 1312 t.Error("Expected error when note not published") 1313 } ··· 1354 t.Fatalf("Failed to restore session: %v", err) 1355 } 1356 1357 - err = handler.PatchPreview(ctx, id) 1358 suite.AssertNoError(err, "preview should succeed") 1359 }) 1360 }) ··· 1367 handler := CreateHandler(t, NewPublicationHandler) 1368 ctx := context.Background() 1369 1370 - err := handler.PatchValidate(ctx, 1) 1371 if err == nil { 1372 t.Error("Expected error when not authenticated") 1373 } ··· 1412 t.Fatalf("Failed to restore session: %v", err) 1413 } 1414 1415 - err = handler.PatchValidate(ctx, id) 1416 suite.AssertNoError(err, "validation should succeed") 1417 }) 1418 })
··· 1019 handler := CreateHandler(t, NewPublicationHandler) 1020 ctx := context.Background() 1021 1022 + err := handler.PostPreview(ctx, 1, false, "", false) 1023 if err == nil { 1024 t.Error("Expected error when not authenticated") 1025 } ··· 1051 t.Fatalf("Failed to restore session: %v", err) 1052 } 1053 1054 + err = handler.PostPreview(ctx, 999, false, "", false) 1055 if err == nil { 1056 t.Error("Expected error when note does not exist") 1057 } ··· 1095 t.Fatalf("Failed to restore session: %v", err) 1096 } 1097 1098 + err = handler.PostPreview(ctx, id, false, "", false) 1099 if err == nil { 1100 t.Error("Expected error when note already published") 1101 } ··· 1135 t.Fatalf("Failed to restore session: %v", err) 1136 } 1137 1138 + err = handler.PostPreview(ctx, id, false, "", false) 1139 suite.AssertNoError(err, "preview should succeed") 1140 }) 1141 ··· 1169 t.Fatalf("Failed to restore session: %v", err) 1170 } 1171 1172 + err = handler.PostPreview(ctx, id, true, "", false) 1173 suite.AssertNoError(err, "preview draft should succeed") 1174 }) 1175 }) ··· 1182 handler := CreateHandler(t, NewPublicationHandler) 1183 ctx := context.Background() 1184 1185 + err := handler.PostValidate(ctx, 1, false, "", false) 1186 if err == nil { 1187 t.Error("Expected error when not authenticated") 1188 } ··· 1222 t.Fatalf("Failed to restore session: %v", err) 1223 } 1224 1225 + err = handler.PostValidate(ctx, id, false, "", false) 1226 suite.AssertNoError(err, "validation should succeed") 1227 }) 1228 }) ··· 1235 handler := CreateHandler(t, NewPublicationHandler) 1236 ctx := context.Background() 1237 1238 + err := handler.PatchPreview(ctx, 1, "", false) 1239 if err == nil { 1240 t.Error("Expected error when not authenticated") 1241 } ··· 1267 t.Fatalf("Failed to restore session: %v", err) 1268 } 1269 1270 + err = handler.PatchPreview(ctx, 999, "", false) 1271 if err == nil { 1272 t.Error("Expected error when note does not exist") 1273 } ··· 1307 t.Fatalf("Failed to restore session: %v", err) 1308 } 1309 1310 + err = handler.PatchPreview(ctx, id, "", false) 1311 if err == nil { 1312 t.Error("Expected error when note not published") 1313 } ··· 1354 t.Fatalf("Failed to restore session: %v", err) 1355 } 1356 1357 + err = handler.PatchPreview(ctx, id, "", false) 1358 suite.AssertNoError(err, "preview should succeed") 1359 }) 1360 }) ··· 1367 handler := CreateHandler(t, NewPublicationHandler) 1368 ctx := context.Background() 1369 1370 + err := handler.PatchValidate(ctx, 1, "", false) 1371 if err == nil { 1372 t.Error("Expected error when not authenticated") 1373 } ··· 1412 t.Fatalf("Failed to restore session: %v", err) 1413 } 1414 1415 + err = handler.PatchValidate(ctx, id, "", false) 1416 suite.AssertNoError(err, "validation should succeed") 1417 }) 1418 })
+18 -11
internal/ui/publication_list_adapter.go
··· 170 return NewPublicationDataList(repo, opts, filter) 171 } 172 173 - // formatPublicationForView formats a publication for display with glamour 174 - func formatPublicationForView(note *models.Note) string { 175 var content strings.Builder 176 177 content.WriteString("# " + note.Title + "\n\n") ··· 180 if note.IsDraft { 181 status = "draft" 182 } 183 - content.WriteString("**Status:** " + status + "\n") 184 185 if note.PublishedAt != nil { 186 - content.WriteString("**Published:** " + note.PublishedAt.Format("2006-01-02 15:04") + "\n") 187 } 188 189 - content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n") 190 191 if note.LeafletRKey != nil { 192 - content.WriteString("**RKey:** `" + *note.LeafletRKey + "`\n") 193 } 194 195 if note.LeafletCID != nil { 196 - content.WriteString("**CID:** `" + *note.LeafletCID + "`\n") 197 } 198 199 content.WriteString("\n---\n\n") ··· 208 } 209 } 210 211 renderer, err := glamour.NewTermRenderer( 212 - glamour.WithAutoStyle(), 213 glamour.WithWordWrap(80), 214 ) 215 if err != nil { 216 - return content.String() 217 } 218 219 - rendered, err := renderer.Render(content.String()) 220 if err != nil { 221 - return content.String() 222 } 223 224 return rendered
··· 170 return NewPublicationDataList(repo, opts, filter) 171 } 172 173 + // buildPublicationMarkdown builds markdown content for a publication without rendering 174 + func buildPublicationMarkdown(note *models.Note) string { 175 var content strings.Builder 176 177 content.WriteString("# " + note.Title + "\n\n") ··· 180 if note.IsDraft { 181 status = "draft" 182 } 183 + content.WriteString("- **Status:** " + status + "\n") 184 185 if note.PublishedAt != nil { 186 + content.WriteString("- **Published:** " + note.PublishedAt.Format("2006-01-02 15:04") + "\n") 187 } 188 189 + content.WriteString("- **Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n") 190 191 if note.LeafletRKey != nil { 192 + content.WriteString("- **RKey:** `" + ObfuscateMiddle(*note.LeafletRKey, 3, 3) + "`\n") 193 } 194 195 if note.LeafletCID != nil { 196 + content.WriteString("- **CID:** `" + ObfuscateMiddle(*note.LeafletCID, 3, 3) + "`\n") 197 } 198 199 content.WriteString("\n---\n\n") ··· 208 } 209 } 210 211 + return content.String() 212 + } 213 + 214 + // formatPublicationForView formats a publication for display with glamour 215 + func formatPublicationForView(note *models.Note) string { 216 + markdown := buildPublicationMarkdown(note) 217 + 218 renderer, err := glamour.NewTermRenderer( 219 + glamour.WithStandardStyle("tokyo-night"), 220 glamour.WithWordWrap(80), 221 ) 222 if err != nil { 223 + return markdown 224 } 225 226 + rendered, err := renderer.Render(markdown) 227 if err != nil { 228 + return markdown 229 } 230 231 return rendered
+18 -24
internal/ui/publication_list_adapter_test.go
··· 493 } 494 }) 495 496 - t.Run("formatPublicationForView", func(t *testing.T) { 497 t.Run("formats published note with all metadata", func(t *testing.T) { 498 rkey := "test-rkey" 499 cid := "test-cid" ··· 510 LeafletCID: &cid, 511 } 512 513 - result := formatPublicationForView(note) 514 515 if !strings.Contains(result, "Test Article") { 516 - t.Errorf("Formatted view should contain title\nGot: %s", result) 517 } 518 if !strings.Contains(result, "published") { 519 - t.Errorf("Formatted view should contain status 'published'\nGot: %s", result) 520 } 521 if !strings.Contains(result, "2024-01-15") { 522 - t.Errorf("Formatted view should contain published date\nGot: %s", result) 523 } 524 - if !strings.Contains(result, "Modified") && !strings.Contains(result, "2024-01-16") { 525 - t.Errorf("Formatted view should contain modified date\nGot: %s", result) 526 - } 527 - if !strings.Contains(result, "test-rkey") { 528 - t.Error("Formatted view should contain rkey") 529 - } 530 - if !strings.Contains(result, "test-cid") { 531 - t.Error("Formatted view should contain cid") 532 } 533 }) 534 ··· 541 Modified: time.Date(2024, 1, 20, 14, 0, 0, 0, time.UTC), 542 } 543 544 - result := formatPublicationForView(note) 545 546 if !strings.Contains(result, "Draft Article") { 547 - t.Error("Formatted view should contain title") 548 } 549 if !strings.Contains(result, "draft") { 550 - t.Error("Formatted view should contain status 'draft'") 551 } 552 if strings.Contains(result, "Published:") { 553 - t.Error("Formatted draft view should not contain published date") 554 } 555 if !strings.Contains(result, "2024-01-20 14:00") { 556 - t.Error("Formatted view should contain modified date") 557 } 558 }) 559 ··· 566 Modified: time.Now(), 567 } 568 569 - result := formatPublicationForView(note) 570 571 if !strings.Contains(result, "Plain Content") { 572 - t.Error("Formatted view should contain title") 573 } 574 if !strings.Contains(result, "This content has no markdown header") { 575 - t.Error("Formatted view should contain full content") 576 } 577 }) 578 ··· 585 Modified: time.Now(), 586 } 587 588 - result := formatPublicationForView(note) 589 590 titleCount := strings.Count(result, "Article Title") 591 if titleCount < 1 { 592 - t.Error("Formatted view should contain title at least once") 593 } 594 if !strings.Contains(result, "Content after title") { 595 - t.Error("Formatted view should contain content after title") 596 } 597 }) 598 })
··· 493 } 494 }) 495 496 + t.Run("buildPublicationMarkdown", func(t *testing.T) { 497 t.Run("formats published note with all metadata", func(t *testing.T) { 498 rkey := "test-rkey" 499 cid := "test-cid" ··· 510 LeafletCID: &cid, 511 } 512 513 + result := buildPublicationMarkdown(note) 514 515 if !strings.Contains(result, "Test Article") { 516 + t.Error("Markdown should contain title") 517 } 518 if !strings.Contains(result, "published") { 519 + t.Error("Markdown should contain status 'published'") 520 } 521 if !strings.Contains(result, "2024-01-15") { 522 + t.Error("Markdown should contain published date") 523 } 524 + if !strings.Contains(result, "2024-01-16") { 525 + t.Error("Markdown should contain modified date") 526 } 527 }) 528 ··· 535 Modified: time.Date(2024, 1, 20, 14, 0, 0, 0, time.UTC), 536 } 537 538 + result := buildPublicationMarkdown(note) 539 540 if !strings.Contains(result, "Draft Article") { 541 + t.Error("Markdown should contain title") 542 } 543 if !strings.Contains(result, "draft") { 544 + t.Error("Markdown should contain status 'draft'") 545 } 546 if strings.Contains(result, "Published:") { 547 + t.Error("Draft markdown should not contain published date") 548 } 549 if !strings.Contains(result, "2024-01-20 14:00") { 550 + t.Error("Markdown should contain modified date") 551 } 552 }) 553 ··· 560 Modified: time.Now(), 561 } 562 563 + result := buildPublicationMarkdown(note) 564 565 if !strings.Contains(result, "Plain Content") { 566 + t.Error("Markdown should contain title") 567 } 568 if !strings.Contains(result, "This content has no markdown header") { 569 + t.Error("Markdown should contain full content") 570 } 571 }) 572 ··· 579 Modified: time.Now(), 580 } 581 582 + result := buildPublicationMarkdown(note) 583 584 titleCount := strings.Count(result, "Article Title") 585 if titleCount < 1 { 586 + t.Error("Markdown should contain title at least once") 587 } 588 if !strings.Contains(result, "Content after title") { 589 + t.Error("Markdown should contain content after title") 590 } 591 }) 592 })
+48 -43
internal/ui/publication_view.go
··· 5 "fmt" 6 "io" 7 "os" 8 - "strings" 9 10 "github.com/charmbracelet/bubbles/help" 11 "github.com/charmbracelet/bubbles/key" ··· 173 return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help) 174 } 175 176 - // buildPublicationMarkdown creates the markdown content for rendering 177 - func buildPublicationMarkdown(note *models.Note) string { 178 - var content strings.Builder 179 - 180 - content.WriteString("# " + note.Title + "\n\n") 181 - 182 - status := "published" 183 - if note.IsDraft { 184 - status = "draft" 185 - } 186 - content.WriteString("**Status:** " + status + "\n") 187 - 188 - if note.PublishedAt != nil { 189 - content.WriteString("**Published:** " + note.PublishedAt.Format("2006-01-02 15:04") + "\n") 190 - } 191 - 192 - content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n") 193 - 194 - if note.LeafletRKey != nil { 195 - content.WriteString("**RKey:** `" + *note.LeafletRKey + "`\n") 196 - } 197 - 198 - if note.LeafletCID != nil { 199 - content.WriteString("**CID:** `" + *note.LeafletCID + "`\n") 200 - } 201 - 202 - content.WriteString("\n---\n\n") 203 - 204 - noteContent := strings.TrimSpace(note.Content) 205 - if !strings.HasPrefix(noteContent, "# ") { 206 - content.WriteString(noteContent) 207 - } else { 208 - lines := strings.Split(noteContent, "\n") 209 - if len(lines) > 1 { 210 - content.WriteString(strings.Join(lines[1:], "\n")) 211 - } 212 - } 213 - 214 - return content.String() 215 - } 216 - 217 // formatPublicationContent renders markdown with glamour for viewport display 218 func formatPublicationContent(note *models.Note) (string, error) { 219 markdown := buildPublicationMarkdown(note) 220 221 renderer, err := glamour.NewTermRenderer( 222 glamour.WithAutoStyle(), 223 - glamour.WithWordWrap(80), 224 ) 225 if err != nil { 226 return markdown, fmt.Errorf("failed to create renderer: %w", err) ··· 277 fmt.Fprint(pv.opts.Output, content) 278 return nil 279 }
··· 5 "fmt" 6 "io" 7 "os" 8 + "unicode/utf8" 9 10 "github.com/charmbracelet/bubbles/help" 11 "github.com/charmbracelet/bubbles/key" ··· 173 return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help) 174 } 175 176 // formatPublicationContent renders markdown with glamour for viewport display 177 func formatPublicationContent(note *models.Note) (string, error) { 178 markdown := buildPublicationMarkdown(note) 179 180 renderer, err := glamour.NewTermRenderer( 181 glamour.WithAutoStyle(), 182 + glamour.WithStandardStyle("tokyo-night"), 183 + glamour.WithPreservedNewLines(), 184 + glamour.WithWordWrap(79), 185 ) 186 if err != nil { 187 return markdown, fmt.Errorf("failed to create renderer: %w", err) ··· 238 fmt.Fprint(pv.opts.Output, content) 239 return nil 240 } 241 + 242 + // ObfuscateMiddle returns a string where the middle portion is replaced by "..." 243 + // TODO: move to package utils or shared 244 + func ObfuscateMiddle(s string, left, right int) string { 245 + if s == "" { 246 + return s 247 + } 248 + if left < 0 { 249 + left = 0 250 + } 251 + if right < 0 { 252 + right = 0 253 + } 254 + 255 + n := utf8.RuneCountInString(s) 256 + if left+right >= n { 257 + return s 258 + } 259 + 260 + var ( 261 + prefixRunes = make([]rune, 0, left) 262 + suffixRunes = make([]rune, 0, right) 263 + ) 264 + i := 0 265 + for _, r := range s { 266 + if i >= left { 267 + break 268 + } 269 + prefixRunes = append(prefixRunes, r) 270 + i++ 271 + } 272 + 273 + if right > 0 { 274 + allRunes := []rune(s) 275 + start := max(n-right, 0) 276 + suffixRunes = append(suffixRunes, allRunes[start:]...) 277 + } 278 + 279 + const repl = "..." 280 + if right == 0 { 281 + return string(prefixRunes) + repl 282 + } 283 + return string(prefixRunes) + repl + string(suffixRunes) 284 + }
+11 -11
internal/ui/publication_view_test.go
··· 138 139 output := buf.String() 140 141 - if !strings.Contains(output, "Test Publication") { 142 t.Error("Note title not displayed") 143 } 144 if !strings.Contains(output, "published") { ··· 153 if !strings.Contains(output, "RKey:") { 154 t.Error("RKey not displayed") 155 } 156 - if !strings.Contains(output, "test-rkey-123") { 157 t.Error("RKey value not displayed") 158 } 159 if !strings.Contains(output, "CID:") { 160 t.Error("CID not displayed") 161 } 162 - if !strings.Contains(output, "test-cid-456") { 163 t.Error("CID value not displayed") 164 } 165 - if !strings.Contains(output, "This is the content") { 166 t.Error("Note content not displayed") 167 } 168 }) ··· 213 214 output := buf.String() 215 216 - if !strings.Contains(output, "Minimal Note") { 217 t.Error("Note title not displayed") 218 } 219 - if !strings.Contains(output, "Simple content") { 220 t.Error("Note content not displayed") 221 } 222 if !strings.Contains(output, "Modified:") { ··· 235 "**Status:** published", 236 "**Published:**", 237 "**Modified:**", 238 - "**RKey:** `test-rkey-123`", 239 - "**CID:** `test-cid-456`", 240 "---", 241 "This is the content", 242 } ··· 308 t.Fatalf("formatPublicationContent failed: %v", err) 309 } 310 311 - if !strings.Contains(content, "Test Publication") { 312 t.Error("Formatted content should include note title") 313 } 314 }) ··· 634 t.Error("No output generated") 635 } 636 637 - if !strings.Contains(output, note.Title) { 638 t.Error("Note title not displayed") 639 } 640 - if !strings.Contains(output, "This is the content") { 641 t.Error("Note content not displayed") 642 } 643 })
··· 138 139 output := buf.String() 140 141 + if !strings.Contains(output, "Test") || !strings.Contains(output, "Publication") { 142 t.Error("Note title not displayed") 143 } 144 if !strings.Contains(output, "published") { ··· 153 if !strings.Contains(output, "RKey:") { 154 t.Error("RKey not displayed") 155 } 156 + if !strings.Contains(output, "tes") || !strings.Contains(output, "123") { 157 t.Error("RKey value not displayed") 158 } 159 if !strings.Contains(output, "CID:") { 160 t.Error("CID not displayed") 161 } 162 + if !strings.Contains(output, "tes") || !strings.Contains(output, "456") { 163 t.Error("CID value not displayed") 164 } 165 + if !strings.Contains(output, "This") || !strings.Contains(output, "content") { 166 t.Error("Note content not displayed") 167 } 168 }) ··· 213 214 output := buf.String() 215 216 + if !strings.Contains(output, "Minimal") || !strings.Contains(output, "Note") { 217 t.Error("Note title not displayed") 218 } 219 + if !strings.Contains(output, "Simple") || !strings.Contains(output, "content") { 220 t.Error("Note content not displayed") 221 } 222 if !strings.Contains(output, "Modified:") { ··· 235 "**Status:** published", 236 "**Published:**", 237 "**Modified:**", 238 + "**RKey:**", 239 + "**CID:**", 240 "---", 241 "This is the content", 242 } ··· 308 t.Fatalf("formatPublicationContent failed: %v", err) 309 } 310 311 + if !strings.Contains(content, "Test") || !strings.Contains(content, "Publication") { 312 t.Error("Formatted content should include note title") 313 } 314 }) ··· 634 t.Error("No output generated") 635 } 636 637 + if !strings.Contains(output, "Test") || !strings.Contains(output, "Publication") { 638 t.Error("Note title not displayed") 639 } 640 + if !strings.Contains(output, "This") || !strings.Contains(output, "content") { 641 t.Error("Note content not displayed") 642 } 643 })