Fossil SCM

/attachaddV2 now handle multiple attachments concurrently.

stephan 2026-06-03 12:00 UTC attach-v2
Commit c499f7f59d8287cd21aa5f4681a4634476030c006fbf765a02eb63c4eecefe12
3 files changed +167 -10 +5 -5 +127 -28
+167 -10
--- src/attach.c
+++ src/attach.c
@@ -33,14 +33,15 @@
3333
** the only one which attachments should target.
3434
*/
3535
int attachment_target_type(const char *zTarget){
3636
static Stmt q = empty_Stmt_m;
3737
int rc = 0;
38
- if( forumpost_head_rid2(zTarget)>0 ){
38
+ if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
39
+ return 0;
40
+ }else if( forumpost_head_rid2(zTarget)>0 ){
3941
return CFTYPE_FORUM;
40
- }
41
- if( !q.pStmt ){
42
+ }else if( !q.pStmt ){
4243
db_static_prepare(
4344
&q,
4445
"SELECT CASE "
4546
"WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
4647
"WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
@@ -378,15 +379,17 @@
378379
}
379380
zName += n;
380381
if( zName[0]==0 ) zName = "unknown";
381382
blob_appendf(&manifest, "A %F%s %F %s\n",
382383
zName, addCompress ? ".gz" : "", zTarget, zUUID);
383
- while( fossil_isspace(zComment[0]) ) zComment++;
384
- n = strlen(zComment);
385
- while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
386
- if( n>0 ){
387
- blob_appendf(&manifest, "C %#F\n", n, zComment);
384
+ if( zComment!=0 && zComment[0]!=0 ){
385
+ while( fossil_isspace(zComment[0]) ) zComment++;
386
+ n = strlen(zComment);
387
+ while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
388
+ if( n>0 ){
389
+ blob_appendf(&manifest, "C %#F\n", n, zComment);
390
+ }
388391
}
389392
zDate = date_in_standard_format("now");
390393
blob_appendf(&manifest, "D %s\n", zDate);
391394
blob_appendf(&manifest, "U %F\n", login_name());
392395
md5sum_blob(&manifest, &cksum);
@@ -537,23 +540,177 @@
537540
builtin_fossil_js_bundle_or("attach", NULL);
538541
style_finish_page();
539542
fossil_free(zTargetType);
540543
fossil_free(zExtraFree);
541544
}
545
+
546
+/*
547
+** WEBPAGE: attachaddV2_ajax_post hidden
548
+**
549
+** Requires a POST request with:
550
+**
551
+** target=ATTACHMENT_TARGET
552
+** file1..fileN=FILE_OBJECTS
553
+** dryrun=0|1
554
+**
555
+** Each posted file in the set file1..fileN gets attached to the
556
+** given target, permissions permitting. If dryrun>0 then the change
557
+** is rolled back instead of committed.
558
+**
559
+** Responds with JSON: an empty object on success and
560
+** {error:"message"} on error. The on-success response structure is
561
+** subject to amendment.
562
+*/
563
+void attachaddV2_ajax_post(void){
564
+ const char *zTarget = P("target");
565
+ char *zExtraFree = 0;
566
+ int iTgtType = 0;
567
+ int bNeedsModeration = 0;
568
+ int i;
569
+ int goodCaptcha = 1;
570
+ int szLimit; /* attachment-max-size setting */
571
+ int bRollback = 0; /* Roll back if true. */
572
+ char aKeyPrefix[20]; /* Buffer for key "file%d" */
573
+ char aKeySize[30]; /* Buffer for key "file%d:bytes" */
574
+ char aKeyName[30]; /* Buffer for key "file%d:filename" */
575
+ char aKeyDesc[30]; /* Buffer for key "file%d_desc" */
576
+ if( ! ajax_route_bootstrap(0, 1) ){
577
+ return;
578
+ }else if( !(goodCaptcha = captcha_is_correct(0)) ){
579
+ goto ajax_post_403;
580
+ }
581
+ db_begin_transaction();
582
+ iTgtType = attachment_target_type(zTarget);
583
+ switch( iTgtType ){
584
+ default:
585
+ case 0:
586
+ ajax_route_error(400, "Invalid attachment target.");
587
+ db_rollback_transaction();
588
+ return;
589
+ case CFTYPE_FORUM:{
590
+ int fpid;
591
+ if( g.perm.AttachForum==0 ){
592
+ goto ajax_post_403;
593
+ }
594
+ fpid = forumpost_head_rid2(zTarget);
595
+ if( fpid<=0 ){
596
+ goto ajax_post_404;
597
+ }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
598
+ ajax_route_error(403, "Only admins can attach files to "
599
+ "other users' forum posts.");
600
+ db_rollback_transaction();
601
+ return;
602
+ }
603
+ zTarget = zExtraFree = rid_to_uuid(fpid);
604
+ bNeedsModeration = forum_need_moderation();
605
+ break;
606
+ }
607
+ case CFTYPE_EVENT:{
608
+ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
609
+ goto ajax_post_403;
610
+ }
611
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'", zTarget) ){
612
+ zTarget = zExtraFree =
613
+ db_text(0, "SELECT substr(tagname,7) FROM tag"
614
+ " WHERE tagname GLOB 'event-%q*'", zTarget);
615
+ if( zTarget==0){
616
+ goto ajax_post_404;
617
+ }
618
+ }
619
+ bNeedsModeration = 0;
620
+ break;
621
+ }
622
+ case CFTYPE_TICKET:{
623
+ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
624
+ goto ajax_post_403;
625
+ }
626
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'", zTarget) ){
627
+ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
628
+ " WHERE tagname GLOB 'tkt-%q*'", zTarget);
629
+ if( zTarget==0 ){
630
+ goto ajax_post_404;
631
+ }
632
+ }
633
+ bNeedsModeration = ticket_need_moderation(0);
634
+ break;
635
+ }
636
+ case CFTYPE_WIKI:{
637
+ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
638
+ goto ajax_post_403;
639
+ }
640
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zTarget) ){
641
+ goto ajax_post_404;
642
+ }
643
+ bNeedsModeration = wiki_need_moderation(0);
644
+ break;
645
+ }
646
+ }
647
+
648
+ szLimit = db_get_int("attachment-size-limit", 0);
649
+ for(i = 1; !bRollback; ++i){
650
+ /* Look for P("fileN"), where N=1..n */
651
+ const char *zContent;
652
+ int szContent;
653
+ sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i);
654
+ zContent = P(aKeyPrefix);
655
+ if( !zContent ){
656
+ /* End of the list. */
657
+ break;
658
+ }
659
+ sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes",
660
+ aKeyPrefix);
661
+ szContent = atoi(PD(aKeySize,"-1"));
662
+ if( szContent<0 ){
663
+ bRollback = 1;
664
+ ajax_route_error(400,"Invalid file size.");
665
+ }else if( szLimit>0 && szContent>szLimit ){
666
+ bRollback = 1;
667
+ ajax_route_error(400, "File size limit is %d bytes.", szLimit);
668
+ break;
669
+ }else{
670
+ sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
671
+ aKeyPrefix);
672
+ sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
673
+ aKeyPrefix);
674
+ attach_commit(P(aKeyName), zTarget, zContent, szContent,
675
+ bNeedsModeration, P(aKeyDesc));
676
+ }
677
+ }
678
+ fossil_free(zExtraFree);
679
+ if( !bRollback ){
680
+ CX("{}");
681
+ if( atoi(PD("dryrun","0"))>0 ){
682
+ bRollback = 1;
683
+ }
684
+ }
685
+ db_end_transaction(bRollback);
686
+ return;
687
+ajax_post_403:
688
+ db_rollback_transaction();
689
+ ajax_route_error(403, "Permission denied.");
690
+ return;
691
+ajax_post_404:
692
+ db_rollback_transaction();
693
+ ajax_route_error(404, "Target not found.");
694
+ return;
695
+}
542696
543697
/*
544698
** WEBPAGE: attachaddV2 hidden
545699
** Add a new attachment.
546700
**
547701
** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
548702
** from=URL
549703
**
704
+** Works like /attachadd but uses a JS-based interactive attachment
705
+** selector.
706
+**
550707
*/
551708
void attachaddV2_page(void){
552709
const char *zFrom = P("from");
553710
const char *zTarget = P("target");
554
- char * zTo = 0;
711
+ char *zTo = 0;
555712
char *zTargetType = 0;
556713
char *zExtraFree = 0;
557714
int iTgtType = 0;
558715
int szContent = 0;
559716
int goodCaptcha = 1;
@@ -646,11 +803,11 @@
646803
@ <a href="%R/help/attachment-size-limit">Limit</a> is
647804
@ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
648805
/* Fall through and render form. */
649806
}else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
650807
#if 0
651
- attach_commit(zName, zTarget, aContent, szContent, zMimetype,
808
+ attach_commit(zName, zTarget, aContent, szContent,
652809
bNeedsModeration, zComment);
653810
#endif
654811
cgi_redirect(zTo ? zTo : zFrom);
655812
}
656813
#else
657814
--- src/attach.c
+++ src/attach.c
@@ -33,14 +33,15 @@
33 ** the only one which attachments should target.
34 */
35 int attachment_target_type(const char *zTarget){
36 static Stmt q = empty_Stmt_m;
37 int rc = 0;
38 if( forumpost_head_rid2(zTarget)>0 ){
 
 
39 return CFTYPE_FORUM;
40 }
41 if( !q.pStmt ){
42 db_static_prepare(
43 &q,
44 "SELECT CASE "
45 "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
46 "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
@@ -378,15 +379,17 @@
378 }
379 zName += n;
380 if( zName[0]==0 ) zName = "unknown";
381 blob_appendf(&manifest, "A %F%s %F %s\n",
382 zName, addCompress ? ".gz" : "", zTarget, zUUID);
383 while( fossil_isspace(zComment[0]) ) zComment++;
384 n = strlen(zComment);
385 while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
386 if( n>0 ){
387 blob_appendf(&manifest, "C %#F\n", n, zComment);
 
 
388 }
389 zDate = date_in_standard_format("now");
390 blob_appendf(&manifest, "D %s\n", zDate);
391 blob_appendf(&manifest, "U %F\n", login_name());
392 md5sum_blob(&manifest, &cksum);
@@ -537,23 +540,177 @@
537 builtin_fossil_js_bundle_or("attach", NULL);
538 style_finish_page();
539 fossil_free(zTargetType);
540 fossil_free(zExtraFree);
541 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
543 /*
544 ** WEBPAGE: attachaddV2 hidden
545 ** Add a new attachment.
546 **
547 ** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
548 ** from=URL
549 **
 
 
 
550 */
551 void attachaddV2_page(void){
552 const char *zFrom = P("from");
553 const char *zTarget = P("target");
554 char * zTo = 0;
555 char *zTargetType = 0;
556 char *zExtraFree = 0;
557 int iTgtType = 0;
558 int szContent = 0;
559 int goodCaptcha = 1;
@@ -646,11 +803,11 @@
646 @ <a href="%R/help/attachment-size-limit">Limit</a> is
647 @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
648 /* Fall through and render form. */
649 }else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
650 #if 0
651 attach_commit(zName, zTarget, aContent, szContent, zMimetype,
652 bNeedsModeration, zComment);
653 #endif
654 cgi_redirect(zTo ? zTo : zFrom);
655 }
656 #else
657
--- src/attach.c
+++ src/attach.c
@@ -33,14 +33,15 @@
33 ** the only one which attachments should target.
34 */
35 int attachment_target_type(const char *zTarget){
36 static Stmt q = empty_Stmt_m;
37 int rc = 0;
38 if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
39 return 0;
40 }else if( forumpost_head_rid2(zTarget)>0 ){
41 return CFTYPE_FORUM;
42 }else if( !q.pStmt ){
 
43 db_static_prepare(
44 &q,
45 "SELECT CASE "
46 "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
47 "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
@@ -378,15 +379,17 @@
379 }
380 zName += n;
381 if( zName[0]==0 ) zName = "unknown";
382 blob_appendf(&manifest, "A %F%s %F %s\n",
383 zName, addCompress ? ".gz" : "", zTarget, zUUID);
384 if( zComment!=0 && zComment[0]!=0 ){
385 while( fossil_isspace(zComment[0]) ) zComment++;
386 n = strlen(zComment);
387 while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
388 if( n>0 ){
389 blob_appendf(&manifest, "C %#F\n", n, zComment);
390 }
391 }
392 zDate = date_in_standard_format("now");
393 blob_appendf(&manifest, "D %s\n", zDate);
394 blob_appendf(&manifest, "U %F\n", login_name());
395 md5sum_blob(&manifest, &cksum);
@@ -537,23 +540,177 @@
540 builtin_fossil_js_bundle_or("attach", NULL);
541 style_finish_page();
542 fossil_free(zTargetType);
543 fossil_free(zExtraFree);
544 }
545
546 /*
547 ** WEBPAGE: attachaddV2_ajax_post hidden
548 **
549 ** Requires a POST request with:
550 **
551 ** target=ATTACHMENT_TARGET
552 ** file1..fileN=FILE_OBJECTS
553 ** dryrun=0|1
554 **
555 ** Each posted file in the set file1..fileN gets attached to the
556 ** given target, permissions permitting. If dryrun>0 then the change
557 ** is rolled back instead of committed.
558 **
559 ** Responds with JSON: an empty object on success and
560 ** {error:"message"} on error. The on-success response structure is
561 ** subject to amendment.
562 */
563 void attachaddV2_ajax_post(void){
564 const char *zTarget = P("target");
565 char *zExtraFree = 0;
566 int iTgtType = 0;
567 int bNeedsModeration = 0;
568 int i;
569 int goodCaptcha = 1;
570 int szLimit; /* attachment-max-size setting */
571 int bRollback = 0; /* Roll back if true. */
572 char aKeyPrefix[20]; /* Buffer for key "file%d" */
573 char aKeySize[30]; /* Buffer for key "file%d:bytes" */
574 char aKeyName[30]; /* Buffer for key "file%d:filename" */
575 char aKeyDesc[30]; /* Buffer for key "file%d_desc" */
576 if( ! ajax_route_bootstrap(0, 1) ){
577 return;
578 }else if( !(goodCaptcha = captcha_is_correct(0)) ){
579 goto ajax_post_403;
580 }
581 db_begin_transaction();
582 iTgtType = attachment_target_type(zTarget);
583 switch( iTgtType ){
584 default:
585 case 0:
586 ajax_route_error(400, "Invalid attachment target.");
587 db_rollback_transaction();
588 return;
589 case CFTYPE_FORUM:{
590 int fpid;
591 if( g.perm.AttachForum==0 ){
592 goto ajax_post_403;
593 }
594 fpid = forumpost_head_rid2(zTarget);
595 if( fpid<=0 ){
596 goto ajax_post_404;
597 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
598 ajax_route_error(403, "Only admins can attach files to "
599 "other users' forum posts.");
600 db_rollback_transaction();
601 return;
602 }
603 zTarget = zExtraFree = rid_to_uuid(fpid);
604 bNeedsModeration = forum_need_moderation();
605 break;
606 }
607 case CFTYPE_EVENT:{
608 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
609 goto ajax_post_403;
610 }
611 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'", zTarget) ){
612 zTarget = zExtraFree =
613 db_text(0, "SELECT substr(tagname,7) FROM tag"
614 " WHERE tagname GLOB 'event-%q*'", zTarget);
615 if( zTarget==0){
616 goto ajax_post_404;
617 }
618 }
619 bNeedsModeration = 0;
620 break;
621 }
622 case CFTYPE_TICKET:{
623 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
624 goto ajax_post_403;
625 }
626 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'", zTarget) ){
627 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
628 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
629 if( zTarget==0 ){
630 goto ajax_post_404;
631 }
632 }
633 bNeedsModeration = ticket_need_moderation(0);
634 break;
635 }
636 case CFTYPE_WIKI:{
637 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
638 goto ajax_post_403;
639 }
640 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zTarget) ){
641 goto ajax_post_404;
642 }
643 bNeedsModeration = wiki_need_moderation(0);
644 break;
645 }
646 }
647
648 szLimit = db_get_int("attachment-size-limit", 0);
649 for(i = 1; !bRollback; ++i){
650 /* Look for P("fileN"), where N=1..n */
651 const char *zContent;
652 int szContent;
653 sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i);
654 zContent = P(aKeyPrefix);
655 if( !zContent ){
656 /* End of the list. */
657 break;
658 }
659 sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes",
660 aKeyPrefix);
661 szContent = atoi(PD(aKeySize,"-1"));
662 if( szContent<0 ){
663 bRollback = 1;
664 ajax_route_error(400,"Invalid file size.");
665 }else if( szLimit>0 && szContent>szLimit ){
666 bRollback = 1;
667 ajax_route_error(400, "File size limit is %d bytes.", szLimit);
668 break;
669 }else{
670 sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
671 aKeyPrefix);
672 sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
673 aKeyPrefix);
674 attach_commit(P(aKeyName), zTarget, zContent, szContent,
675 bNeedsModeration, P(aKeyDesc));
676 }
677 }
678 fossil_free(zExtraFree);
679 if( !bRollback ){
680 CX("{}");
681 if( atoi(PD("dryrun","0"))>0 ){
682 bRollback = 1;
683 }
684 }
685 db_end_transaction(bRollback);
686 return;
687 ajax_post_403:
688 db_rollback_transaction();
689 ajax_route_error(403, "Permission denied.");
690 return;
691 ajax_post_404:
692 db_rollback_transaction();
693 ajax_route_error(404, "Target not found.");
694 return;
695 }
696
697 /*
698 ** WEBPAGE: attachaddV2 hidden
699 ** Add a new attachment.
700 **
701 ** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
702 ** from=URL
703 **
704 ** Works like /attachadd but uses a JS-based interactive attachment
705 ** selector.
706 **
707 */
708 void attachaddV2_page(void){
709 const char *zFrom = P("from");
710 const char *zTarget = P("target");
711 char *zTo = 0;
712 char *zTargetType = 0;
713 char *zExtraFree = 0;
714 int iTgtType = 0;
715 int szContent = 0;
716 int goodCaptcha = 1;
@@ -646,11 +803,11 @@
803 @ <a href="%R/help/attachment-size-limit">Limit</a> is
804 @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
805 /* Fall through and render form. */
806 }else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
807 #if 0
808 attach_commit(zName, zTarget, aContent, szContent,
809 bNeedsModeration, zComment);
810 #endif
811 cgi_redirect(zTo ? zTo : zFrom);
812 }
813 #else
814
+5 -5
--- src/forum.c
+++ src/forum.c
@@ -227,18 +227,18 @@
227227
}
228228
229229
/*
230230
** Works like forumpost_head_rid() but expects zUuid to be an
231231
** unambiguous forum post name. It may be a hash prefix, so long as
232
-** it's unambiguous. Returns 0 if the name cannot be unambiguously
233
-** resolved as a forum post.
232
+** it's unambiguous. Returns the rid of the head post, -1 if the name
233
+** is ambiguous, and 0 if the name cannot be resolved as a forum post.
234234
*/
235235
int forumpost_head_rid2(const char *zUuid){
236236
const int fpid = symbolic_name_to_rid(zUuid, "f");
237237
return fpid>0
238238
? forumpost_head_rid(fpid)
239
- : 0;
239
+ : fpid;
240240
}
241241
242242
/*
243243
** Given a forum post RID and user name, returns true if zUserName
244244
** matches the event.(euser,user) field for a formpost entry with the
@@ -1279,13 +1279,13 @@
12791279
&& forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
12801280
/* When an admin edits someone else's post, the admin
12811281
** effectively takes over ownership of it (and we currently
12821282
** have no way of passing it back). Because of this, we
12831283
** check the ownership of `p` instead of `pHead`. */
1284
- @ <form method="post" action="%R/attachadd" \
1284
+ @ <form method="post" action="%R/attachaddV2" \
12851285
@ class='file-attach'>\
1286
- @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1286
+ @ <input type="hidden" name="target" value="%T(pHead->zUuid)">
12871287
@ <input type="submit" value="Attach...">
12881288
login_insert_csrf_secret();
12891289
moderation_pending_www(p->fpid);
12901290
@ </form>
12911291
}
12921292
--- src/forum.c
+++ src/forum.c
@@ -227,18 +227,18 @@
227 }
228
229 /*
230 ** Works like forumpost_head_rid() but expects zUuid to be an
231 ** unambiguous forum post name. It may be a hash prefix, so long as
232 ** it's unambiguous. Returns 0 if the name cannot be unambiguously
233 ** resolved as a forum post.
234 */
235 int forumpost_head_rid2(const char *zUuid){
236 const int fpid = symbolic_name_to_rid(zUuid, "f");
237 return fpid>0
238 ? forumpost_head_rid(fpid)
239 : 0;
240 }
241
242 /*
243 ** Given a forum post RID and user name, returns true if zUserName
244 ** matches the event.(euser,user) field for a formpost entry with the
@@ -1279,13 +1279,13 @@
1279 && forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
1280 /* When an admin edits someone else's post, the admin
1281 ** effectively takes over ownership of it (and we currently
1282 ** have no way of passing it back). Because of this, we
1283 ** check the ownership of `p` instead of `pHead`. */
1284 @ <form method="post" action="%R/attachadd" \
1285 @ class='file-attach'>\
1286 @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1287 @ <input type="submit" value="Attach...">
1288 login_insert_csrf_secret();
1289 moderation_pending_www(p->fpid);
1290 @ </form>
1291 }
1292
--- src/forum.c
+++ src/forum.c
@@ -227,18 +227,18 @@
227 }
228
229 /*
230 ** Works like forumpost_head_rid() but expects zUuid to be an
231 ** unambiguous forum post name. It may be a hash prefix, so long as
232 ** it's unambiguous. Returns the rid of the head post, -1 if the name
233 ** is ambiguous, and 0 if the name cannot be resolved as a forum post.
234 */
235 int forumpost_head_rid2(const char *zUuid){
236 const int fpid = symbolic_name_to_rid(zUuid, "f");
237 return fpid>0
238 ? forumpost_head_rid(fpid)
239 : fpid;
240 }
241
242 /*
243 ** Given a forum post RID and user name, returns true if zUserName
244 ** matches the event.(euser,user) field for a formpost entry with the
@@ -1279,13 +1279,13 @@
1279 && forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
1280 /* When an admin edits someone else's post, the admin
1281 ** effectively takes over ownership of it (and we currently
1282 ** have no way of passing it back). Because of this, we
1283 ** check the ownership of `p` instead of `pHead`. */
1284 @ <form method="post" action="%R/attachaddV2" \
1285 @ class='file-attach'>\
1286 @ <input type="hidden" name="target" value="%T(pHead->zUuid)">
1287 @ <input type="submit" value="Attach...">
1288 login_insert_csrf_secret();
1289 moderation_pending_www(p->fpid);
1290 @ </form>
1291 }
1292
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -41,24 +41,26 @@
4141
4242
opt.controls = [array of DOM elements]. Optional DOM elements
4343
to inject into the UI element which wraps the "Add" button.
4444
See this.controlsElement.
4545
46
- opt.listener = {add: func, remove: func, populate: func}: if
47
- these are functions they are registered as listeners for
48
- 'entry-added', 'entry-removed', and/or 'entry-populated'
49
- events, described below. opt.listener.all, if set, is used
50
- as a fallback for any of 'add', 'remove', or 'populate'
51
- which are not set.
46
+ opt.listener = function or object: {add: func, remove: func,
47
+ populate: func}: if these are functions they are registered as
48
+ listeners for 'entry-added', 'entry-removed', and/or
49
+ 'entry-populated' events, described below. opt.listener.all, if
50
+ set, is used as a fallback for any of 'add', 'remove', or
51
+ 'populate' which are not set. If opt.listener is a function
52
+ then it behaves as if listener={all: thatFunction}.
5253
5354
Events:
5455
5556
This class fires CustomEvents for certain changes:
5657
5758
'entry-added' and 'entry-removed' trigger when an attachment
58
- entry row is added/removed. Its event.detail is {attacher:
59
- this, row: object, type: 'same as event type'}.
59
+ entry row is added/removed. Its event.detail is:
60
+
61
+ {attacher: this, row: object, type: 'same as event type'}.
6062
6163
'entry-populated' is triggered when a visible entry gets
6264
content attached to it, with the same detail structure as
6365
described above.
6466
@@ -67,34 +69,49 @@
6769
*/
6870
constructor(opt){
6971
this.#opt = opt = F.nu({
7072
addButtonLabel: false,
7173
startWith: 0,
72
- limit: 0
74
+ limit: 0,
75
+ dryRun: false
7376
}, opt);
77
+ this.#e.body = D.addClass(D.div(), 'attach-widget');
7478
const eBtnAdd = this.#e.btnAdd = D.addClass(
7579
D.button(this.#opt.addButtonLabel || 'Add attachment',
7680
()=>this.#addRow()),
7781
'attach-add-button'
7882
);
7983
eBtnAdd.type = 'button';
84
+ this.#e.err = D.addClass(D.div(), 'error', 'hidden');
85
+ this.#e.body.append(this.#e.err);
86
+
8087
const eControls = this.#e.controls =
8188
D.addClass(D.div(), 'attach-controls');
8289
eControls.append(eBtnAdd);
83
- this.#e.list = D.addClass(D.div(), 'attach-widget');
84
- opt.container.appendChild(this.#e.list);
85
- this.#e.list.appendChild(eControls);
90
+ opt.container.appendChild(this.#e.body);
91
+ this.#e.body.appendChild(eControls);
8692
if( opt.listener ){
87
- const doCb = (eventType, cb)=>{
88
- const f = cb || opt.listener.all;
93
+ const doCb = (eventType, key)=>{
94
+ const f = (opt.listener instanceof Function)
95
+ ? opt.listener
96
+ : (opt.listener[key] || opt.listener.all);
8997
if( f instanceof Function ){
9098
this.addEventListener(eventType, f);
9199
}
92100
};
93
- doCb('entry-added', opt.listener.add);
94
- doCb('entry-removed', opt.listener.remove);
95
- doCb('entry-populated', opt.listener.populate);
101
+ doCb('entry-added', 'add');
102
+ doCb('entry-removed', 'remove');
103
+ doCb('entry-populated', 'populate');
104
+ }
105
+ if( 0 ){
106
+ /* Add dry-run toggle for testing. */
107
+ const eLbl = D.label(false, "Dry-run?");
108
+ const eCb = D.checkbox(true);
109
+ eLbl.append(eCb);
110
+ eControls.append(eLbl);
111
+ eCb.checked = opt.dryRun = true;
112
+ eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
96113
}
97114
if( Array.isArray(opt.controls) ){
98115
eControls.append(...opt.controls);
99116
}
100117
if( opt.startWith > 0 ){
@@ -121,17 +138,36 @@
121138
if( r.file ) return true;
122139
}
123140
return false;
124141
}
125142
143
+ get isDryRun(){
144
+ return !!this.#opt.dryRun;
145
+ }
126146
/**
127147
Returns the DOM element (div.attach-controls) which wraps the
128148
"Add" button. Clients may add buttons to it.
129149
*/
130150
get controlsElement(){
131151
return this.#e.controls;
132152
}
153
+
154
+ /**
155
+ Reports an error by appending each argument to the error widget
156
+ and unhiding it. If passed no arugments, it clears and hides
157
+ the error widget.
158
+ */
159
+ reportError(...msg){
160
+ const e = this.#e.err;
161
+ D.clearElement(e);
162
+ if( msg.length ){
163
+ e.classList.remove('hidden');
164
+ e.append(...msg);
165
+ }else{
166
+ e.classList.add('hidden');
167
+ }
168
+ }
133169
134170
#removeRow(rowObj){
135171
rowObj.e.row.remove();
136172
this.#rows = this.#rows.filter(v=>v!==rowObj);
137173
this.#updateControls();
@@ -148,28 +184,39 @@
148184
&& this.#opt.startWith>0 ){
149185
/* Intended primarily for /addattach. */
150186
this.#addRow();
151187
}
152188
}
189
+
190
+ clear(){
191
+ for(const r of [...this.#rows/*clone because #rows may change*/]){
192
+ this.#removeRow(r);
193
+ }
194
+ this.reportError();
195
+ }
153196
154197
/**
155
- Hide or show the Add button, as appropriate.
198
+ Hides or shows the Add button, as appropriate.
156199
*/
157200
#updateControls(){
158201
const b = this.#e.btnAdd;
159202
if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
160203
b.classList.add('hidden');
161
- //b.setAttribute('disabled','');
204
+ D.disable(b);
162205
//F.toast.warning("Attachment form limit reached.");
163206
}else{
164207
b.classList.remove('hidden');
165
- //b.removeAttribute('disabled');
166
- this.#e.list.append(this.#e.controls/*move to the end*/);
208
+ D.enable(b);
209
+ this.#e.body.append(this.#e.controls/*move to the end*/);
167210
}
168211
}
169212
170
- #rowError(rowObj, ...msg){
213
+ /**
214
+ Sets rowObj.e.err up with an error message, or clears it if
215
+ passed only 1 argument.
216
+ */
217
+ #rowError(rowObj,...msg){
171218
let e = rowObj.e.err;
172219
if( e ){
173220
D.clearElement(e);
174221
}else{
175222
if( !msg.length ) return;
@@ -274,11 +321,11 @@
274321
size: eSize,
275322
desc: eDesc,
276323
row: eRow,
277324
remove: eRemove
278325
});
279
- this.#e.list.append(eRow);
326
+ this.#e.body.append(eRow);
280327
this.#rows.push( rowObj );
281328
this.#updateControls();
282329
this.#events.dispatchEvent(
283330
new CustomEvent('entry-added',{
284331
detail: F.nu({
@@ -322,12 +369,13 @@
322369
}else if( file.size < 1000000 ){
323370
szLbl = (file.size / 1024).toFixed(2)+' KB';
324371
}else{
325372
szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
326373
}
374
+ this.#rowError(rowObj);
327375
const old = this.#rowMatchingName(file.name);
328
- if( old && rowObj !== old){
376
+ if( old && rowObj !== old ){
329377
/*
330378
Fossil attachments treat the name as a unique-per-target
331379
key, with the newest one being the primary. If a name is
332380
given twice, remove the new entry and reuse the older
333381
one. There are conceivable, but also unlikely, cases where
@@ -335,10 +383,11 @@
335383
/foo/bar and /baz/bar, but that seems like a lesser evil
336384
than attaching the same file N times, leading to N
337385
attachment artifacts.
338386
*/
339387
/* recycle `old` instead to avoid UI flicker. */
388
+ this.#rowError(old);
340389
this.#removeRow(rowObj);
341390
rowObj.e = old.e;
342391
}
343392
rowObj.file = file;
344393
rowObj.mimeType = file.type || 'application/octet-stream';
@@ -419,10 +468,12 @@
419468
}
420469
}/*Attacher*/;
421470
F.Attacher = Attacher;
422471
423472
if( document.body.classList.contains('cpage-attachaddV2') ){
473
+ const urlArgs = new URLSearchParams(window.location.search);
474
+ let zTarget = urlArgs.get('target');
424475
const eFormDiv = document.querySelector('#attachadd-form-wrapper');
425476
const eBtnSubmit = D.button("Submit");
426477
eBtnSubmit.type = 'button';
427478
const updateBtnSubmit = (attacher)=>{
428479
if( attacher.isPopulated ){
@@ -433,19 +484,67 @@
433484
};
434485
const cbAttacherChange = (ev)=>{
435486
const a = ev.detail.attacher;
436487
updateBtnSubmit(a);
437488
};
438
- const cbSubmit = (ev)=>{
439
- };
440
- eBtnSubmit.addEventListener('click', cbSubmit, false);
441489
const att = new Attacher({
442490
container: eFormDiv,
443491
startWith: 1,
444
- listener: F.nu({all: cbAttacherChange}),
492
+ listener: cbAttacherChange,
445493
controls: [eBtnSubmit]
446494
});
495
+ eBtnSubmit.addEventListener('click', async (ev)=>{
496
+ att.reportError();
497
+ const li = att.collectState();
498
+ if( !li.length ) return;
499
+ if( eBtnSubmit.dataset.submitted ) return;
500
+ eBtnSubmit.dataset.submitted = 1;
501
+ D.disable(eBtnSubmit);
502
+ const fd = new FormData();
503
+ let i = 0;
504
+ for(const row of li){
505
+ ++i;
506
+ fd.append(`file${i}`, row.content);
507
+ if( row.description ) fd.append(`file${i}_desc`, row.description);
508
+ }
509
+ for( const eIn of eFormDiv.querySelectorAll(':scope > input[type="hidden"]') ){
510
+ if( eIn.name==='target' ){
511
+ zTarget = eIn.value;
512
+ }
513
+ fd.append(eIn.name, eIn.value)
514
+ }
515
+ if( att.isDryRun ){
516
+ fd.append('dryrun', '1');
517
+ }
518
+ let err;
519
+ const resp = await window.fetch(F.repoUrl('attachaddV2_ajax_post'), {
520
+ method: 'POST',
521
+ body: fd
522
+ }).catch((e)=>{
523
+ err = e;
524
+ });
525
+ D.enable(eBtnSubmit);
526
+ delete eBtnSubmit.dataset.submitted;
527
+ const jr = err ? undefined : await resp.json().catch(()=>{});
528
+ if( jr?.error || !resp.ok ){
529
+ const msg = err ? err.message : (jr?.error || resp.statusText);
530
+ att.reportError("Attaching failed: ", msg);
531
+ }else{
532
+ att.clear();
533
+ const to = urlArgs.get('to') || urlArgs.get('from') || jr?.redirect;
534
+ if( to ){
535
+ if( '/'===to[0] ){
536
+ to = F.repoUrl(to.substr(1));
537
+ }
538
+ window.location = to;
539
+ }else{
540
+ const tgt = '?target='+zTarget+'&cacheBuster='+Date.now();
541
+ //console.error("FIXME: location=",tgt);
542
+ window.location = tgt;
543
+ }
544
+ }
545
+ })/*submit handler*/;
447546
updateBtnSubmit(att);
448547
F.page.attacher = att /* only for testing via dev console */;
449548
}/* /attachaddV2 */
450549
451550
})(window.fossil);
452551
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -41,24 +41,26 @@
41
42 opt.controls = [array of DOM elements]. Optional DOM elements
43 to inject into the UI element which wraps the "Add" button.
44 See this.controlsElement.
45
46 opt.listener = {add: func, remove: func, populate: func}: if
47 these are functions they are registered as listeners for
48 'entry-added', 'entry-removed', and/or 'entry-populated'
49 events, described below. opt.listener.all, if set, is used
50 as a fallback for any of 'add', 'remove', or 'populate'
51 which are not set.
 
52
53 Events:
54
55 This class fires CustomEvents for certain changes:
56
57 'entry-added' and 'entry-removed' trigger when an attachment
58 entry row is added/removed. Its event.detail is {attacher:
59 this, row: object, type: 'same as event type'}.
 
60
61 'entry-populated' is triggered when a visible entry gets
62 content attached to it, with the same detail structure as
63 described above.
64
@@ -67,34 +69,49 @@
67 */
68 constructor(opt){
69 this.#opt = opt = F.nu({
70 addButtonLabel: false,
71 startWith: 0,
72 limit: 0
 
73 }, opt);
 
