Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview/strings: multi-file support

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jun 17, 2026, 12:31 AM +0900) commit 262439d4 parent 71588c93 change-id qnzovvxp
+78 -49
-2
appview/pages/templates/strings/fragments/formBody.html
··· 23 23 <div class="flex justify-between items-center"> 24 24 <div id="error"></div> 25 25 <div class="flex gap-2"> 26 - <!-- TODO: accept multiple files in a string 27 26 <button 28 27 type="button" 29 28 class="w-fit btn rounded py-0" ··· 37 36 Add file 38 37 </span> 39 38 </button> 40 - --> 41 39 <button 42 40 type="submit" 43 41 class="w-fit btn-create group"
+78 -47
appview/strings/strings.go
··· 12 12 "net/http" 13 13 "strconv" 14 14 "strings" 15 + "sync" 15 16 "time" 16 17 18 + "golang.org/x/sync/errgroup" 17 19 "tangled.org/core/api/tangled" 18 20 "tangled.org/core/appview/db" 19 21 "tangled.org/core/appview/middleware" ··· 349 351 description = &val 350 352 } 351 353 352 - filename := r.FormValue("filename") 353 - if filename == "" { 354 - fail("Empty filename.", nil) 355 - return 356 - } 357 - 358 - content := r.FormValue("content") 359 - if content == "" { 360 - fail("Empty content.", nil) 354 + fileNames := r.Form["filename"] 355 + fileContents := r.Form["content"] 356 + if len(fileNames) != len(fileContents) { 357 + s.Pages.Notice(w, "error", "mismatched file name & content lengths") 361 358 return 362 359 } 363 360 364 361 client, err := s.OAuth.AuthorizedClient(r) 365 362 if err != nil { 366 - fail("Failed to create record.", err) 363 + l.Error("failed to get authorized client", "err", err) 364 + s.Pages.Notice(w, "error", "Failed to create string.") 367 365 return 368 366 } 369 367 370 - blob, err := xrpc.RepoUploadBlob(ctx, client, strings.NewReader(content), textPlain) 371 - if err != nil { 372 - fail("Failed to create record.", err) 368 + files := make([]models.String_File, len(fileNames)) 369 + var mu sync.Mutex 370 + 371 + g, gctx := errgroup.WithContext(ctx) 372 + g.SetLimit(10) 373 + for i := range len(files) { 374 + g.Go(func() error { 375 + blob, err := xrpc.RepoUploadBlob(gctx, client, strings.NewReader(fileContents[i]), textPlain) 376 + if err != nil { 377 + return err 378 + } 379 + if blob.Blob == nil { 380 + return fmt.Errorf("uploadBlob: blob is missing from response") 381 + } 382 + 383 + mu.Lock() 384 + files[i] = models.String_File{ 385 + Name: fileNames[i], 386 + Content: *blob.Blob, 387 + } 388 + mu.Unlock() 389 + return nil 390 + }) 391 + } 392 + if err := g.Wait(); err != nil { 393 + l.Warn("failed to upload blobs", "err", err) 394 + s.Pages.Notice(w, "error", "Failed to upload blobs.") 373 395 return 374 396 } 375 397 376 398 newString := oldString 377 399 newString.Title = title 378 400 newString.Description = description 379 - newString.Files = []models.String_File{ 380 - { 381 - Name: filename, 382 - Content: *blob.Blob, 383 - }, 384 - } 401 + newString.Files = files 385 402 386 403 // first replace the existing record in the PDS 387 404 var exCid string ··· 414 431 415 432 // if that went okay, updated the db 416 433 if err = db.AddString(s.Db, newString); err != nil { 417 - fail("Failed to update string.", err) 418 - return 434 + l.Error("db: failed to update string", "err", err) 419 435 } 420 436 421 437 s.Notifier.EditString(ctx, &newString) ··· 440 456 l.Error("failed to render", "err", err) 441 457 } 442 458 case http.MethodPost: 443 - fail := func(msg string, err error) { 444 - l.Error(msg, "err", err) 445 - s.Pages.Notice(w, "error", msg) 459 + if err := r.ParseForm(); err != nil { 460 + s.Pages.Notice(w, "error", "bad request") 461 + return 446 462 } 447 463 448 464 var title *string ··· 455 471 description = &val 456 472 } 457 473 458 - filename := r.FormValue("filename") 459 - if filename == "" { 460 - fail("Empty filename.", nil) 461 - return 462 - } 463 - 464 - content := r.FormValue("content") 465 - if content == "" { 466 - fail("Empty content.", nil) 474 + fileNames := r.Form["filename"] 475 + fileContents := r.Form["content"] 476 + if len(fileNames) != len(fileContents) { 477 + s.Pages.Notice(w, "error", "mismatched file name & content lengths") 467 478 return 468 479 } 469 480 470 481 client, err := s.OAuth.AuthorizedClient(r) 471 482 if err != nil { 472 - fail("Failed to create record.", err) 483 + l.Error("failed to get authorized client", "err", err) 484 + s.Pages.Notice(w, "error", "Failed to create string.") 473 485 return 474 486 } 475 487 476 - blob, err := xrpc.RepoUploadBlob(ctx, client, strings.NewReader(content), textPlain) 477 - if err != nil { 478 - fail("Failed to create record.", err) 488 + files := make([]models.String_File, len(fileNames)) 489 + var mu sync.Mutex 490 + 491 + g, gctx := errgroup.WithContext(ctx) 492 + g.SetLimit(10) 493 + for i := range len(files) { 494 + g.Go(func() error { 495 + blob, err := xrpc.RepoUploadBlob(gctx, client, strings.NewReader(fileContents[i]), textPlain) 496 + if err != nil { 497 + return err 498 + } 499 + if blob.Blob == nil { 500 + return fmt.Errorf("uploadBlob: blob is missing from response") 501 + } 502 + 503 + mu.Lock() 504 + files[i] = models.String_File{ 505 + Name: fileNames[i], 506 + Content: *blob.Blob, 507 + } 508 + mu.Unlock() 509 + return nil 510 + }) 511 + } 512 + if err := g.Wait(); err != nil { 513 + l.Warn("failed to upload blobs", "err", err) 514 + s.Pages.Notice(w, "error", "Failed to upload blobs.") 479 515 return 480 516 } 481 517 ··· 484 520 Rkey: syntax.RecordKey(tid.TID()), 485 521 Title: title, 486 522 Description: description, 487 - Files: []models.String_File{ 488 - { 489 - Name: filename, 490 - Content: *blob.Blob, 491 - }, 492 - }, 493 - Created: time.Now(), 523 + Files: files, 524 + Created: time.Now(), 494 525 } 495 526 496 527 resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ ··· 500 531 Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 501 532 }) 502 533 if err != nil { 503 - fail("Failed to create record.", err) 534 + l.Error("failed to create string", "err", err) 535 + s.Pages.Notice(w, "error", "Failed to create string.") 504 536 return 505 537 } 506 538 l := l.With("aturi", resp.Uri) ··· 508 540 509 541 // insert into DB 510 542 if err = db.AddString(s.Db, newString); err != nil { 511 - fail("Failed to create string.", err) 512 - return 543 + l.Error("db: failed to insert string", "err", err) 513 544 } 514 545 515 546 s.Notifier.NewString(ctx, &newString)