Skip to content

Commit 8fc4810

Browse files
committed
feat(wip): update push command to support file input and dry-run option
1 parent 7baf332 commit 8fc4810

File tree

5 files changed

+259
-83
lines changed

5 files changed

+259
-83
lines changed

cmd/publication_commands.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ Examples:
296296
root.AddCommand(patchCmd)
297297

298298
pushCmd := &cobra.Command{
299-
Use: "push [note-ids...]",
299+
Use: "push [note-ids...] [--file files...]",
300300
Short: "Create or update multiple documents on leaflet",
301301
Long: `Batch publish or update multiple local notes to leaflet.pub.
302302
@@ -307,10 +307,27 @@ For each note:
307307
This is useful for bulk operations and continuous publishing workflows.
308308
309309
Examples:
310-
noteleaf pub push 1 2 3 # Publish/update notes 1, 2, and 3
311-
noteleaf pub push 42 99 --draft # Create/update as drafts`,
312-
Args: cobra.MinimumNArgs(1),
310+
noteleaf pub push 1 2 3 # Publish/update notes 1, 2, and 3
311+
noteleaf pub push 42 99 --draft # Create/update as drafts
312+
noteleaf pub push --file article.md # Create note from file and push
313+
noteleaf pub push --file a.md b.md --draft # Create notes from multiple files
314+
noteleaf pub push 1 2 --dry-run # Validate without pushing
315+
noteleaf pub push --file article.md --dry-run # Create note but don't push`,
313316
RunE: func(cmd *cobra.Command, args []string) error {
317+
isDraft, _ := cmd.Flags().GetBool("draft")
318+
dryRun, _ := cmd.Flags().GetBool("dry-run")
319+
files, _ := cmd.Flags().GetStringSlice("file")
320+
321+
defer c.handler.Close()
322+
323+
if len(files) > 0 {
324+
return c.handler.PushFromFiles(cmd.Context(), files, isDraft, dryRun)
325+
}
326+
327+
if len(args) == 0 {
328+
return fmt.Errorf("no note IDs or files provided")
329+
}
330+
314331
noteIDs := make([]int64, len(args))
315332
for i, arg := range args {
316333
id, err := parseNoteID(arg)
@@ -320,15 +337,13 @@ Examples:
320337
noteIDs[i] = id
321338
}
322339

323-
isDraft, _ := cmd.Flags().GetBool("draft")
324-
325-
defer c.handler.Close()
326-
return c.handler.Push(cmd.Context(), noteIDs, isDraft)
340+
return c.handler.Push(cmd.Context(), noteIDs, isDraft, dryRun)
327341
},
328342
}
329343
pushCmd.Flags().Bool("draft", false, "Create/update as drafts instead of publishing")
344+
pushCmd.Flags().Bool("dry-run", false, "Create note records but skip leaflet push")
345+
pushCmd.Flags().StringSliceP("file", "f", []string{}, "Create notes from markdown files before pushing")
330346
root.AddCommand(pushCmd)
331-
332347
return root
333348
}
334349

internal/docs/ROADMAP.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ Noteleaf is a command-line and TUI tool for managing tasks, notes, media, and ar
44

55
## Core Usability
66

7-
The foundation across all domains is implemented. Tasks support CRUD operations, projects, tags, contexts, and time tracking. Notes have create, list, read, edit, and remove commands with interactive and static modes. Media queues exist for books, movies, and TV with progress and status management. SQLite persistence is in place with setup, seed, and reset commands. TUIs and colorized output are available.
7+
The foundation across all domains is implemented. Tasks support CRUD operations, projects, tags, contexts, and time tracking.
8+
Notes have create, list, read, edit, and remove commands with interactive and static modes. Media queues exist for books, movies, and TV with progress and status management. SQLite persistence is in place with setup, seed, and reset commands. TUIs and colorized output are available.
89

910
## RC
1011

@@ -43,7 +44,7 @@ The foundation across all domains is implemented. Tasks support CRUD operations,
4344
#### Publication
4445

