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
feat(wip): create or update post handler & command
desertthunder.dev
3 months ago
2a281596
f5d0c10a
+872
-68
4 changed files
expand all
collapse all
unified
split
cmd
publication_commands.go
publication_commands_test.go
internal
handlers
publication.go
publication_test.go
+94
cmd/publication_commands.go
···
2
3
import (
4
"fmt"
0
5
6
"github.com/spf13/cobra"
7
"github.com/stormlightlabs/noteleaf/internal/handlers"
···
154
}
155
root.AddCommand(statusCmd)
156
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
157
return root
158
}
0
0
0
0
0
0
0
0
···
2
3
import (
4
"fmt"
5
+
"strconv"
6
7
"github.com/spf13/cobra"
8
"github.com/stormlightlabs/noteleaf/internal/handlers"
···
155
}
156
root.AddCommand(statusCmd)
157
158
+
postCmd := &cobra.Command{
159
+
Use: "post [note-id]",
160
+
Short: "Create a new document on leaflet",
161
+
Long: `Publish a local note to leaflet.pub as a new document.
162
+
163
+
This command converts your markdown note to leaflet's block format and creates
164
+
a new document on the platform. The note will be linked to the leaflet document
165
+
for future updates via the patch command.
166
+
167
+
Examples:
168
+
noteleaf pub post 123 # Publish note 123
169
+
noteleaf pub post 123 --draft # Create as draft
170
+
noteleaf pub post 123 --preview # Preview without posting
171
+
noteleaf pub post 123 --validate # Validate conversion only`,
172
+
Args: cobra.ExactArgs(1),
173
+
RunE: func(cmd *cobra.Command, args []string) error {
174
+
noteID, err := parseNoteID(args[0])
175
+
if err != nil {
176
+
return err
177
+
}
178
+
179
+
isDraft, _ := cmd.Flags().GetBool("draft")
180
+
preview, _ := cmd.Flags().GetBool("preview")
181
+
validate, _ := cmd.Flags().GetBool("validate")
182
+
183
+
defer c.handler.Close()
184
+
185
+
if preview {
186
+
return c.handler.PostPreview(cmd.Context(), noteID, isDraft)
187
+
}
188
+
189
+
if validate {
190
+
return c.handler.PostValidate(cmd.Context(), noteID, isDraft)
191
+
}
192
+
193
+
return c.handler.Post(cmd.Context(), noteID, isDraft)
194
+
},
195
+
}
196
+
postCmd.Flags().Bool("draft", false, "Create as draft instead of publishing")
197
+
postCmd.Flags().Bool("preview", false, "Show what would be posted without actually posting")
198
+
postCmd.Flags().Bool("validate", false, "Validate markdown conversion without posting")
199
+
root.AddCommand(postCmd)
200
+
201
+
patchCmd := &cobra.Command{
202
+
Use: "patch [note-id]",
203
+
Short: "Update an existing document on leaflet",
204
+
Long: `Update an existing leaflet document from a local note.
205
+
206
+
This command converts your markdown note to leaflet's block format and updates
207
+
the existing document on the platform. The note must have been previously posted
208
+
or pulled from leaflet (it needs a leaflet record key).
209
+
210
+
The document's draft/published status is preserved from the note's current state.
211
+
212
+
Examples:
213
+
noteleaf pub patch 123 # Update existing document
214
+
noteleaf pub patch 123 --preview # Preview without updating
215
+
noteleaf pub patch 123 --validate # Validate conversion only`,
216
+
Args: cobra.ExactArgs(1),
217
+
RunE: func(cmd *cobra.Command, args []string) error {
218
+
noteID, err := parseNoteID(args[0])
219
+
if err != nil {
220
+
return err
221
+
}
222
+
223
+
preview, _ := cmd.Flags().GetBool("preview")
224
+
validate, _ := cmd.Flags().GetBool("validate")
225
+
226
+
defer c.handler.Close()
227
+
228
+
if preview {
229
+
return c.handler.PatchPreview(cmd.Context(), noteID)
230
+
}
231
+
232
+
if validate {
233
+
return c.handler.PatchValidate(cmd.Context(), noteID)
234
+
}
235
+
236
+
return c.handler.Patch(cmd.Context(), noteID)
237
+
},
238
+
}
239
+
patchCmd.Flags().Bool("preview", false, "Show what would be updated without actually patching")
240
+
patchCmd.Flags().Bool("validate", false, "Validate markdown conversion without patching")
241
+
root.AddCommand(patchCmd)
242
+
243
return root
244
}
245
+
246
+
func parseNoteID(arg string) (int64, error) {
247
+
noteID, err := strconv.ParseInt(arg, 10, 64)
248
+
if err != nil {
249
+
return 0, fmt.Errorf("invalid note ID '%s': must be a number", arg)
250
+
}
251
+
return noteID, nil
252
+
}
+192
cmd/publication_commands_test.go
···
66
"pull",
67
"list [--published|--draft|--all]",
68
"status",
0
0
69
}
70
71
for _, expected := range expectedSubcommands {
···
171
t.Error("Expected pull to fail when not authenticated")
172
}
173
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
174
t.Errorf("Expected 'not authenticated' error, got: %v", err)
175
}
176
})
···
66
"pull",
67
"list [--published|--draft|--all]",
68
"status",
69
+
"post [note-id]",
70
+
"patch [note-id]",
71
}
72
73
for _, expected := range expectedSubcommands {
···
173
t.Error("Expected pull to fail when not authenticated")
174
}
175
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
176
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
177
+
}
178
+
})
179
+
})
180
+
181
+
t.Run("Post Command", func(t *testing.T) {
182
+
t.Run("requires note ID argument", func(t *testing.T) {
183
+
handler, cleanup := createTestPublicationHandler(t)
184
+
defer cleanup()
185
+
186
+
cmd := NewPublicationCommand(handler).Create()
187
+
cmd.SetArgs([]string{"post"})
188
+
err := cmd.Execute()
189
+
190
+
if err == nil {
191
+
t.Error("Expected error for missing note ID")
192
+
}
193
+
})
194
+
195
+
t.Run("rejects invalid note ID", func(t *testing.T) {
196
+
handler, cleanup := createTestPublicationHandler(t)
197
+
defer cleanup()
198
+
199
+
cmd := NewPublicationCommand(handler).Create()
200
+
cmd.SetArgs([]string{"post", "not-a-number"})
201
+
err := cmd.Execute()
202
+
203
+
if err == nil {
204
+
t.Error("Expected error for invalid note ID")
205
+
}
206
+
if !strings.Contains(err.Error(), "invalid note ID") {
207
+
t.Errorf("Expected 'invalid note ID' error, got: %v", err)
208
+
}
209
+
})
210
+
211
+
t.Run("fails when not authenticated", func(t *testing.T) {
212
+
handler, cleanup := createTestPublicationHandler(t)
213
+
defer cleanup()
214
+
215
+
cmd := NewPublicationCommand(handler).Create()
216
+
cmd.SetArgs([]string{"post", "123"})
217
+
err := cmd.Execute()
218
+
219
+
if err == nil {
220
+
t.Error("Expected post to fail when not authenticated")
221
+
}
222
+
if !strings.Contains(err.Error(), "not authenticated") {
223
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
224
+
}
225
+
})
226
+
227
+
t.Run("preview mode fails when not authenticated", func(t *testing.T) {
228
+
handler, cleanup := createTestPublicationHandler(t)
229
+
defer cleanup()
230
+
231
+
cmd := NewPublicationCommand(handler).Create()
232
+
cmd.SetArgs([]string{"post", "123", "--preview"})
233
+
err := cmd.Execute()
234
+
235
+
if err == nil {
236
+
t.Error("Expected post --preview to fail when not authenticated")
237
+
}
238
+
if !strings.Contains(err.Error(), "not authenticated") {
239
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
240
+
}
241
+
})
242
+
243
+
t.Run("validate mode fails when not authenticated", func(t *testing.T) {
244
+
handler, cleanup := createTestPublicationHandler(t)
245
+
defer cleanup()
246
+
247
+
cmd := NewPublicationCommand(handler).Create()
248
+
cmd.SetArgs([]string{"post", "123", "--validate"})
249
+
err := cmd.Execute()
250
+
251
+
if err == nil {
252
+
t.Error("Expected post --validate to fail when not authenticated")
253
+
}
254
+
if !strings.Contains(err.Error(), "not authenticated") {
255
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
256
+
}
257
+
})
258
+
259
+
t.Run("accepts draft flag", func(t *testing.T) {
260
+
handler, cleanup := createTestPublicationHandler(t)
261
+
defer cleanup()
262
+
263
+
cmd := NewPublicationCommand(handler).Create()
264
+
cmd.SetArgs([]string{"post", "123", "--draft"})
265
+
err := cmd.Execute()
266
+
267
+
if err == nil {
268
+
t.Error("Expected post --draft to fail when not authenticated")
269
+
}
270
+
if !strings.Contains(err.Error(), "not authenticated") {
271
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
272
+
}
273
+
})
274
+
275
+
t.Run("accepts preview and draft flags together", func(t *testing.T) {
276
+
handler, cleanup := createTestPublicationHandler(t)
277
+
defer cleanup()
278
+
279
+
cmd := NewPublicationCommand(handler).Create()
280
+
cmd.SetArgs([]string{"post", "123", "--preview", "--draft"})
281
+
err := cmd.Execute()
282
+
283
+
if err == nil {
284
+
t.Error("Expected post --preview --draft to fail when not authenticated")
285
+
}
286
+
if !strings.Contains(err.Error(), "not authenticated") {
287
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
288
+
}
289
+
})
290
+
})
291
+
292
+
t.Run("Patch Command", func(t *testing.T) {
293
+
t.Run("requires note ID argument", func(t *testing.T) {
294
+
handler, cleanup := createTestPublicationHandler(t)
295
+
defer cleanup()
296
+
297
+
cmd := NewPublicationCommand(handler).Create()
298
+
cmd.SetArgs([]string{"patch"})
299
+
err := cmd.Execute()
300
+
301
+
if err == nil {
302
+
t.Error("Expected error for missing note ID")
303
+
}
304
+
})
305
+
306
+
t.Run("rejects invalid note ID", func(t *testing.T) {
307
+
handler, cleanup := createTestPublicationHandler(t)
308
+
defer cleanup()
309
+
310
+
cmd := NewPublicationCommand(handler).Create()
311
+
cmd.SetArgs([]string{"patch", "not-a-number"})
312
+
err := cmd.Execute()
313
+
314
+
if err == nil {
315
+
t.Error("Expected error for invalid note ID")
316
+
}
317
+
if !strings.Contains(err.Error(), "invalid note ID") {
318
+
t.Errorf("Expected 'invalid note ID' error, got: %v", err)
319
+
}
320
+
})
321
+
322
+
t.Run("fails when not authenticated", func(t *testing.T) {
323
+
handler, cleanup := createTestPublicationHandler(t)
324
+
defer cleanup()
325
+
326
+
cmd := NewPublicationCommand(handler).Create()
327
+
cmd.SetArgs([]string{"patch", "123"})
328
+
err := cmd.Execute()
329
+
330
+
if err == nil {
331
+
t.Error("Expected patch to fail when not authenticated")
332
+
}
333
+
if !strings.Contains(err.Error(), "not authenticated") {
334
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
335
+
}
336
+
})
337
+
338
+
t.Run("preview mode fails when not authenticated", func(t *testing.T) {
339
+
handler, cleanup := createTestPublicationHandler(t)
340
+
defer cleanup()
341
+
342
+
cmd := NewPublicationCommand(handler).Create()
343
+
cmd.SetArgs([]string{"patch", "123", "--preview"})
344
+
err := cmd.Execute()
345
+
346
+
if err == nil {
347
+
t.Error("Expected patch --preview to fail when not authenticated")
348
+
}
349
+
if !strings.Contains(err.Error(), "not authenticated") {
350
+
t.Errorf("Expected 'not authenticated' error, got: %v", err)
351
+
}
352
+
})
353
+
354
+
t.Run("validate mode fails when not authenticated", func(t *testing.T) {
355
+
handler, cleanup := createTestPublicationHandler(t)
356
+
defer cleanup()
357
+
358
+
cmd := NewPublicationCommand(handler).Create()
359
+
cmd.SetArgs([]string{"patch", "123", "--validate"})
360
+
err := cmd.Execute()
361
+
362
+
if err == nil {
363
+
t.Error("Expected patch --validate to fail when not authenticated")
364
+
}
365
+
if !strings.Contains(err.Error(), "not authenticated") {
366
t.Errorf("Expected 'not authenticated' error, got: %v", err)
367
}
368
})
+180
-68
internal/handlers/publication.go
···
2
//
3
// TODO: Post (create 1)
4
// TODO: Patch (update 1)
5
-
// TODO: Push (create or update - more than 1)
0
0
6
// TODO: Add TUI viewing for document details
0
0
0
0
0
7
// TODO: Repost - "Reblog" - post to BlueSky
8
package handlers
9
···
283
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
284
}
285
286
-
note, err := h.repos.Notes.Get(ctx, noteID)
287
-
if err != nil {
288
-
return fmt.Errorf("failed to get note: %w", err)
289
-
}
290
-
291
-
if note.HasLeafletAssociation() {
292
-
return fmt.Errorf("note already published - use patch to update")
293
-
}
294
-
295
-
session, err := h.atproto.GetSession()
296
-
if err != nil {
297
-
return fmt.Errorf("failed to get session: %w", err)
298
-
}
299
-
300
// TODO: Implement image handling for markdown conversion
301
// 1. Extract note's directory from filepath/database
302
// 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
303
// 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
304
// This will upload images to AT Protocol and get real CIDs/dimensions
305
-
converter := public.NewMarkdownConverter()
306
-
blocks, err := converter.ToLeaflet(note.Content)
307
if err != nil {
308
-
return fmt.Errorf("failed to convert markdown to leaflet format: %w", err)
309
-
}
310
-
311
-
doc := public.Document{
312
-
Author: session.DID,
313
-
Title: note.Title,
314
-
Description: "",
315
-
Pages: []public.LinearDocument{
316
-
{
317
-
Type: public.TypeLinearDocument,
318
-
Blocks: blocks,
319
-
},
320
-
},
321
-
}
322
-
323
-
if !isDraft {
324
-
now := time.Now()
325
-
doc.PublishedAt = now.Format(time.RFC3339)
326
}
327
328
ui.Infoln("Creating document '%s' on leaflet...", note.Title)
329
330
-
result, err := h.atproto.PostDocument(ctx, doc, isDraft)
331
if err != nil {
332
return fmt.Errorf("failed to post document: %w", err)
333
}
···
364
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
365
}
366
367
-
note, err := h.repos.Notes.Get(ctx, noteID)
368
if err != nil {
369
return fmt.Errorf("failed to get note: %w", err)
370
}
371
372
-
if !note.HasLeafletAssociation() {
373
-
return fmt.Errorf("note not published - use post to create")
374
-
}
375
-
376
-
session, err := h.atproto.GetSession()
377
-
if err != nil {
378
-
return fmt.Errorf("failed to get session: %w", err)
379
-
}
380
-
381
// TODO: Implement image handling for markdown conversion (same as Post method)
382
// 1. Extract note's directory from filepath/database
383
// 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
384
// 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
385
// This will upload images to AT Protocol and get real CIDs/dimensions
386
-
converter := public.NewMarkdownConverter()
387
-
blocks, err := converter.ToLeaflet(note.Content)
388
if err != nil {
389
-
return fmt.Errorf("failed to convert markdown to leaflet format: %w", err)
390
}
391
392
-
doc := public.Document{
393
-
Author: session.DID,
394
-
Title: note.Title,
395
-
Description: "",
396
-
Pages: []public.LinearDocument{
397
-
{
398
-
Type: public.TypeLinearDocument,
399
-
Blocks: blocks,
400
-
},
401
-
},
402
-
}
403
-
404
-
if !note.IsDraft && note.PublishedAt != nil {
405
-
doc.PublishedAt = note.PublishedAt.Format(time.RFC3339)
406
-
} else if !note.IsDraft {
407
-
now := time.Now()
408
-
doc.PublishedAt = now.Format(time.RFC3339)
409
-
note.PublishedAt = &now
410
}
411
412
ui.Infoln("Updating document '%s' on leaflet...", note.Title)
413
414
-
result, err := h.atproto.PatchDocument(ctx, *note.LeafletRKey, doc, note.IsDraft)
415
if err != nil {
416
return fmt.Errorf("failed to patch document: %w", err)
417
}
···
461
}
462
463
ui.Successln("Document deleted successfully!")
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
464
465
return nil
466
}
···
2
//
3
// TODO: Post (create 1)
4
// TODO: Patch (update 1)
5
+
// TODO: Push
6
+
// - Builds on Post & Patch (create or update - more than 1)
7
+
//
8
// TODO: Add TUI viewing for document details
9
+
// - interactive list, read Markdown with glamour
10
+
//
11
+
// TODO: Add TUI for confirming post
12
+
// - proofread post with glamour
13
+
//
14
// TODO: Repost - "Reblog" - post to BlueSky
15
package handlers
16
···
290
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
291
}
292
0
0
0
0
0
0
0
0
0
0
0
0
0
0
293
// TODO: Implement image handling for markdown conversion
294
// 1. Extract note's directory from filepath/database
295
// 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
296
// 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
297
// This will upload images to AT Protocol and get real CIDs/dimensions
298
+
note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false)
0
299
if err != nil {
300
+
return err
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
301
}
302
303
ui.Infoln("Creating document '%s' on leaflet...", note.Title)
304
305
+
result, err := h.atproto.PostDocument(ctx, *doc, isDraft)
306
if err != nil {
307
return fmt.Errorf("failed to post document: %w", err)
308
}
···
339
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
340
}
341
342
+
tempNote, err := h.repos.Notes.Get(ctx, noteID)
343
if err != nil {
344
return fmt.Errorf("failed to get note: %w", err)
345
}
346
0
0
0
0
0
0
0
0
0
347
// TODO: Implement image handling for markdown conversion (same as Post method)
348
// 1. Extract note's directory from filepath/database
349
// 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob()
350
// 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet()
351
// This will upload images to AT Protocol and get real CIDs/dimensions
352
+
note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true)
0
353
if err != nil {
354
+
return err
355
}
356
357
+
// Update note.PublishedAt if we set a new timestamp
358
+
if !note.IsDraft && note.PublishedAt == nil && doc.PublishedAt != "" {
359
+
publishedAt, err := time.Parse(time.RFC3339, doc.PublishedAt)
360
+
if err == nil {
361
+
note.PublishedAt = &publishedAt
362
+
}
0
0
0
0
0
0
0
0
0
0
0
0
363
}
364
365
ui.Infoln("Updating document '%s' on leaflet...", note.Title)
366
367
+
result, err := h.atproto.PatchDocument(ctx, *note.LeafletRKey, *doc, note.IsDraft)
368
if err != nil {
369
return fmt.Errorf("failed to patch document: %w", err)
370
}
···
414
}
415
416
ui.Successln("Document deleted successfully!")
417
+
418
+
return nil
419
+
}
420
+
421
+
// prepareDocumentForPublish prepares a note for publication by converting to Leaflet format
422
+
func (h *PublicationHandler) prepareDocumentForPublish(ctx context.Context, noteID int64, isDraft bool, forPatch bool) (*models.Note, *public.Document, error) {
423
+
note, err := h.repos.Notes.Get(ctx, noteID)
424
+
if err != nil {
425
+
return nil, nil, fmt.Errorf("failed to get note: %w", err)
426
+
}
427
+
428
+
if !forPatch && note.HasLeafletAssociation() {
429
+
return nil, nil, fmt.Errorf("note already published - use patch to update")
430
+
}
431
+
432
+
if forPatch && !note.HasLeafletAssociation() {
433
+
return nil, nil, fmt.Errorf("note not published - use post to create")
434
+
}
435
+
436
+
session, err := h.atproto.GetSession()
437
+
if err != nil {
438
+
return nil, nil, fmt.Errorf("failed to get session: %w", err)
439
+
}
440
+
441
+
converter := public.NewMarkdownConverter()
442
+
blocks, err := converter.ToLeaflet(note.Content)
443
+
if err != nil {
444
+
return nil, nil, fmt.Errorf("failed to convert markdown to leaflet format: %w", err)
445
+
}
446
+
447
+
doc := &public.Document{
448
+
Author: session.DID,
449
+
Title: note.Title,
450
+
Description: "",
451
+
Pages: []public.LinearDocument{
452
+
{
453
+
Type: public.TypeLinearDocument,
454
+
Blocks: blocks,
455
+
},
456
+
},
457
+
}
458
+
459
+
if !isDraft {
460
+
if forPatch && note.PublishedAt != nil {
461
+
doc.PublishedAt = note.PublishedAt.Format(time.RFC3339)
462
+
} else {
463
+
now := time.Now()
464
+
doc.PublishedAt = now.Format(time.RFC3339)
465
+
}
466
+
}
467
+
468
+
return note, doc, nil
469
+
}
470
+
471
+
// PostPreview shows what would be posted without actually posting
472
+
func (h *PublicationHandler) PostPreview(ctx context.Context, noteID int64, isDraft bool) error {
473
+
if !h.atproto.IsAuthenticated() {
474
+
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
475
+
}
476
+
477
+
note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false)
478
+
if err != nil {
479
+
return err
480
+
}
481
+
482
+
status := "published"
483
+
if isDraft {
484
+
status = "draft"
485
+
}
486
+
487
+
ui.Infoln("Preview: Would create document on leaflet")
488
+
ui.Infoln(" Title: %s", doc.Title)
489
+
ui.Infoln(" Status: %s", status)
490
+
ui.Infoln(" Pages: %d", len(doc.Pages))
491
+
ui.Infoln(" Blocks: %d", len(doc.Pages[0].Blocks))
492
+
if doc.PublishedAt != "" {
493
+
ui.Infoln(" PublishedAt: %s", doc.PublishedAt)
494
+
}
495
+
ui.Infoln(" Note ID: %d", note.ID)
496
+
ui.Successln("Preview complete - no changes made")
497
+
498
+
return nil
499
+
}
500
+
501
+
// PostValidate validates markdown conversion without posting
502
+
func (h *PublicationHandler) PostValidate(ctx context.Context, noteID int64, isDraft bool) error {
503
+
if !h.atproto.IsAuthenticated() {
504
+
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
505
+
}
506
+
507
+
note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false)
508
+
if err != nil {
509
+
return err
510
+
}
511
+
512
+
ui.Infoln("Validating markdown conversion for note %d...", note.ID)
513
+
ui.Successln("Validation successful!")
514
+
ui.Infoln(" Title: %s", doc.Title)
515
+
ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks))
516
+
517
+
return nil
518
+
}
519
+
520
+
// PatchPreview shows what would be patched without actually patching
521
+
func (h *PublicationHandler) PatchPreview(ctx context.Context, noteID int64) error {
522
+
if !h.atproto.IsAuthenticated() {
523
+
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
524
+
}
525
+
526
+
tempNote, err := h.repos.Notes.Get(ctx, noteID)
527
+
if err != nil {
528
+
return fmt.Errorf("failed to get note: %w", err)
529
+
}
530
+
531
+
note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true)
532
+
if err != nil {
533
+
return err
534
+
}
535
+
536
+
status := "published"
537
+
if note.IsDraft {
538
+
status = "draft"
539
+
}
540
+
541
+
ui.Infoln("Preview: Would update document on leaflet")
542
+
ui.Infoln(" Title: %s", doc.Title)
543
+
ui.Infoln(" Status: %s", status)
544
+
ui.Infoln(" RKey: %s", *note.LeafletRKey)
545
+
ui.Infoln(" Pages: %d", len(doc.Pages))
546
+
ui.Infoln(" Blocks: %d", len(doc.Pages[0].Blocks))
547
+
if doc.PublishedAt != "" {
548
+
ui.Infoln(" PublishedAt: %s", doc.PublishedAt)
549
+
}
550
+
ui.Successln("Preview complete - no changes made")
551
+
552
+
return nil
553
+
}
554
+
555
+
// PatchValidate validates markdown conversion without patching
556
+
func (h *PublicationHandler) PatchValidate(ctx context.Context, noteID int64) error {
557
+
if !h.atproto.IsAuthenticated() {
558
+
return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
559
+
}
560
+
561
+
tempNote, err := h.repos.Notes.Get(ctx, noteID)
562
+
if err != nil {
563
+
return fmt.Errorf("failed to get note: %w", err)
564
+
}
565
+
566
+
note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true)
567
+
if err != nil {
568
+
return err
569
+
}
570
+
571
+
ui.Infoln("Validating markdown conversion for note %d...", note.ID)
572
+
ui.Successln("Validation successful!")
573
+
ui.Infoln(" Title: %s", doc.Title)
574
+
ui.Infoln(" RKey: %s", *note.LeafletRKey)
575
+
ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks))
576
577
return nil
578
}
+406
internal/handlers/publication_test.go
···
1010
})
1011
})
1012
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1013
t.Run("Delete", func(t *testing.T) {
1014
t.Run("returns error when not authenticated", func(t *testing.T) {
1015
suite := NewHandlerTestSuite(t)
···
1010
})
1011
})
1012
1013
+
t.Run("PostPreview", func(t *testing.T) {
1014
+
t.Run("returns error when not authenticated", func(t *testing.T) {
1015
+
suite := NewHandlerTestSuite(t)
1016
+
defer suite.Cleanup()
1017
+
1018
+
handler := CreateHandler(t, NewPublicationHandler)
1019
+
ctx := context.Background()
1020
+
1021
+
err := handler.PostPreview(ctx, 1, false)
1022
+
if err == nil {
1023
+
t.Error("Expected error when not authenticated")
1024
+
}
1025
+
1026
+
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
1027
+
t.Errorf("Expected 'not authenticated' error, got '%v'", err)
1028
+
}
1029
+
})
1030
+
1031
+
t.Run("returns error when note does not exist", func(t *testing.T) {
1032
+
suite := NewHandlerTestSuite(t)
1033
+
defer suite.Cleanup()
1034
+
1035
+
handler := CreateHandler(t, NewPublicationHandler)
1036
+
ctx := context.Background()
1037
+
1038
+
session := &services.Session{
1039
+
DID: "did:plc:test123",
1040
+
Handle: "test.bsky.social",
1041
+
AccessJWT: "access_token",
1042
+
RefreshJWT: "refresh_token",
1043
+
PDSURL: "https://bsky.social",
1044
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1045
+
Authenticated: true,
1046
+
}
1047
+
1048
+
err := handler.atproto.RestoreSession(session)
1049
+
if err != nil {
1050
+
t.Fatalf("Failed to restore session: %v", err)
1051
+
}
1052
+
1053
+
err = handler.PostPreview(ctx, 999, false)
1054
+
if err == nil {
1055
+
t.Error("Expected error when note does not exist")
1056
+
}
1057
+
1058
+
if err != nil && !strings.Contains(err.Error(), "failed to get note") {
1059
+
t.Errorf("Expected 'failed to get note' error, got '%v'", err)
1060
+
}
1061
+
})
1062
+
1063
+
t.Run("returns error when note already published", func(t *testing.T) {
1064
+
suite := NewHandlerTestSuite(t)
1065
+
defer suite.Cleanup()
1066
+
1067
+
handler := CreateHandler(t, NewPublicationHandler)
1068
+
ctx := context.Background()
1069
+
1070
+
rkey := "existing_rkey"
1071
+
cid := "existing_cid"
1072
+
note := &models.Note{
1073
+
Title: "Already Published",
1074
+
Content: "# Test content",
1075
+
LeafletRKey: &rkey,
1076
+
LeafletCID: &cid,
1077
+
}
1078
+
1079
+
id, err := handler.repos.Notes.Create(ctx, note)
1080
+
suite.AssertNoError(err, "create note")
1081
+
1082
+
session := &services.Session{
1083
+
DID: "did:plc:test123",
1084
+
Handle: "test.bsky.social",
1085
+
AccessJWT: "access_token",
1086
+
RefreshJWT: "refresh_token",
1087
+
PDSURL: "https://bsky.social",
1088
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1089
+
Authenticated: true,
1090
+
}
1091
+
1092
+
err = handler.atproto.RestoreSession(session)
1093
+
if err != nil {
1094
+
t.Fatalf("Failed to restore session: %v", err)
1095
+
}
1096
+
1097
+
err = handler.PostPreview(ctx, id, false)
1098
+
if err == nil {
1099
+
t.Error("Expected error when note already published")
1100
+
}
1101
+
1102
+
if err != nil && !strings.Contains(err.Error(), "already published") {
1103
+
t.Errorf("Expected 'already published' error, got '%v'", err)
1104
+
}
1105
+
})
1106
+
1107
+
t.Run("shows preview for valid note", func(t *testing.T) {
1108
+
suite := NewHandlerTestSuite(t)
1109
+
defer suite.Cleanup()
1110
+
1111
+
handler := CreateHandler(t, NewPublicationHandler)
1112
+
ctx := context.Background()
1113
+
1114
+
note := &models.Note{
1115
+
Title: "Test Note",
1116
+
Content: "# Test content\n\nThis is a test.",
1117
+
}
1118
+
1119
+
id, err := handler.repos.Notes.Create(ctx, note)
1120
+
suite.AssertNoError(err, "create note")
1121
+
1122
+
session := &services.Session{
1123
+
DID: "did:plc:test123",
1124
+
Handle: "test.bsky.social",
1125
+
AccessJWT: "access_token",
1126
+
RefreshJWT: "refresh_token",
1127
+
PDSURL: "https://bsky.social",
1128
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1129
+
Authenticated: true,
1130
+
}
1131
+
1132
+
err = handler.atproto.RestoreSession(session)
1133
+
if err != nil {
1134
+
t.Fatalf("Failed to restore session: %v", err)
1135
+
}
1136
+
1137
+
err = handler.PostPreview(ctx, id, false)
1138
+
suite.AssertNoError(err, "preview should succeed")
1139
+
})
1140
+
1141
+
t.Run("shows preview for draft", func(t *testing.T) {
1142
+
suite := NewHandlerTestSuite(t)
1143
+
defer suite.Cleanup()
1144
+
1145
+
handler := CreateHandler(t, NewPublicationHandler)
1146
+
ctx := context.Background()
1147
+
1148
+
note := &models.Note{
1149
+
Title: "Draft Note",
1150
+
Content: "# Draft content",
1151
+
}
1152
+
1153
+
id, err := handler.repos.Notes.Create(ctx, note)
1154
+
suite.AssertNoError(err, "create note")
1155
+
1156
+
session := &services.Session{
1157
+
DID: "did:plc:test123",
1158
+
Handle: "test.bsky.social",
1159
+
AccessJWT: "access_token",
1160
+
RefreshJWT: "refresh_token",
1161
+
PDSURL: "https://bsky.social",
1162
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1163
+
Authenticated: true,
1164
+
}
1165
+
1166
+
err = handler.atproto.RestoreSession(session)
1167
+
if err != nil {
1168
+
t.Fatalf("Failed to restore session: %v", err)
1169
+
}
1170
+
1171
+
err = handler.PostPreview(ctx, id, true)
1172
+
suite.AssertNoError(err, "preview draft should succeed")
1173
+
})
1174
+
})
1175
+
1176
+
t.Run("PostValidate", func(t *testing.T) {
1177
+
t.Run("returns error when not authenticated", func(t *testing.T) {
1178
+
suite := NewHandlerTestSuite(t)
1179
+
defer suite.Cleanup()
1180
+
1181
+
handler := CreateHandler(t, NewPublicationHandler)
1182
+
ctx := context.Background()
1183
+
1184
+
err := handler.PostValidate(ctx, 1, false)
1185
+
if err == nil {
1186
+
t.Error("Expected error when not authenticated")
1187
+
}
1188
+
1189
+
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
1190
+
t.Errorf("Expected 'not authenticated' error, got '%v'", err)
1191
+
}
1192
+
})
1193
+
1194
+
t.Run("validates markdown conversion successfully", func(t *testing.T) {
1195
+
suite := NewHandlerTestSuite(t)
1196
+
defer suite.Cleanup()
1197
+
1198
+
handler := CreateHandler(t, NewPublicationHandler)
1199
+
ctx := context.Background()
1200
+
1201
+
note := &models.Note{
1202
+
Title: "Test Note",
1203
+
Content: "# Test content\n\nValid markdown here.",
1204
+
}
1205
+
1206
+
id, err := handler.repos.Notes.Create(ctx, note)
1207
+
suite.AssertNoError(err, "create note")
1208
+
1209
+
session := &services.Session{
1210
+
DID: "did:plc:test123",
1211
+
Handle: "test.bsky.social",
1212
+
AccessJWT: "access_token",
1213
+
RefreshJWT: "refresh_token",
1214
+
PDSURL: "https://bsky.social",
1215
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1216
+
Authenticated: true,
1217
+
}
1218
+
1219
+
err = handler.atproto.RestoreSession(session)
1220
+
if err != nil {
1221
+
t.Fatalf("Failed to restore session: %v", err)
1222
+
}
1223
+
1224
+
err = handler.PostValidate(ctx, id, false)
1225
+
suite.AssertNoError(err, "validation should succeed")
1226
+
})
1227
+
})
1228
+
1229
+
t.Run("PatchPreview", func(t *testing.T) {
1230
+
t.Run("returns error when not authenticated", func(t *testing.T) {
1231
+
suite := NewHandlerTestSuite(t)
1232
+
defer suite.Cleanup()
1233
+
1234
+
handler := CreateHandler(t, NewPublicationHandler)
1235
+
ctx := context.Background()
1236
+
1237
+
err := handler.PatchPreview(ctx, 1)
1238
+
if err == nil {
1239
+
t.Error("Expected error when not authenticated")
1240
+
}
1241
+
1242
+
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
1243
+
t.Errorf("Expected 'not authenticated' error, got '%v'", err)
1244
+
}
1245
+
})
1246
+
1247
+
t.Run("returns error when note does not exist", func(t *testing.T) {
1248
+
suite := NewHandlerTestSuite(t)
1249
+
defer suite.Cleanup()
1250
+
1251
+
handler := CreateHandler(t, NewPublicationHandler)
1252
+
ctx := context.Background()
1253
+
1254
+
session := &services.Session{
1255
+
DID: "did:plc:test123",
1256
+
Handle: "test.bsky.social",
1257
+
AccessJWT: "access_token",
1258
+
RefreshJWT: "refresh_token",
1259
+
PDSURL: "https://bsky.social",
1260
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1261
+
Authenticated: true,
1262
+
}
1263
+
1264
+
err := handler.atproto.RestoreSession(session)
1265
+
if err != nil {
1266
+
t.Fatalf("Failed to restore session: %v", err)
1267
+
}
1268
+
1269
+
err = handler.PatchPreview(ctx, 999)
1270
+
if err == nil {
1271
+
t.Error("Expected error when note does not exist")
1272
+
}
1273
+
1274
+
if err != nil && !strings.Contains(err.Error(), "failed to get note") {
1275
+
t.Errorf("Expected 'failed to get note' error, got '%v'", err)
1276
+
}
1277
+
})
1278
+
1279
+
t.Run("returns error when note not published", func(t *testing.T) {
1280
+
suite := NewHandlerTestSuite(t)
1281
+
defer suite.Cleanup()
1282
+
1283
+
handler := CreateHandler(t, NewPublicationHandler)
1284
+
ctx := context.Background()
1285
+
1286
+
note := &models.Note{
1287
+
Title: "Not Published",
1288
+
Content: "# Test content",
1289
+
}
1290
+
1291
+
id, err := handler.repos.Notes.Create(ctx, note)
1292
+
suite.AssertNoError(err, "create note")
1293
+
1294
+
session := &services.Session{
1295
+
DID: "did:plc:test123",
1296
+
Handle: "test.bsky.social",
1297
+
AccessJWT: "access_token",
1298
+
RefreshJWT: "refresh_token",
1299
+
PDSURL: "https://bsky.social",
1300
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1301
+
Authenticated: true,
1302
+
}
1303
+
1304
+
err = handler.atproto.RestoreSession(session)
1305
+
if err != nil {
1306
+
t.Fatalf("Failed to restore session: %v", err)
1307
+
}
1308
+
1309
+
err = handler.PatchPreview(ctx, id)
1310
+
if err == nil {
1311
+
t.Error("Expected error when note not published")
1312
+
}
1313
+
1314
+
if err != nil && !strings.Contains(err.Error(), "not published") {
1315
+
t.Errorf("Expected 'not published' error, got '%v'", err)
1316
+
}
1317
+
})
1318
+
1319
+
t.Run("shows preview for published note", func(t *testing.T) {
1320
+
suite := NewHandlerTestSuite(t)
1321
+
defer suite.Cleanup()
1322
+
1323
+
handler := CreateHandler(t, NewPublicationHandler)
1324
+
ctx := context.Background()
1325
+
1326
+
rkey := "test_rkey"
1327
+
cid := "test_cid"
1328
+
publishedAt := time.Now().Add(-24 * time.Hour)
1329
+
note := &models.Note{
1330
+
Title: "Published Note",
1331
+
Content: "# Updated content",
1332
+
LeafletRKey: &rkey,
1333
+
LeafletCID: &cid,
1334
+
PublishedAt: &publishedAt,
1335
+
IsDraft: false,
1336
+
}
1337
+
1338
+
id, err := handler.repos.Notes.Create(ctx, note)
1339
+
suite.AssertNoError(err, "create note")
1340
+
1341
+
session := &services.Session{
1342
+
DID: "did:plc:test123",
1343
+
Handle: "test.bsky.social",
1344
+
AccessJWT: "access_token",
1345
+
RefreshJWT: "refresh_token",
1346
+
PDSURL: "https://bsky.social",
1347
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1348
+
Authenticated: true,
1349
+
}
1350
+
1351
+
err = handler.atproto.RestoreSession(session)
1352
+
if err != nil {
1353
+
t.Fatalf("Failed to restore session: %v", err)
1354
+
}
1355
+
1356
+
err = handler.PatchPreview(ctx, id)
1357
+
suite.AssertNoError(err, "preview should succeed")
1358
+
})
1359
+
})
1360
+
1361
+
t.Run("PatchValidate", func(t *testing.T) {
1362
+
t.Run("returns error when not authenticated", func(t *testing.T) {
1363
+
suite := NewHandlerTestSuite(t)
1364
+
defer suite.Cleanup()
1365
+
1366
+
handler := CreateHandler(t, NewPublicationHandler)
1367
+
ctx := context.Background()
1368
+
1369
+
err := handler.PatchValidate(ctx, 1)
1370
+
if err == nil {
1371
+
t.Error("Expected error when not authenticated")
1372
+
}
1373
+
1374
+
if err != nil && !strings.Contains(err.Error(), "not authenticated") {
1375
+
t.Errorf("Expected 'not authenticated' error, got '%v'", err)
1376
+
}
1377
+
})
1378
+
1379
+
t.Run("validates markdown conversion successfully", func(t *testing.T) {
1380
+
suite := NewHandlerTestSuite(t)
1381
+
defer suite.Cleanup()
1382
+
1383
+
handler := CreateHandler(t, NewPublicationHandler)
1384
+
ctx := context.Background()
1385
+
1386
+
rkey := "test_rkey"
1387
+
cid := "test_cid"
1388
+
note := &models.Note{
1389
+
Title: "Published Note",
1390
+
Content: "# Updated content\n\nValid markdown here.",
1391
+
LeafletRKey: &rkey,
1392
+
LeafletCID: &cid,
1393
+
IsDraft: false,
1394
+
}
1395
+
1396
+
id, err := handler.repos.Notes.Create(ctx, note)
1397
+
suite.AssertNoError(err, "create note")
1398
+
1399
+
session := &services.Session{
1400
+
DID: "did:plc:test123",
1401
+
Handle: "test.bsky.social",
1402
+
AccessJWT: "access_token",
1403
+
RefreshJWT: "refresh_token",
1404
+
PDSURL: "https://bsky.social",
1405
+
ExpiresAt: time.Now().Add(2 * time.Hour),
1406
+
Authenticated: true,
1407
+
}
1408
+
1409
+
err = handler.atproto.RestoreSession(session)
1410
+
if err != nil {
1411
+
t.Fatalf("Failed to restore session: %v", err)
1412
+
}
1413
+
1414
+
err = handler.PatchValidate(ctx, id)
1415
+
suite.AssertNoError(err, "validation should succeed")
1416
+
})
1417
+
})
1418
+
1419
t.Run("Delete", func(t *testing.T) {
1420
t.Run("returns error when not authenticated", func(t *testing.T) {
1421
suite := NewHandlerTestSuite(t)