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