74 const eBtnAdd = this.#e.btnAdd = D.addClass(
75 D.button(this.#opt.addButtonLabel || 'Add attachment',
76 ()=>this.#addRow()),
77 'attach-add-button'
78 );
79 eBtnAdd.type = 'button';
 
 
 
80 const eControls = this.#e.controls =
81 D.addClass(D.div(), 'attach-controls');
82 eControls.append(eBtnAdd);
83 this.#e.list = D.addClass(D.div(), 'attach-widget');
84 opt.container.appendChild(this.#e.list);
85 this.#e.list.appendChild(eControls);
86 if( opt.listener ){
87 const doCb = (eventType, cb)=>{
88 const f = cb || opt.listener.all;
 
 
89 if( f instanceof Function ){
90 this.addEventListener(eventType, f);
91 }
92 };
93 doCb('entry-added', opt.listener.add);
94 doCb('entry-removed', opt.listener.remove);
95 doCb('entry-populated', opt.listener.populate);
 
 
 
 
 
 
 
 
 
96 }
97 if( Array.isArray(opt.controls) ){
98 eControls.append(...opt.controls);
99 }
100 if( opt.startWith > 0 ){
@@ -121,17 +138,36 @@
121 if( r.file ) return true;
122 }
123 return false;
124 }
125
 
 
 