4546
- [x] Implement authentication with BlueSky/leaflet (AT Protocol).
46-
- [ ] Add OAuth2
47+
- [ ] Add [OAuth2](#publications--authentication)
4748
- [x] Verify `pub pull` fetches and syncs documents from leaflet.
4849
- [x] Confirm `pub list` with status filtering (`all`, `published`, `draft`).
4950
- [ ] Test `pub post` creates new documents with draft/preview/validate modes.
@@ -206,6 +207,36 @@ Features that demonstrate Go proficiency and broaden Noteleaf’s scope.
206207
- [ ] Export to multiple formats
207208
- [ ] Linking with tasks and notes
208209

210+
### Publications & Authentication
211+
212+
- [ ] OAuth2 authentication for AT Protocol
213+
- [ ] Client metadata server for publishing application details
214+
- [ ] DPoP (Demonstrating Proof of Possession) implementation
215+
- [ ] ES256 JWT generation with unique JTI nonces
216+
- [ ] Server-issued nonce management with 5-minute rotation
217+
- [ ] Separate nonce tracking for authorization and resource servers
218+
- [ ] PAR (Pushed Authorization Requests) flow
219+
- [ ] PKCE code challenge generation
220+
- [ ] State token management
221+
- [ ] Request URI handling
222+
- [ ] Identity resolution and verification
223+
- [ ] Bidirectional handle verification
224+
- [ ] DID resolution from handles
225+
- [ ] Authorization server discovery via .well-known endpoints
226+
- [ ] Token lifecycle management
227+
- [ ] Access token refresh (5-15 min lifetime recommended)
228+
- [ ] Refresh token rotation (180 day max for confidential clients)
229+
- [ ] Concurrent request handling to prevent duplicate refreshes
230+
- [ ] Secure token storage (encrypted at rest)
231+
- [ ] Local callback server for OAuth redirects
232+
- [ ] Ephemeral HTTP server on localhost
233+
- [ ] Browser launch integration
234+
- [ ] Timeout handling for abandoned flows
235+
- [ ] Migration path from app passwords to OAuth
236+
- [ ] Detect existing app password sessions
237+
- [ ] Prompt users to upgrade authentication
238+
- [ ] Maintain backward compatibility
239+
209240
### User Experience
210241

211242
- [ ] Shell completions

internal/handlers/publication.go

Lines changed: 152 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"os"
99
"path/filepath"
10+
"strings"
1011
"time"
1112

1213
"github.com/stormlightlabs/noteleaf/internal/models"
@@ -384,17 +385,133 @@ func (h *PublicationHandler) Delete(ctx context.Context, noteID int64) error {
384385
return nil
385386
}
386387

388+
// createNoteFromFile creates a note from a markdown file and returns its ID
389+
func (h *PublicationHandler) createNoteFromFile(ctx context.Context, filePath string) (int64, error) {
390+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
391+
return 0, fmt.Errorf("file does not exist: %s", filePath)
392+
}
393+
394+
content, err := os.ReadFile(filePath)
395+
if err != nil {
396+
return 0, fmt.Errorf("failed to read file: %w", err)
397+
}
398+
399+
contentStr := string(content)
400+
if strings.TrimSpace(contentStr) == "" {
401+
return 0, fmt.Errorf("file is empty: %s", filePath)
402+
}
403+
404+
title, noteContent, tags := parseNoteContent(contentStr)
405+
if title == "" {
406+
title = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
407+
}
408+
409+
note := &models.Note{
410+
Title: title,
411+
Content: noteContent,
412+
Tags: tags,
413+
FilePath: filePath,
414+
}
415+
416+
noteID, err := h.repos.Notes.Create(ctx, note)
417+
if err != nil {
418+
return 0, fmt.Errorf("failed to create note: %w", err)
419+
}
420+
421+
ui.Infoln("Created note from file: %s", filePath)
422+
ui.Infoln(" Note: %s (ID: %d)", title, noteID)
423+
if len(tags) > 0 {
424+
ui.Infoln(" Tags: %s", strings.Join(tags, ", "))
425+
}
426+
427+
return noteID, nil
428+
}
429+
430+
// parseNoteContent extracts title, content, and tags from markdown content
431+
func parseNoteContent(content string) (title, noteContent string, tags []string) {
432+
lines := strings.Split(content, "\n")
433+
434+
for _, line := range lines {
435+
line = strings.TrimSpace(line)
436+
if after, ok := strings.CutPrefix(line, "# "); ok {
437+
title = after
438+
break
439+
}
440+
}
441+
442+
for _, line := range lines {
443+
line = strings.TrimSpace(line)
444+
if strings.HasPrefix(line, "<!-- Tags:") && strings.HasSuffix(line, "-->") {
445+
tagStr := strings.TrimPrefix(line, "<!-- Tags:")
446+
tagStr = strings.TrimSuffix(tagStr, "-->")
447+
tagStr = strings.TrimSpace(tagStr)
448+
449+
if tagStr != "" {
450+
for tag := range strings.SplitSeq(tagStr, ",") {
451+
tag = strings.TrimSpace(tag)
452+
if tag != "" {
453+
tags = append(tags, tag)
454+
}
455+
}
456+
}
457+
}
458+
}
459+
460+
noteContent = content
461+
462+
return title, noteContent, tags
463+
}
464+
465+
// PushFromFiles creates notes from files and pushes them to leaflet
466+
func (h *PublicationHandler) PushFromFiles(ctx context.Context, filePaths []string, isDraft bool, dryRun bool) error {
467+
if len(filePaths) == 0 {
468+
return fmt.Errorf("no file paths provided")
469+
}
470+
471+
ui.Infoln("Creating notes from %d file(s)...\n", len(filePaths))
472+
473+
noteIDs := make([]int64, 0, len(filePaths))
474+
var failed int
475+
476+
for _, filePath := range filePaths {
477+
noteID, err := h.createNoteFromFile(ctx, filePath)
478+
if err != nil {
479+
ui.Warningln("Failed to create note from %s: %v", filePath, err)
480+
failed++
481+
continue
482+
}
483+
noteIDs = append(noteIDs, noteID)
484+
}
485+
486+
if len(noteIDs) == 0 {
487+
return fmt.Errorf("failed to create any notes from files")
488+
}
489+
490+
ui.Newline()
491+
if dryRun {
492+
ui.Successln("Created %d note(s) from files. Skipping leaflet push (dry run).", len(noteIDs))
493+
ui.Infoln("Note IDs: %v", noteIDs)
494+
return nil
495+
}
496+
497+
return h.Push(ctx, noteIDs, isDraft, dryRun)
498+
}
499+
387500
// Push creates or updates multiple documents on leaflet from local notes
388-
func (h *PublicationHandler) Push(ctx context.Context, noteIDs []int64, isDraft bool) error {
389-
if !h.atproto.IsAuthenticated() {
501+
func (h *PublicationHandler) Push(ctx context.Context, noteIDs []int64, isDraft bool, dryRun bool) error {
502+
if !dryRun && !h.atproto.IsAuthenticated() {
390503
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
391504
}
392505

393506
if len(noteIDs) == 0 {
394507
return fmt.Errorf("no note IDs provided")
395508
}
396509

397-
ui.Infoln("Processing %d note(s)...\n", len(noteIDs))
510+
if dryRun {
511+
ui.Infoln("Dry run: validating %d note(s)...\n", len(noteIDs))
512+
} else {
513+
ui.Infoln("Processing %d note(s)...\n", len(noteIDs))
514+
}
398515

399516
var created, updated, failed int
400517
var errors []string
@@ -408,29 +525,50 @@ func (h *PublicationHandler) Push(ctx context.Context, noteIDs []int64, isDraft
408525
continue
409526
}
410527

411-
if note.HasLeafletAssociation() {
412-
err = h.Patch(ctx, noteID)
528+
if dryRun {
529+
_, _, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, note.HasLeafletAssociation())
413530
if err != nil {
414-
ui.Warningln(" [%d] Failed to update '%s': %v", noteID, note.Title, err)
531+
ui.Warningln(" [%d] Validation failed for '%s': %v", noteID, note.Title, err)
415532
errors = append(errors, fmt.Sprintf("note %d (%s): %v", noteID, note.Title, err))
416533
failed++
417534
} else {
418-
updated++
535+
ui.Infoln(" [%d] '%s' - validation passed", noteID, note.Title)
536+
if note.HasLeafletAssociation() {
537+
updated++
538+
} else {
539+
created++
540+
}
419541
}
420542
} else {
421-
err = h.Post(ctx, noteID, isDraft)
422-
if err != nil {
423-
ui.Warningln(" [%d] Failed to create '%s': %v", noteID, note.Title, err)
424-
errors = append(errors, fmt.Sprintf("note %d (%s): %v", noteID, note.Title, err))
425-
failed++
543+
if note.HasLeafletAssociation() {
544+
err = h.Patch(ctx, noteID)
545+
if err != nil {
546+
ui.Warningln(" [%d] Failed to update '%s': %v", noteID, note.Title, err)
547+
errors = append(errors, fmt.Sprintf("note %d (%s): %v", noteID, note.Title, err))
548+
failed++
549+
} else {
550+
updated++
551+
}
426552
} else {
427-
created++
553+
err = h.Post(ctx, noteID, isDraft)
554+
if err != nil {
555+
ui.Warningln(" [%d] Failed to create '%s': %v", noteID, note.Title, err)
556+
errors = append(errors, fmt.Sprintf("note %d (%s): %v", noteID, note.Title, err))
557+
failed++
558+
} else {
559+
created++
560+
}
428561
}
429562
}
430563
}
431564

432565
ui.Newline()
433-
ui.Successln("Push complete: %d created, %d updated, %d failed", created, updated, failed)
566+
if dryRun {
567+
ui.Successln("Dry run complete: %d would be created, %d would be updated, %d failed validation", created, updated, failed)
568+
ui.Infoln("No changes made to leaflet")
569+
} else {
570+
ui.Successln("Push complete: %d created, %d updated, %d failed", created, updated, failed)
571+
}
434572

435573
if len(errors) > 0 {
436574
return fmt.Errorf("push completed with %d error(s)", failed)

internal/handlers/publication_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,7 +1662,7 @@ func TestPublicationHandler(t *testing.T) {
16621662
handler := CreateHandler(t, NewPublicationHandler)
16631663
ctx := context.Background()
16641664

1665-
err := handler.Push(ctx, []int64{1, 2, 3}, false)
1665+
err := handler.Push(ctx, []int64{1, 2, 3}, false, false)
16661666
if err == nil {
16671667
t.Error("Expected error when not authenticated")
16681668
}
@@ -1694,7 +1694,7 @@ func TestPublicationHandler(t *testing.T) {
16941694
t.Fatalf("Failed to restore session: %v", err)
16951695
}
16961696

1697-
err = handler.Push(ctx, []int64{}, false)
1697+
err = handler.Push(ctx, []int64{}, false, false)
16981698
if err == nil {
16991699
t.Error("Expected error when no note IDs provided")
17001700
}
@@ -1726,7 +1726,7 @@ func TestPublicationHandler(t *testing.T) {
17261726
t.Fatalf("Failed to restore session: %v", err)
17271727
}
17281728

1729-
err = handler.Push(ctx, []int64{999}, false)
1729+
err = handler.Push(ctx, []int64{999}, false, false)
17301730
if err == nil {
17311731
t.Error("Expected error when note not found")
17321732
}
@@ -1773,7 +1773,7 @@ func TestPublicationHandler(t *testing.T) {
17731773
t.Fatalf("Failed to restore session: %v", err)
17741774
}
17751775

1776-
err = handler.Push(ctx, []int64{id1, id2}, false)
1776+
err = handler.Push(ctx, []int64{id1, id2}, false, false)
17771777

17781778
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
17791779
t.Logf("Got error during push (expected for external service call): %v", err)
@@ -1830,7 +1830,7 @@ func TestPublicationHandler(t *testing.T) {
18301830
t.Fatalf("Failed to restore session: %v", err)
18311831
}
18321832

1833-
err = handler.Push(ctx, []int64{id1, id2}, false)
1833+
err = handler.Push(ctx, []int64{id1, id2}, false, false)
18341834

18351835
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
18361836
t.Logf("Got error during push (expected for external service call): %v", err)
@@ -1882,7 +1882,7 @@ func TestPublicationHandler(t *testing.T) {
18821882
t.Fatalf("Failed to restore session: %v", err)
18831883
}
18841884

1885-
err = handler.Push(ctx, []int64{newID, existingID}, false)
1885+
err = handler.Push(ctx, []int64{newID, existingID}, false, false)
18861886

18871887
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
18881888
t.Logf("Got error during push (expected for external service call): %v", err)
@@ -1920,7 +1920,7 @@ func TestPublicationHandler(t *testing.T) {
19201920
}
19211921

19221922
invalidID := int64(999)
1923-
err = handler.Push(ctx, []int64{id1, invalidID}, false)
1923+
err = handler.Push(ctx, []int64{id1, invalidID}, false, false)
19241924

19251925
if err == nil {
19261926
t.Error("Expected error due to invalid note ID")
@@ -1961,7 +1961,7 @@ func TestPublicationHandler(t *testing.T) {
19611961
t.Fatalf("Failed to restore session: %v", err)
19621962
}
19631963

1964-
err = handler.Push(ctx, []int64{id}, true)
1964+
err = handler.Push(ctx, []int64{id}, true, false)
19651965

19661966
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
19671967
t.Logf("Got error during push (expected for external service call): %v", err)

0 commit comments

Comments
 (0)