126 /**
127 Returns the DOM element (div.attach-controls) which wraps the
128 "Add" button. Clients may add buttons to it.
129 */
130 get controlsElement(){
131 return this.#e.controls;
132 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
134 #removeRow(rowObj){
135 rowObj.e.row.remove();
136 this.#rows = this.#rows.filter(v=>v!==rowObj);
137 this.#updateControls();
@@ -148,28 +184,39 @@
148 && this.#opt.startWith>0 ){
149 /* Intended primarily for /addattach. */
150 this.#addRow();
151 }
152 }
 
 
 
 
 
 
 
153
154 /**
155 Hide or show the Add button, as appropriate.
156 */
157 #updateControls(){
158 const b = this.#e.btnAdd;
159 if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
160 b.classList.add('hidden');
161 //b.setAttribute('disabled','');
162 //F.toast.warning("Attachment form limit reached.");
163 }else{
164 b.classList.remove('hidden');
165 //b.removeAttribute('disabled');
166 this.#e.list.append(this.#e.controls/*move to the end*/);
167 }
168 }
169
170 #rowError(rowObj, ...msg){
 
 
 
 
171 let e = rowObj.e.err;
172 if( e ){
173 D.clearElement(e);
174 }else{
175 if( !msg.length ) return;
@@ -274,11 +321,11 @@
274 size: eSize,
275 desc: eDesc,
276 row: eRow,
277 remove: eRemove
278 });
279 this.#e.list.append(eRow);
280 this.#rows.push( rowObj );
281 this.#updateControls();
282 this.#events.dispatchEvent(
283 new CustomEvent('entry-added',{
284 detail: F.nu({
@@ -322,12 +369,13 @@
322 }else if( file.size < 1000000 ){
323 szLbl = (file.size / 1024).toFixed(2)+' KB';
324 }else{
325 szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
326 }
 
327 const old = this.#rowMatchingName(file.name);
328 if( old && rowObj !== old){
329 /*
330 Fossil attachments treat the name as a unique-per-target
331 key, with the newest one being the primary. If a name is
332 given twice, remove the new entry and reuse the older
333 one. There are conceivable, but also unlikely, cases where
@@ -335,10 +383,11 @@
335 /foo/bar and /baz/bar, but that seems like a lesser evil
336 than attaching the same file N times, leading to N
337 attachment artifacts.
338 */
339 /* recycle `old` instead to avoid UI flicker. */
 
340 this.#removeRow(rowObj);
341 rowObj.e = old.e;
342 }
343 rowObj.file = file;
344 rowObj.mimeType = file.type || 'application/octet-stream';
@@ -419,10 +468,12 @@
419 }
420 }/*Attacher*/;
421 F.Attacher = Attacher;
422
423 if( document.body.classList.contains('cpage-attachaddV2') ){
 
 
424 const eFormDiv = document.querySelector('#attachadd-form-wrapper');
425 const eBtnSubmit = D.button("Submit");
426 eBtnSubmit.type = 'button';
427 const updateBtnSubmit = (attacher)=>{
428 if( attacher.isPopulated ){
@@ -433,19 +484,67 @@
433 };
434 const cbAttacherChange = (ev)=>{
435 const a = ev.detail.attacher;
436 updateBtnSubmit(a);
437 };
438 const cbSubmit = (ev)=>{
439 };
440 eBtnSubmit.addEventListener('click', cbSubmit, false);
441 const att = new Attacher({
442 container: eFormDiv,
443 startWith: 1,
444 listener: F.nu({all: cbAttacherChange}),
445 controls: [eBtnSubmit]
446 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447 updateBtnSubmit(att);
448 F.page.attacher = att /* only for testing via dev console */;
449 }/* /attachaddV2 */
450
451 })(window.fossil);
452
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -41,24 +41,26 @@
41
42 opt.controls = [array of DOM elements]. Optional DOM elements
43 to inject into the UI element which wraps the "Add" button.
44 See this.controlsElement.
45
46 opt.listener = function or object: {add: func, remove: func,
47 populate: func}: if these are functions they are registered as
48 listeners for 'entry-added', 'entry-removed', and/or
49 'entry-populated' events, described below. opt.listener.all, if
50 set, is used as a fallback for any of 'add', 'remove', or
51 'populate' which are not set. If opt.listener is a function
52 then it behaves as if listener={all: thatFunction}.
53
54 Events:
55
56 This class fires CustomEvents for certain changes:
57
58 'entry-added' and 'entry-removed' trigger when an attachment
59 entry row is added/removed. Its event.detail is:
60
61 {attacher: this, row: object, type: 'same as event type'}.
62
63 'entry-populated' is triggered when a visible entry gets
64 content attached to it, with the same detail structure as
65 described above.
66
@@ -67,34 +69,49 @@
69 */
70 constructor(opt){
71 this.#opt = opt = F.nu({
72 addButtonLabel: false,
73 startWith: 0,
74 limit: 0,
75 dryRun: false
76 }, opt);
77 this.#e.body = D.addClass(D.div(), 'attach-widget');
78 const eBtnAdd = this.#e.btnAdd = D.addClass(
79 D.button(this.#opt.addButtonLabel || 'Add attachment',
80 ()=>this.#addRow()),
81 'attach-add-button'
82 );
83 eBtnAdd.type = 'button';
84 this.#e.err = D.addClass(D.div(), 'error', 'hidden');
85 this.#e.body.append(this.#e.err);
86
87 const eControls = this.#e.controls =
88 D.addClass(D.div(), 'attach-controls');
89 eControls.append(eBtnAdd);
90 opt.container.appendChild(this.#e.body);
91 this.#e.body.appendChild(eControls);
 
92 if( opt.listener ){
93 const doCb = (eventType, key)=>{
94 const f = (opt.listener instanceof Function)
95 ? opt.listener
96 : (opt.listener[key] || opt.listener.all);
97 if( f instanceof Function ){
98 this.addEventListener(eventType, f);
99 }
100 };
101 doCb('entry-added', 'add');
102 doCb('entry-removed', 'remove');
103 doCb('entry-populated', 'populate');
104 }
105 if( 0 ){
106 /* Add dry-run toggle for testing. */
107 const eLbl = D.label(false, "Dry-run?");
108 const eCb = D.checkbox(true);
109 eLbl.append(eCb);
110 eControls.append(eLbl);
111 eCb.checked = opt.dryRun = true;
112 eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
113 }
114 if( Array.isArray(opt.controls) ){
115 eControls.append(...opt.controls);
116 }
117 if( opt.startWith > 0 ){
@@ -121,17 +138,36 @@
138 if( r.file ) return true;
139 }
140 return false;
141 }
142
143 get isDryRun(){
144 return !!this.#opt.dryRun;
145 }
146 /**
147 Returns the DOM element (div.attach-controls) which wraps the
148 "Add" button. Clients may add buttons to it.
149 */
150 get controlsElement(){
151 return this.#e.controls;
152 }
153
154 /**
155 Reports an error by appending each argument to the error widget
156 and unhiding it. If passed no arugments, it clears and hides
157 the error widget.
158 */
159 reportError(...msg){
160 const e = this.#e.err;
161 D.clearElement(e);
162 if( msg.length ){
163 e.classList.remove('hidden');
164 e.append(...msg);
165 }else{
166 e.classList.add('hidden');
167 }
168 }
169
170 #removeRow(rowObj){
171 rowObj.e.row.remove();
172 this.#rows = this.#rows.filter(v=>v!==rowObj);
173 this.#updateControls();
@@ -148,28 +184,39 @@
184 && this.#opt.startWith>0 ){
185 /* Intended primarily for /addattach. */
186 this.#addRow();
187 }
188 }
189
190 clear(){
191 for(const r of [...this.#rows/*clone because #rows may change*/]){
192 this.#removeRow(r);
193 }
194 this.reportError();
195 }
196
197 /**
198 Hides or shows the Add button, as appropriate.
199 */
200 #updateControls(){
201 const b = this.#e.btnAdd;
202 if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
203 b.classList.add('hidden');
204 D.disable(b);
205 //F.toast.warning("Attachment form limit reached.");
206 }else{
207 b.classList.remove('hidden');
208 D.enable(b);
209 this.#e.body.append(this.#e.controls/*move to the end*/);
210 }
211 }
212
213 /**
214 Sets rowObj.e.err up with an error message, or clears it if
215 passed only 1 argument.
216 */
217 #rowError(rowObj,...msg){
218 let e = rowObj.e.err;
219 if( e ){
220 D.clearElement(e);
221 }else{
222 if( !msg.length ) return;
@@ -274,11 +321,11 @@
321 size: eSize,
322 desc: eDesc,
323 row: eRow,
324 remove: eRemove
325 });
326 this.#e.body.append(eRow);
327 this.#rows.push( rowObj );
328 this.#updateControls();
329 this.#events.dispatchEvent(
330 new CustomEvent('entry-added',{
331 detail: F.nu({
@@ -322,12 +369,13 @@
369 }else if( file.size < 1000000 ){
370 szLbl = (file.size / 1024).toFixed(2)+' KB';
371 }else{
372 szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
373 }
374 this.#rowError(rowObj);
375 const old = this.#rowMatchingName(file.name);
376 if( old && rowObj !== old ){
377 /*
378 Fossil attachments treat the name as a unique-per-target
379 key, with the newest one being the primary. If a name is
380 given twice, remove the new entry and reuse the older
381 one. There are conceivable, but also unlikely, cases where
@@ -335,10 +383,11 @@
383 /foo/bar and /baz/bar, but that seems like a lesser evil
384 than attaching the same file N times, leading to N
385 attachment artifacts.
386 */
387 /* recycle `old` instead to avoid UI flicker. */
388 this.#rowError(old);
389 this.#removeRow(rowObj);
390 rowObj.e = old.e;
391 }
392 rowObj.file = file;
393 rowObj.mimeType = file.type || 'application/octet-stream';
@@ -419,10 +468,12 @@
468 }
469 }/*Attacher*/;
470 F.Attacher = Attacher;
471
472 if( document.body.classList.contains('cpage-attachaddV2') ){
473 const urlArgs = new URLSearchParams(window.location.search);
474 let zTarget = urlArgs.get('target');
475 const eFormDiv = document.querySelector('#attachadd-form-wrapper');
476 const eBtnSubmit = D.button("Submit");
477 eBtnSubmit.type = 'button';
478 const updateBtnSubmit = (attacher)=>{
479 if( attacher.isPopulated ){
@@ -433,19 +484,67 @@
484 };
485 const cbAttacherChange = (ev)=>{
486 const a = ev.detail.attacher;
487 updateBtnSubmit(a);
488 };
 
 
 
489 const att = new Attacher({
490 container: eFormDiv,
491 startWith: 1,
492 listener: cbAttacherChange,
493 controls: [eBtnSubmit]
494 });
495 eBtnSubmit.addEventListener('click', async (ev)=>{
496 att.reportError();
497 const li = att.collectState();
498 if( !li.length ) return;
499 if( eBtnSubmit.dataset.submitted ) return;
500 eBtnSubmit.dataset.submitted = 1;
501 D.disable(eBtnSubmit);
502 const fd = new FormData();
503 let i = 0;
504 for(const row of li){
505 ++i;
506 fd.append(`file${i}`, row.content);
507 if( row.description ) fd.append(`file${i}_desc`, row.description);
508 }
509 for( const eIn of eFormDiv.querySelectorAll(':scope > input[type="hidden"]') ){
510 if( eIn.name==='target' ){
511 zTarget = eIn.value;
512 }
513 fd.append(eIn.name, eIn.value)
514 }
515 if( att.isDryRun ){
516 fd.append('dryrun', '1');
517 }
518 let err;
519 const resp = await window.fetch(F.repoUrl('attachaddV2_ajax_post'), {
520 method: 'POST',
521 body: fd
522 }).catch((e)=>{
523 err = e;
524 });
525 D.enable(eBtnSubmit);
526 delete eBtnSubmit.dataset.submitted;
527 const jr = err ? undefined : await resp.json().catch(()=>{});
528 if( jr?.error || !resp.ok ){
529 const msg = err ? err.message : (jr?.error || resp.statusText);
530 att.reportError("Attaching failed: ", msg);
531 }else{
532 att.clear();
533 const to = urlArgs.get('to') || urlArgs.get('from') || jr?.redirect;
534 if( to ){
535 if( '/'===to[0] ){
536 to = F.repoUrl(to.substr(1));
537 }
538 window.location = to;
539 }else{
540 const tgt = '?target='+zTarget+'&cacheBuster='+Date.now();
541 //console.error("FIXME: location=",tgt);
542 window.location = tgt;
543 }
544 }
545 })/*submit handler*/;
546 updateBtnSubmit(att);
547 F.page.attacher = att /* only for testing via dev console */;
548 }/* /attachaddV2 */
549
550 })(window.fossil);
551

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button