Fossil SCM

Rework the attachments and forum with JS-based interfaces and enable attachments for forum posts. No-JS browsers fall back to the older forms. Full details are in the merged-in branch's commit history, wiki page, and in [forum:b1960bb7d3200328|forum post b1960bb7d3200328].

stephan 2026-06-16 10:16 UTC trunk merge
Commit ca906591c3cc71224e592707eb31738f66492ca6551b90cd08d548607d884b40
--- skins/ardoise/css.txt
+++ skins/ardoise/css.txt
@@ -154,24 +154,19 @@
154154
button,
155155
select {
156156
text-transform: none
157157
}
158158
button,
159
-html input[type=button],
159
+input[type=button],
160160
input[type=reset],
161
-input[type=submit] {
161
+input[type=submit]{
162162
cursor: pointer
163163
}
164
-button[disabled],
165
-html input[disabled] {
164
+button:disabled,
165
+input:disabled {
166166
cursor: default
167167
}
168
-button::-moz-focus-inner,
169
-input::-moz-focus-inner {
170
- border: 0;
171
- padding: 0
172
-}
173168
input {
174169
line-height: normal
175170
}
176171
input[type=checkbox],
177172
input[type=radio] {
@@ -307,11 +302,11 @@
307302
text-decoration: none;
308303
text-align: center;
309304
white-space: nowrap;
310305
cursor: pointer
311306
}
312
-input[type=submit]:disabled {
307
+input:disabled {
313308
color: rgb(70,70,70);
314309
background-color: rgb(153,153,153);
315310
}
316311
317312
@media (min-width:550px) {
@@ -416,30 +411,20 @@
416411
.offset-by-eleven.columns {
417412
margin-left: 95.33333%
418413
}
419414
}
420415
.button,
421
-button {
422
- color: #aaa;
423
- background-color: #444;
424
- border-radius: 5px;
425
- border: 0
426
-}
416
+button,
427417
input[type=button],
428418
input[type=reset],
429419
input[type=submit] {
430420
color: #ddd;
431421
background-color: #446979;
432422
border: 0;
433423
border-radius: 5px
434424
}
435
-.button:hover,
436
-button:hover {
437
- color: #444;
438
- background-color: #aaa;
439
- outline: 0
440
-}
425
+button:hover,
441426
input[type=button]:hover,
442427
input[type=reset]:hover,
443428
input[type=submit]:hover {
444429
color: #446979;
445430
background-color: #ddd;
@@ -452,29 +437,20 @@
452437
input[type=submit]:focus {
453438
color: #333;
454439
border-color: #888;
455440
outline: 0
456441
}
457
-.button.button-primary,
458
-.button.button-primary:focus,
459
-.button.button-primary:hover,
460
-button.button-primary,
461
-button.button-primary:focus,
462
-button.button-primary:hover,
463
-input[type=button].button-primary,
464
-input[type=button].button-primary:focus,
465
-input[type=button].button-primary:hover,
466
-input[type=reset].button-primary,
467
-input[type=reset].button-primary:focus,
468
-input[type=reset].button-primary:hover,
469
-input[type=submit].button-primary,
470
-input[type=submit].button-primary:focus,
471
-input[type=submit].button-primary:hover {
472
- color: #303536;
473
- background-color: #ff8000;
474
- border-color: #ff8000
475
-}
442
+
443
+button:disabled,
444
+input[type=button]:disabled,
445
+input[type=reset]:disabled,
446
+input[type=submit]:disabled{
447
+ color: #ddd;
448
+ background-color: #7f7f7f;
449
+ opacity: 0.8;
450
+}
451
+
476452
input[type=email],
477453
input[type=number],
478454
input[type=password],
479455
input[type=search],
480456
input[type=tel],
481457
--- skins/ardoise/css.txt
+++ skins/ardoise/css.txt
@@ -154,24 +154,19 @@
154 button,
155 select {
156 text-transform: none
157 }
158 button,
159 html input[type=button],
160 input[type=reset],
161 input[type=submit] {
162 cursor: pointer
163 }
164 button[disabled],
165 html input[disabled] {
166 cursor: default
167 }
168 button::-moz-focus-inner,
169 input::-moz-focus-inner {
170 border: 0;
171 padding: 0
172 }
173 input {
174 line-height: normal
175 }
176 input[type=checkbox],
177 input[type=radio] {
@@ -307,11 +302,11 @@
307 text-decoration: none;
308 text-align: center;
309 white-space: nowrap;
310 cursor: pointer
311 }
312 input[type=submit]:disabled {
313 color: rgb(70,70,70);
314 background-color: rgb(153,153,153);
315 }
316
317 @media (min-width:550px) {
@@ -416,30 +411,20 @@
416 .offset-by-eleven.columns {
417 margin-left: 95.33333%
418 }
419 }
420 .button,
421 button {
422 color: #aaa;
423 background-color: #444;
424 border-radius: 5px;
425 border: 0
426 }
427 input[type=button],
428 input[type=reset],
429 input[type=submit] {
430 color: #ddd;
431 background-color: #446979;
432 border: 0;
433 border-radius: 5px
434 }
435 .button:hover,
436 button:hover {
437 color: #444;
438 background-color: #aaa;
439 outline: 0
440 }
441 input[type=button]:hover,
442 input[type=reset]:hover,
443 input[type=submit]:hover {
444 color: #446979;
445 background-color: #ddd;
@@ -452,29 +437,20 @@
452 input[type=submit]:focus {
453 color: #333;
454 border-color: #888;
455 outline: 0
456 }
457 .button.button-primary,
458 .button.button-primary:focus,
459 .button.button-primary:hover,
460 button.button-primary,
461 button.button-primary:focus,
462 button.button-primary:hover,
463 input[type=button].button-primary,
464 input[type=button].button-primary:focus,
465 input[type=button].button-primary:hover,
466 input[type=reset].button-primary,
467 input[type=reset].button-primary:focus,
468 input[type=reset].button-primary:hover,
469 input[type=submit].button-primary,
470 input[type=submit].button-primary:focus,
471 input[type=submit].button-primary:hover {
472 color: #303536;
473 background-color: #ff8000;
474 border-color: #ff8000
475 }
476 input[type=email],
477 input[type=number],
478 input[type=password],
479 input[type=search],
480 input[type=tel],
481
--- skins/ardoise/css.txt
+++ skins/ardoise/css.txt
@@ -154,24 +154,19 @@
154 button,
155 select {
156 text-transform: none
157 }
158 button,
159 input[type=button],
160 input[type=reset],
161 input[type=submit]{
162 cursor: pointer
163 }
164 button:disabled,
165 input:disabled {
166 cursor: default
167 }
 
 
 
 
 
168 input {
169 line-height: normal
170 }
171 input[type=checkbox],
172 input[type=radio] {
@@ -307,11 +302,11 @@
302 text-decoration: none;
303 text-align: center;
304 white-space: nowrap;
305 cursor: pointer
306 }
307 input:disabled {
308 color: rgb(70,70,70);
309 background-color: rgb(153,153,153);
310 }
311
312 @media (min-width:550px) {
@@ -416,30 +411,20 @@
411 .offset-by-eleven.columns {
412 margin-left: 95.33333%
413 }
414 }
415 .button,
416 button,
 
 
 
 
 
417 input[type=button],
418 input[type=reset],
419 input[type=submit] {
420 color: #ddd;
421 background-color: #446979;
422 border: 0;
423 border-radius: 5px
424 }
425 button:hover,
 
 
 
 
 
426 input[type=button]:hover,
427 input[type=reset]:hover,
428 input[type=submit]:hover {
429 color: #446979;
430 background-color: #ddd;
@@ -452,29 +437,20 @@
437 input[type=submit]:focus {
438 color: #333;
439 border-color: #888;
440 outline: 0
441 }
442
443 button:disabled,
444 input[type=button]:disabled,
445 input[type=reset]:disabled,
446 input[type=submit]:disabled{
447 color: #ddd;
448 background-color: #7f7f7f;
449 opacity: 0.8;
450 }
451
 
 
 
 
 
 
 
 
 
452 input[type=email],
453 input[type=number],
454 input[type=password],
455 input[type=search],
456 input[type=tel],
457
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -1,17 +1,9 @@
11
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
22
3
-/**
4
- * 1. Set default font family to sans-serif.
5
- * 2. Prevent iOS text size adjust after orientation change, without disabling
6
- * user zoom.
7
- */
8
-
93
html {
104
font-family: sans-serif; /* 1 */
11
- -ms-text-size-adjust: 100%; /* 2 */
12
- -webkit-text-size-adjust: 100%; /* 2 */
135
}
146
157
/**
168
* Remove default margin.
179
*/
@@ -207,11 +199,10 @@
207199
/**
208200
* Address differences between Firefox and other browsers.
209201
*/
210202
211203
hr {
212
- -moz-box-sizing: content-box;
213204
box-sizing: content-box;
214205
height: 0;
215206
}
216207
217208
/**
@@ -277,47 +268,27 @@
277268
button,
278269
select {
279270
text-transform: none;
280271
}
281272
282
-/**
283
- * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
284
- * and `video` controls.
285
- * 2. Correct inability to style clickable `input` types in iOS.
286
- * 3. Improve usability and consistency of cursor style between image-type
287
- * `input` and others.
288
- */
289
-
290273
button,
291
-html input[type="button"], /* 1 */
292
-input[type="reset"],
293
-input[type="submit"],
294
-input[type="button"].submit,
295
-button.submit{
296
- -webkit-appearance: button; /* 2 */
297
- cursor: pointer; /* 3 */
274
+input[type=button],
275
+input[type=reset],
276
+input[type=submit]{
277
+ cursor: pointer;
298278
}
299279
300280
/**
301281
* Re-set default cursor for disabled elements.
302282
*/
303
-
304
-button[disabled],
305
-html input[disabled] {
283
+button:disabled,
284
+input[type=button]:disabled,
285
+input[type=reset]:disabled,
286
+input[type=submit]:disabled{
306287
cursor: default;
307288
}
308289
309
-/**
310
- * Remove inner padding and border in Firefox 4+.
311
- */
312
-
313
-button::-moz-focus-inner,
314
-input::-moz-focus-inner {
315
- border: 0;
316
- padding: 0;
317
-}
318
-
319290
/**
320291
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
321292
* the UA stylesheet.
322293
*/
323294
@@ -348,20 +319,11 @@
348319
input[type="number"]::-webkit-inner-spin-button,
349320
input[type="number"]::-webkit-outer-spin-button {
350321
height: auto;
351322
}
352323
353
-/**
354
- * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
355
- * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
356
- * (include `-moz` to future-proof).
357
- */
358
-
359324
input[type="search"] {
360
- -webkit-appearance: textfield; /* 1 */
361
- -moz-box-sizing: content-box;
362
- -webkit-box-sizing: content-box; /* 2 */
363325
box-sizing: content-box;
364326
}
365327
366328
/**
367329
* Remove inner padding and search cancel button in Safari and Chrome on OS X.
@@ -517,73 +479,65 @@
517479
518480
/* Buttons
519481
––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
520482
.button,
521483
button,
522
-input[type="button"],
523
-input[type="reset"],
524
-input[type="submit"],
525
-input[type="button"].submit,
526
-button.submit{
484
+input[type=button],
485
+input[type=reset],
486
+input[type=submit]{
527487
display: inline-block;
528488
height: 3.3rem;
529489
padding: 0 2.2rem;
530
- color: #555 !important;
490
+ color: #444;
491
+ background-color: #f8f8f8;;
492
+ border-color: #446979;
531493
text-align: center;
532494
font-size: 1.1rem;
533495
font-weight: 700;
534496
line-height: 3.3rem;
535497
letter-spacing: .08rem;
536498
text-transform: uppercase;
537499
text-decoration: none;
538500
white-space: nowrap;
539
- background-color: transparent;
540501
border-radius: 4px;
541
- border: 1px solid #ccc;
502
+ /*border: 1px solid #ccc;*/
503
+ border: 1px solid #446979;
542504
cursor: pointer;
543505
box-sizing: border-box;
544506
}
545507
546508
.button:hover,
547509
button:hover,
548
-input[type="button"]:hover,
549
-input[type="reset"]:hover,
550
-.button:focus,
510
+input[type=button]:hover,
511
+input[type=reset]:hover,
512
+input[type=submit]:hover,
513
+.button:hover,
551514
button:focus,
552
-input[type="button"]:focus,
553
-input[type="reset"]:focus {
554
- color: #444 !important;
555
- background-color: #eee;
556
- border-color: #aaa;
515
+input[type=button]:focus,
516
+input[type=reset]:focus,
517
+input[type=submit]:focus{
557518
outline: 0;
558
-}
559
-
560
-input[type="submit"],
561
-input[type="button"].submit,
562
-button.submit{
563
- color: white !important;
564
- background-color: #446979;
565
- border-color: #446979;
566
-}
567
-
568
-input[type="submit"]:hover,
569
-input[type="submit"]:focus,
570
-input[type="button"].submit:hover,
571
-input[type="button"].submit:focus,
572
-button.submit:hover,
573
-button.submit:focus{
574
- color: white !important;
519
+ color: white;
575520
background-color: #648898;
576521
border-color: #648898;
577522
}
578523
579
-input[type="submit"]:disabled,
580
-input[type="button"].submit:disabled,
581
-button.submit:disabled{
582
- color: rgb(128,128,128);
524
+.button:disabled,
525
+button:disabled,
526
+input[type=button]:disabled,
527
+input[type=reset]:disabled,
528
+input[type=submit]:disabled{
529
+ color: #444;
583530
background-color: rgb(153,153,153);
584531
}
532
+
533
+.content a.button,
534
+.submenu a.button,
535
+.submenu a.button:visited {
536
+ color: #444;
537
+ background: #f8f8f8;
538
+}
585539
586540
587541
/* Forms
588542
––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
589543
input[type="email"],
@@ -611,12 +565,10 @@
611565
input[type="text"],
612566
input[type="tel"],
613567
input[type="url"],
614568
input[type="password"],
615569
textarea {
616
- -webkit-appearance: none;
617
- -moz-appearance: none;
618570
appearance: none;
619571
}
620572
621573
textarea {
622574
height: inherit;
@@ -1293,5 +1245,11 @@
12931245
}
12941246
12951247
body.forum .forumPosts.fileage a:visited {
12961248
color: #648999;
12971249
}
1250
+
1251
+/* Chat
1252
+––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
1253
+body.chat .chat-input-field:focus {
1254
+ border-color: #446979;
1255
+}
12981256
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -1,17 +1,9 @@
1 /*! normalize.css v3.0.2 | MIT License | git.io/normalize */
2
3 /**
4 * 1. Set default font family to sans-serif.
5 * 2. Prevent iOS text size adjust after orientation change, without disabling
6 * user zoom.
7 */
8
9 html {
10 font-family: sans-serif; /* 1 */
11 -ms-text-size-adjust: 100%; /* 2 */
12 -webkit-text-size-adjust: 100%; /* 2 */
13 }
14
15 /**
16 * Remove default margin.
17 */
@@ -207,11 +199,10 @@
207 /**
208 * Address differences between Firefox and other browsers.
209 */
210
211 hr {
212 -moz-box-sizing: content-box;
213 box-sizing: content-box;
214 height: 0;
215 }
216
217 /**
@@ -277,47 +268,27 @@
277 button,
278 select {
279 text-transform: none;
280 }
281
282 /**
283 * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
284 * and `video` controls.
285 * 2. Correct inability to style clickable `input` types in iOS.
286 * 3. Improve usability and consistency of cursor style between image-type
287 * `input` and others.
288 */
289
290 button,
291 html input[type="button"], /* 1 */
292 input[type="reset"],
293 input[type="submit"],
294 input[type="button"].submit,
295 button.submit{
296 -webkit-appearance: button; /* 2 */
297 cursor: pointer; /* 3 */
298 }
299
300 /**
301 * Re-set default cursor for disabled elements.
302 */
303
304 button[disabled],
305 html input[disabled] {
 
306 cursor: default;
307 }
308
309 /**
310 * Remove inner padding and border in Firefox 4+.
311 */
312
313 button::-moz-focus-inner,
314 input::-moz-focus-inner {
315 border: 0;
316 padding: 0;
317 }
318
319 /**
320 * Address Firefox 4+ setting `line-height` on `input` using `!important` in
321 * the UA stylesheet.
322 */
323
@@ -348,20 +319,11 @@
348 input[type="number"]::-webkit-inner-spin-button,
349 input[type="number"]::-webkit-outer-spin-button {
350 height: auto;
351 }
352
353 /**
354 * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
355 * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
356 * (include `-moz` to future-proof).
357 */
358
359 input[type="search"] {
360 -webkit-appearance: textfield; /* 1 */
361 -moz-box-sizing: content-box;
362 -webkit-box-sizing: content-box; /* 2 */
363 box-sizing: content-box;
364 }
365
366 /**
367 * Remove inner padding and search cancel button in Safari and Chrome on OS X.
@@ -517,73 +479,65 @@
517
518 /* Buttons
519 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
520 .button,
521 button,
522 input[type="button"],
523 input[type="reset"],
524 input[type="submit"],
525 input[type="button"].submit,
526 button.submit{
527 display: inline-block;
528 height: 3.3rem;
529 padding: 0 2.2rem;
530 color: #555 !important;
 
 
531 text-align: center;
532 font-size: 1.1rem;
533 font-weight: 700;
534 line-height: 3.3rem;
535 letter-spacing: .08rem;
536 text-transform: uppercase;
537 text-decoration: none;
538 white-space: nowrap;
539 background-color: transparent;
540 border-radius: 4px;
541 border: 1px solid #ccc;
 
542 cursor: pointer;
543 box-sizing: border-box;
544 }
545
546 .button:hover,
547 button:hover,
548 input[type="button"]:hover,
549 input[type="reset"]:hover,
550 .button:focus,
 
551 button:focus,
552 input[type="button"]:focus,
553 input[type="reset"]:focus {
554 color: #444 !important;
555 background-color: #eee;
556 border-color: #aaa;
557 outline: 0;
558 }
559
560 input[type="submit"],
561 input[type="button"].submit,
562 button.submit{
563 color: white !important;
564 background-color: #446979;
565 border-color: #446979;
566 }
567
568 input[type="submit"]:hover,
569 input[type="submit"]:focus,
570 input[type="button"].submit:hover,
571 input[type="button"].submit:focus,
572 button.submit:hover,
573 button.submit:focus{
574 color: white !important;
575 background-color: #648898;
576 border-color: #648898;
577 }
578
579 input[type="submit"]:disabled,
580 input[type="button"].submit:disabled,
581 button.submit:disabled{
582 color: rgb(128,128,128);
 
 
583 background-color: rgb(153,153,153);
584 }
 
 
 
 
 
 
 
585
586
587 /* Forms
588 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
589 input[type="email"],
@@ -611,12 +565,10 @@
611 input[type="text"],
612 input[type="tel"],
613 input[type="url"],
614 input[type="password"],
615 textarea {
616 -webkit-appearance: none;
617 -moz-appearance: none;
618 appearance: none;
619 }
620
621 textarea {
622 height: inherit;
@@ -1293,5 +1245,11 @@
1293 }
1294
1295 body.forum .forumPosts.fileage a:visited {
1296 color: #648999;
1297 }
 
 
 
 
 
 
1298
--- skins/blitz/css.txt
+++ skins/blitz/css.txt
@@ -1,17 +1,9 @@
1 /*! normalize.css v3.0.2 | MIT License | git.io/normalize */
2
 
 
 
 
 
 
3 html {
4 font-family: sans-serif; /* 1 */
 
 
5 }
6
7 /**
8 * Remove default margin.
9 */
@@ -207,11 +199,10 @@
199 /**
200 * Address differences between Firefox and other browsers.
201 */
202
203 hr {
 
204 box-sizing: content-box;
205 height: 0;
206 }
207
208 /**
@@ -277,47 +268,27 @@
268 button,
269 select {
270 text-transform: none;
271 }
272
 
 
 
 
 
 
 
 
273 button,
274 input[type=button],
275 input[type=reset],
276 input[type=submit]{
277 cursor: pointer;
 
 
 
278 }
279
280 /**
281 * Re-set default cursor for disabled elements.
282 */
283 button:disabled,
284 input[type=button]:disabled,
285 input[type=reset]:disabled,
286 input[type=submit]:disabled{
287 cursor: default;
288 }
289
 
 
 
 
 
 
 
 
 
 
290 /**
291 * Address Firefox 4+ setting `line-height` on `input` using `!important` in
292 * the UA stylesheet.
293 */
294
@@ -348,20 +319,11 @@
319 input[type="number"]::-webkit-inner-spin-button,
320 input[type="number"]::-webkit-outer-spin-button {
321 height: auto;
322 }
323
 
 
 
 
 
 
324 input[type="search"] {
 
 
 
325 box-sizing: content-box;
326 }
327
328 /**
329 * Remove inner padding and search cancel button in Safari and Chrome on OS X.
@@ -517,73 +479,65 @@
479
480 /* Buttons
481 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
482 .button,
483 button,
484 input[type=button],
485 input[type=reset],
486 input[type=submit]{
 
 
487 display: inline-block;
488 height: 3.3rem;
489 padding: 0 2.2rem;
490 color: #444;
491 background-color: #f8f8f8;;
492 border-color: #446979;
493 text-align: center;
494 font-size: 1.1rem;
495 font-weight: 700;
496 line-height: 3.3rem;
497 letter-spacing: .08rem;
498 text-transform: uppercase;
499 text-decoration: none;
500 white-space: nowrap;
 
501 border-radius: 4px;
502 /*border: 1px solid #ccc;*/
503 border: 1px solid #446979;
504 cursor: pointer;
505 box-sizing: border-box;
506 }
507
508 .button:hover,
509 button:hover,
510 input[type=button]:hover,
511 input[type=reset]:hover,
512 input[type=submit]:hover,
513 .button:hover,
514 button:focus,
515 input[type=button]:focus,
516 input[type=reset]:focus,
517 input[type=submit]:focus{
 
 
518 outline: 0;
519 color: white;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520 background-color: #648898;
521 border-color: #648898;
522 }
523
524 .button:disabled,
525 button:disabled,
526 input[type=button]:disabled,
527 input[type=reset]:disabled,
528 input[type=submit]:disabled{
529 color: #444;
530 background-color: rgb(153,153,153);
531 }
532
533 .content a.button,
534 .submenu a.button,
535 .submenu a.button:visited {
536 color: #444;
537 background: #f8f8f8;
538 }
539
540
541 /* Forms
542 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
543 input[type="email"],
@@ -611,12 +565,10 @@
565 input[type="text"],
566 input[type="tel"],
567 input[type="url"],
568 input[type="password"],
569 textarea {
 
 
570 appearance: none;
571 }
572
573 textarea {
574 height: inherit;
@@ -1293,5 +1245,11 @@
1245 }
1246
1247 body.forum .forumPosts.fileage a:visited {
1248 color: #648999;
1249 }
1250
1251 /* Chat
1252 ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */
1253 body.chat .chat-input-field:focus {
1254 border-color: #446979;
1255 }
1256
--- skins/darkmode/css.txt
+++ skins/darkmode/css.txt
@@ -98,50 +98,56 @@
9898
}
9999
.fileage tr:hover,
100100
div.filetreeline:hover {
101101
background-color: #333;
102102
}
103
-div.file-change-line button {
103
+div.file-change-line button{
104104
background-color: #484848
105105
}
106
+button,
107
+input,
108
+optgroup,
109
+select,
110
+textarea {
111
+ background: inherit;
112
+ color: inherit;
113
+ font: inherit;
114
+ margin: 0
115
+}
106116
.button,
107
-button {
108
- color: #aaa;
109
- background-color: #484848;
110
- border-radius: 5px;
111
- border: 0
112
-}
113
-.button:hover,
114
-button:hover {
115
- background-color: #FF4500f0;
116
- color: rgba(24,24,24,0.8);
117
- outline: 0
118
-}
117
+button,
119118
input[type=button],
120
-input[type=reset],
121
-input[type=submit] {
119
+input[type=submit],
120
+input[type=reset]{
122121
color: #ddd;
123122
background-color: #446979;
123
+ border-radius: 5px;
124124
border: 0;
125
- border-radius: 5px
126125
}
126
+.button:hover,
127
+button:hover,
127128
input[type=button]:hover,
128
-input[type=reset]:hover,
129
-input[type=submit]:hover {
129
+input[type=submit]:hover,
130
+input[type=reset]:hover {
130131
background-color: #FF4500f0;
131132
color: rgba(24,24,24,0.8);
132
- outline: 0
133
+ outline: 0;
133134
}
134
-input[type=submit]:disabled {
135
- color: #363636;
136
- background-color: #707070;
135
+.button:disabled,
136
+button:disabled,
137
+input[type=button]:disabled,
138
+input[type=submit]:disabled,
139
+input[type=reset]:disabled{
140
+ color: #222;
141
+ background-color: #b0b0b0;
142
+ opacity: 0.8;
137143
}
138144
.button:focus,
139145
button:focus,
140146
input[type=button]:focus,
141
-input[type=reset]:focus,
142
-input[type=submit]:focus {
147
+input[type=submit]:focus,
148
+input[type=reset]:focus {
143149
outline: 2px outset #333;
144150
border-color: #888;
145151
}
146152
147153
/* All page content from the bottom of the menu or submenu down to
@@ -529,23 +535,10 @@
529535
}
530536
span.snippet>mark {
531537
color: white;
532538
font-weight: bold;
533539
}
534
-button,
535
-input,
536
-optgroup,
537
-select,
538
-textarea {
539
- background: inherit;
540
- color: inherit;
541
- font: inherit;
542
- margin: 0
543
-}
544
-button {
545
- background-color: rgba(45,45,45,0.75);
546
-}
547540
input, textarea, select {
548541
border: 1px solid rgba(127, 201, 255, 0.9);
549542
padding: 1px;
550543
}
551544
select {
552545
--- skins/darkmode/css.txt
+++ skins/darkmode/css.txt
@@ -98,50 +98,56 @@
98 }
99 .fileage tr:hover,
100 div.filetreeline:hover {
101 background-color: #333;
102 }
103 div.file-change-line button {
104 background-color: #484848
105 }
 
 
 
 
 
 
 
 
 
 
106 .button,
107 button {
108 color: #aaa;
109 background-color: #484848;
110 border-radius: 5px;
111 border: 0
112 }
113 .button:hover,
114 button:hover {
115 background-color: #FF4500f0;
116 color: rgba(24,24,24,0.8);
117 outline: 0
118 }
119 input[type=button],
120 input[type=reset],
121 input[type=submit] {
122 color: #ddd;
123 background-color: #446979;
 
124 border: 0;
125 border-radius: 5px
126 }
 
 
127 input[type=button]:hover,
128 input[type=reset]:hover,
129 input[type=submit]:hover {
130 background-color: #FF4500f0;
131 color: rgba(24,24,24,0.8);
132 outline: 0
133 }
134 input[type=submit]:disabled {
135 color: #363636;
136 background-color: #707070;
 
 
 
 
 
137 }
138 .button:focus,
139 button:focus,
140 input[type=button]:focus,
141 input[type=reset]:focus,
142 input[type=submit]:focus {
143 outline: 2px outset #333;
144 border-color: #888;
145 }
146
147 /* All page content from the bottom of the menu or submenu down to
@@ -529,23 +535,10 @@
529 }
530 span.snippet>mark {
531 color: white;
532 font-weight: bold;
533 }
534 button,
535 input,
536 optgroup,
537 select,
538 textarea {
539 background: inherit;
540 color: inherit;
541 font: inherit;
542 margin: 0
543 }
544 button {
545 background-color: rgba(45,45,45,0.75);
546 }
547 input, textarea, select {
548 border: 1px solid rgba(127, 201, 255, 0.9);
549 padding: 1px;
550 }
551 select {
552
--- skins/darkmode/css.txt
+++ skins/darkmode/css.txt
@@ -98,50 +98,56 @@
98 }
99 .fileage tr:hover,
100 div.filetreeline:hover {
101 background-color: #333;
102 }
103 div.file-change-line button{
104 background-color: #484848
105 }
106 button,
107 input,
108 optgroup,
109 select,
110 textarea {
111 background: inherit;
112 color: inherit;
113 font: inherit;
114 margin: 0
115 }
116 .button,
117 button,
 
 
 
 
 
 
 
 
 
 
 
118 input[type=button],
119 input[type=submit],
120 input[type=reset]{
121 color: #ddd;
122 background-color: #446979;
123 border-radius: 5px;
124 border: 0;
 
125 }
126 .button:hover,
127 button:hover,
128 input[type=button]:hover,
129 input[type=submit]:hover,
130 input[type=reset]:hover {
131 background-color: #FF4500f0;
132 color: rgba(24,24,24,0.8);
133 outline: 0;
134 }
135 .button:disabled,
136 button:disabled,
137 input[type=button]:disabled,
138 input[type=submit]:disabled,
139 input[type=reset]:disabled{
140 color: #222;
141 background-color: #b0b0b0;
142 opacity: 0.8;
143 }
144 .button:focus,
145 button:focus,
146 input[type=button]:focus,
147 input[type=submit]:focus,
148 input[type=reset]:focus {
149 outline: 2px outset #333;
150 border-color: #888;
151 }
152
153 /* All page content from the bottom of the menu or submenu down to
@@ -529,23 +535,10 @@
535 }
536 span.snippet>mark {
537 color: white;
538 font-weight: bold;
539 }
 
 
 
 
 
 
 
 
 
 
 
 
 
540 input, textarea, select {
541 border: 1px solid rgba(127, 201, 255, 0.9);
542 padding: 1px;
543 }
544 select {
545
+72 -10
--- src/ajax.c
+++ src/ajax.c
@@ -191,23 +191,52 @@
191191
** {error: formatted message}
192192
**
193193
** If httpCode<=0 then it defaults to 500.
194194
**
195195
** After calling this, the caller should immediately return.
196
+**
197
+** Returns the resulting http code.
196198
*/
197
-void ajax_route_error(int httpCode, const char * zFmt, ...){
199
+int ajax_route_error(int httpCode, const char * zFmt, ...){
198200
Blob msg = empty_blob;
199201
Blob content = empty_blob;
200202
va_list vargs;
203
+
204
+ if( httpCode<=0 ) httpCode=500;
201205
va_start(vargs,zFmt);
202206
blob_vappendf(&msg, zFmt, vargs);
203207
va_end(vargs);
204208
blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg));
205209
blob_reset(&msg);
206210
cgi_set_content(&content);
207
- cgi_set_status(httpCode>0 ? httpCode : 500, "Error");
211
+ cgi_set_status(httpCode, "Error");
208212
cgi_set_content_type("application/json");
213
+ return httpCode;
214
+}
215
+
216
+void ajax_route_error_forbidden(){
217
+ ajax_route_error(403, "Permission denied.");
218
+}
219
+
220
+void ajax_route_error_captcha(){
221
+ ajax_route_error(400, "Invalid captcha response.");
222
+}
223
+
224
+void ajax_route_error_csrf(){
225
+ ajax_route_error(403, "Invalid CSRF signature.");
226
+}
227
+
228
+void ajax_route_error_404(const char *zMsg){
229
+ ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found.");
230
+}
231
+
232
+int ajax_check_csrf(int level){
233
+ if( 0==cgi_csrf_safe(level) ){
234
+ ajax_route_error_csrf();
235
+ return 0;
236
+ }
237
+ return 1;
209238
}
210239
211240
/*
212241
** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as
213242
** logging in the user.
@@ -224,17 +253,18 @@
224253
int ajax_route_bootstrap(int requireWrite, int requirePost){
225254
login_check_credentials();
226255
if( requireWrite!=0 && g.perm.Write==0 ){
227256
ajax_route_error(403,"Write permissions required.");
228257
return 0;
229
- }else if(0==cgi_csrf_safe(requirePost)){
258
+ }else if(requirePost && 0==cgi_csrf_safe(requirePost)){
230259
ajax_route_error(403,
231260
"CSRF violation (make sure sending of HTTP "
232261
"Referer headers is enabled for XHR "
233262
"connections).");
234263
return 0;
235264
}
265
+ cgi_set_content_type("application/json");
236266
return 1;
237267
}
238268
239269
/*
240270
** Helper for collecting filename/check-in request parameters.
@@ -283,12 +313,10 @@
283313
** AJAX_RENDER_PLAIN_TEXT mode.
284314
**
285315
** iframe_height=integer (default=40) Height, in EMs of HTML preview
286316
** iframe.
287317
**
288
-** User must have Write access to use this page.
289
-**
290318
** Responds with the HTML content of the preview. On error it produces
291319
** a JSON response as documented for ajax_route_error().
292320
**
293321
** Extra response headers:
294322
**
@@ -341,10 +369,37 @@
341369
}
342370
if(zRenderMode!=0){
343371
cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode);
344372
}
345373
}
374
+
375
+/*
376
+** AJAX route /ajax/artifact.json.
377
+** URL arguments:
378
+**
379
+** uuid=ARTIFACT_ID REQUIRED
380
+**
381
+** and emits either:
382
+**
383
+** { error: "..." }
384
+**
385
+** with a non-200 response code or the artifact's manifest in JSON
386
+** form with a 200 response code.
387
+*/
388
+void ajax_route_artifact_json(void){
389
+ const char *zUuid = P("uuid");
390
+ Blob json = BLOB_INITIALIZER;
391
+ login_check_credentials();
392
+ if( ! g.perm.Read ){
393
+ ajax_route_error_forbidden();
394
+ }else if( artifact_to_json_by_name(zUuid, &json) ){
395
+ @ %b(&json)
396
+ }else{
397
+ ajax_route_error_404("Cannot resolve artifact ID.");
398
+ }
399
+ blob_reset(&json);
400
+}
346401
347402
#if INTERFACE
348403
/*
349404
** Internal mapping of ajax sub-route names to various metadata.
350405
*/
@@ -351,11 +406,12 @@
351406
struct AjaxRoute {
352407
const char *zName; /* Name part of the route after "ajax/" */
353408
void (*xCallback)(); /* Impl function for the route. */
354409
int bWriteMode; /* True if requires write mode */
355410
int bPost; /* True if requires POST (i.e. CSRF
356
- ** verification) */
411
+ ** verification). Value is passed to
412
+ ** cgi_csrf_safe(). */
357413
};
358414
typedef struct AjaxRoute AjaxRoute;
359415
#endif /*INTERFACE*/
360416
361417
/*
@@ -392,20 +448,26 @@
392448
const char * zName = P("name");
393449
AjaxRoute routeName = {0,0,0,0};
394450
const AjaxRoute * pRoute = 0;
395451
const AjaxRoute routes[] = {
396452
/* Keep these sorted by zName (for bsearch()) */
453
+ {"artifact.json", ajax_route_artifact_json, 0, 0},
397454
{"preview-text", ajax_route_preview_text, 0, 1
398
- /* Note that this does not require write permissions in the repo.
399
- ** It should arguably require write permissions but doing means
400
- ** that /chat does not work without check-in permissions:
455
+ /* Preview does not require write permissions in the repo. It
456
+ ** should arguably require write permissions simply to limit abuse
457
+ ** but doing means that /chat does not work without check-in
458
+ ** permissions:
401459
**
402460
** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898
403461
**
404462
** This particular route is used by /fileedit and /chat, whereas
405463
** /wikiedit uses a simpler wiki-specific route.
406
- */ }
464
+ */
465
+ /* TODO (2026-06-09): preview.txt, preview.md, preview.wiki as
466
+ ** shorthand for preview-text?filename=X.(txt|md|wiki), noting that
467
+ ** the filename is only used for mimetype determination. */
468
+ }
407469
};
408470
409471
if(zName==0 || zName[0]==0){
410472
ajax_route_error(400,"Missing required [route] 'name' parameter.");
411473
return;
412474
--- src/ajax.c
+++ src/ajax.c
@@ -191,23 +191,52 @@
191 ** {error: formatted message}
192 **
193 ** If httpCode<=0 then it defaults to 500.
194 **
195 ** After calling this, the caller should immediately return.
 
 
196 */
197 void ajax_route_error(int httpCode, const char * zFmt, ...){
198 Blob msg = empty_blob;
199 Blob content = empty_blob;
200 va_list vargs;
 
 
201 va_start(vargs,zFmt);
202 blob_vappendf(&msg, zFmt, vargs);
203 va_end(vargs);
204 blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg));
205 blob_reset(&msg);
206 cgi_set_content(&content);
207 cgi_set_status(httpCode>0 ? httpCode : 500, "Error");
208 cgi_set_content_type("application/json");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209 }
210
211 /*
212 ** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as
213 ** logging in the user.
@@ -224,17 +253,18 @@
224 int ajax_route_bootstrap(int requireWrite, int requirePost){
225 login_check_credentials();
226 if( requireWrite!=0 && g.perm.Write==0 ){
227 ajax_route_error(403,"Write permissions required.");
228 return 0;
229 }else if(0==cgi_csrf_safe(requirePost)){
230 ajax_route_error(403,
231 "CSRF violation (make sure sending of HTTP "
232 "Referer headers is enabled for XHR "
233 "connections).");
234 return 0;
235 }
 
236 return 1;
237 }
238
239 /*
240 ** Helper for collecting filename/check-in request parameters.
@@ -283,12 +313,10 @@
283 ** AJAX_RENDER_PLAIN_TEXT mode.
284 **
285 ** iframe_height=integer (default=40) Height, in EMs of HTML preview
286 ** iframe.
287 **
288 ** User must have Write access to use this page.
289 **
290 ** Responds with the HTML content of the preview. On error it produces
291 ** a JSON response as documented for ajax_route_error().
292 **
293 ** Extra response headers:
294 **
@@ -341,10 +369,37 @@
341 }
342 if(zRenderMode!=0){
343 cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode);
344 }
345 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
347 #if INTERFACE
348 /*
349 ** Internal mapping of ajax sub-route names to various metadata.
350 */
@@ -351,11 +406,12 @@
351 struct AjaxRoute {
352 const char *zName; /* Name part of the route after "ajax/" */
353 void (*xCallback)(); /* Impl function for the route. */
354 int bWriteMode; /* True if requires write mode */
355 int bPost; /* True if requires POST (i.e. CSRF
356 ** verification) */
 
357 };
358 typedef struct AjaxRoute AjaxRoute;
359 #endif /*INTERFACE*/
360
361 /*
@@ -392,20 +448,26 @@
392 const char * zName = P("name");
393 AjaxRoute routeName = {0,0,0,0};
394 const AjaxRoute * pRoute = 0;
395 const AjaxRoute routes[] = {
396 /* Keep these sorted by zName (for bsearch()) */
 
397 {"preview-text", ajax_route_preview_text, 0, 1
398 /* Note that this does not require write permissions in the repo.
399 ** It should arguably require write permissions but doing means
400 ** that /chat does not work without check-in permissions:
 
401 **
402 ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898
403 **
404 ** This particular route is used by /fileedit and /chat, whereas
405 ** /wikiedit uses a simpler wiki-specific route.
406 */ }
 
 
 
 
407 };
408
409 if(zName==0 || zName[0]==0){
410 ajax_route_error(400,"Missing required [route] 'name' parameter.");
411 return;
412
--- src/ajax.c
+++ src/ajax.c
@@ -191,23 +191,52 @@
191 ** {error: formatted message}
192 **
193 ** If httpCode<=0 then it defaults to 500.
194 **
195 ** After calling this, the caller should immediately return.
196 **
197 ** Returns the resulting http code.
198 */
199 int ajax_route_error(int httpCode, const char * zFmt, ...){
200 Blob msg = empty_blob;
201 Blob content = empty_blob;
202 va_list vargs;
203
204 if( httpCode<=0 ) httpCode=500;
205 va_start(vargs,zFmt);
206 blob_vappendf(&msg, zFmt, vargs);
207 va_end(vargs);
208 blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg));
209 blob_reset(&msg);
210 cgi_set_content(&content);
211 cgi_set_status(httpCode, "Error");
212 cgi_set_content_type("application/json");
213 return httpCode;
214 }
215
216 void ajax_route_error_forbidden(){
217 ajax_route_error(403, "Permission denied.");
218 }
219
220 void ajax_route_error_captcha(){
221 ajax_route_error(400, "Invalid captcha response.");
222 }
223
224 void ajax_route_error_csrf(){
225 ajax_route_error(403, "Invalid CSRF signature.");
226 }
227
228 void ajax_route_error_404(const char *zMsg){
229 ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found.");
230 }
231
232 int ajax_check_csrf(int level){
233 if( 0==cgi_csrf_safe(level) ){
234 ajax_route_error_csrf();
235 return 0;
236 }
237 return 1;
238 }
239
240 /*
241 ** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as
242 ** logging in the user.
@@ -224,17 +253,18 @@
253 int ajax_route_bootstrap(int requireWrite, int requirePost){
254 login_check_credentials();
255 if( requireWrite!=0 && g.perm.Write==0 ){
256 ajax_route_error(403,"Write permissions required.");
257 return 0;
258 }else if(requirePost && 0==cgi_csrf_safe(requirePost)){
259 ajax_route_error(403,
260 "CSRF violation (make sure sending of HTTP "
261 "Referer headers is enabled for XHR "
262 "connections).");
263 return 0;
264 }
265 cgi_set_content_type("application/json");
266 return 1;
267 }
268
269 /*
270 ** Helper for collecting filename/check-in request parameters.
@@ -283,12 +313,10 @@
313 ** AJAX_RENDER_PLAIN_TEXT mode.
314 **
315 ** iframe_height=integer (default=40) Height, in EMs of HTML preview
316 ** iframe.
317 **
 
 
318 ** Responds with the HTML content of the preview. On error it produces
319 ** a JSON response as documented for ajax_route_error().
320 **
321 ** Extra response headers:
322 **
@@ -341,10 +369,37 @@
369 }
370 if(zRenderMode!=0){
371 cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode);
372 }
373 }
374
375 /*
376 ** AJAX route /ajax/artifact.json.
377 ** URL arguments:
378 **
379 ** uuid=ARTIFACT_ID REQUIRED
380 **
381 ** and emits either:
382 **
383 ** { error: "..." }
384 **
385 ** with a non-200 response code or the artifact's manifest in JSON
386 ** form with a 200 response code.
387 */
388 void ajax_route_artifact_json(void){
389 const char *zUuid = P("uuid");
390 Blob json = BLOB_INITIALIZER;
391 login_check_credentials();
392 if( ! g.perm.Read ){
393 ajax_route_error_forbidden();
394 }else if( artifact_to_json_by_name(zUuid, &json) ){
395 @ %b(&json)
396 }else{
397 ajax_route_error_404("Cannot resolve artifact ID.");
398 }
399 blob_reset(&json);
400 }
401
402 #if INTERFACE
403 /*
404 ** Internal mapping of ajax sub-route names to various metadata.
405 */
@@ -351,11 +406,12 @@
406 struct AjaxRoute {
407 const char *zName; /* Name part of the route after "ajax/" */
408 void (*xCallback)(); /* Impl function for the route. */
409 int bWriteMode; /* True if requires write mode */
410 int bPost; /* True if requires POST (i.e. CSRF
411 ** verification). Value is passed to
412 ** cgi_csrf_safe(). */
413 };
414 typedef struct AjaxRoute AjaxRoute;
415 #endif /*INTERFACE*/
416
417 /*
@@ -392,20 +448,26 @@
448 const char * zName = P("name");
449 AjaxRoute routeName = {0,0,0,0};
450 const AjaxRoute * pRoute = 0;
451 const AjaxRoute routes[] = {
452 /* Keep these sorted by zName (for bsearch()) */
453 {"artifact.json", ajax_route_artifact_json, 0, 0},
454 {"preview-text", ajax_route_preview_text, 0, 1
455 /* Preview does not require write permissions in the repo. It
456 ** should arguably require write permissions simply to limit abuse
457 ** but doing means that /chat does not work without check-in
458 ** permissions:
459 **
460 ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898
461 **
462 ** This particular route is used by /fileedit and /chat, whereas
463 ** /wikiedit uses a simpler wiki-specific route.
464 */
465 /* TODO (2026-06-09): preview.txt, preview.md, preview.wiki as
466 ** shorthand for preview-text?filename=X.(txt|md|wiki), noting that
467 ** the filename is only used for mimetype determination. */
468 }
469 };
470
471 if(zName==0 || zName[0]==0){
472 ajax_route_error(400,"Missing required [route] 'name' parameter.");
473 return;
474
+931 -109
--- src/attach.c
+++ src/attach.c
@@ -22,41 +22,188 @@
2222
#include <assert.h>
2323
2424
/*
2525
** Given a presumedly legal attachment target name, this guesses the
2626
** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27
-** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
28
-** distinguish the target type.
27
+** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot distinguish
28
+** the target type.
29
+**
30
+** zTarget is an attachment target name: wiki page name, tech-note ID,
31
+** ticket ID, or forumpost hash.
32
+**
33
+** If bFull is true then it requires zTarget to be a full ID for
34
+** tech-notes and tickets, otherwise such IDs may be prefixes. If
35
+** bFull is false then tech-notes and tickets will perform a prefix
36
+** match, but it is up to the caller to provide enough of a prefix to
37
+** rule out ambiguity[^1]. When called repeatedly, this routine can
38
+** run a bit faster and more efficiently if bFull is true, but some
39
+** historical use cases call for prefix matches.
40
+**
41
+** Wiki page names always require an exact match.
42
+**
43
+** Forum posts are a special case:
44
+**
45
+** - They ignore the bFull flag. That is, they will do prefix matches
46
+** but will not match an ambiguous prefix.
47
+**
48
+** - It is up to the caller to, if needed, resolve zTarget using
49
+** forumpost_head_rid2() to resolve the RID of the earliest version
50
+** of the post, as that is the only one which attachments should
51
+** target.
2952
**
30
-** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
31
-** if needed, they resolve zTarget using forumpost_head_rid2() so that
32
-** they get the RID of the earliest version of the post, as that is
33
-** the only one which attachments should target.
53
+** [^1]: Historically (from the perspective of 2026-06) attachment
54
+** target lookups have used GLOB prefix matching but have taken no
55
+** measures to ensure that the prefix is unambiguous. Ergo we do the
56
+** same here. It is assumed that the caller passes enough of a prefix
57
+** to be unambiguous and that's worked out fine so far.
3458
*/
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 ){
59
+int attachment_target_type(const char *zTarget, int bFull){
60
+ if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
61
+ return 0;
62
+ }
63
+ if( symbolic_name_to_rid(zTarget, "f")>0 ){
64
+ /* Check forum posts first because they are the most likely target
65
+ ** as of 2026. We should arguably use something more
66
+ ** specialized/efficient than symbolic_name_to_rid(). */
3967
return CFTYPE_FORUM;
4068
}
41
- if( !q.pStmt ){
42
- db_static_prepare(
43
- &q,
69
+ if( bFull ){
70
+ static Stmt q = empty_Stmt_m;
71
+ int rc = 0;
72
+ if( !q.pStmt ){
73
+ db_static_prepare(
74
+ &q,
75
+ "SELECT CASE "
76
+ /* Ordered by presumed likelihood of attachments. */
77
+ "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n"
78
+ "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n"
79
+ "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n"
80
+ "ELSE 0 END",
81
+ CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT
82
+ );
83
+ }
84
+ db_bind_text(&q, ":tgt", zTarget);
85
+ if( SQLITE_ROW==db_step(&q) ){
86
+ rc = db_column_int(&q, 0);
87
+ }
88
+ db_reset(&q);
89
+ return rc;
90
+ }else{
91
+ return db_int(
92
+ 0,
4493
"SELECT CASE "
45
- "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
46
- "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
47
- "WHEN 'wiki-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
94
+ "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')"
95
+ " THEN %d\n"
96
+ "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')"
97
+ " THEN %d\n"
98
+ "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')"
99
+ " THEN %d\n"
48100
"ELSE 0 END",
49
- CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI
101
+ zTarget, CFTYPE_TICKET,
102
+ zTarget, CFTYPE_WIKI,
103
+ zTarget, CFTYPE_EVENT
50104
);
51105
}
52
- db_bind_text(&q, ":tgt", zTarget);
53
- if( SQLITE_ROW==db_step(&q) ){
54
- rc = db_column_int(&q, 0);
106
+}
107
+
108
+/*
109
+** Given an attachment target name, returns the target's blob.rid.
110
+** zTarget and bFull work as described for attachment_target_type().
111
+**
112
+** For forum posts, this always returns the RID of the first version
113
+** of the post, as attachments should always target that instance.
114
+*/
115
+int attachment_target_rid(const char *zTarget, int bFull){
116
+ int rid = 0;
117
+ const int eType = attachment_target_type(zTarget, bFull);
118
+ switch(eType){
119
+ case CFTYPE_TICKET:
120
+ case CFTYPE_EVENT:{
121
+ const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt";
122
+ rid = db_int(
123
+ 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
124
+ "WHERE tagname %s '%s-%q%s'\n"
125
+ "AND x.tagtype>0\n"
126
+ "AND x.tagid=t.tagid\n"
127
+ "AND x.rid=b.rid\n"
128
+ "ORDER BY x.mtime DESC",
129
+ bFull ? "=" : "GLOB"/*safe-for-%s*/,
130
+ zTagPrefix/*safe-for-%s*/,
131
+ zTarget,
132
+ bFull ? "" : "*"/*safe-for-%s*/
133
+ );
134
+ break;
135
+ }
136
+ case CFTYPE_FORUM:
137
+ rid = db_int(
138
+ 0, "SELECT f.fpid FROM forumpost f, blob b\n"
139
+ "WHERE f.fpid=b.rid\n"
140
+ "AND b.uuid %s '%q%s'",
141
+ bFull ? "=" : "GLOB"/*safe-for-%s*/,
142
+ zTarget,
143
+ bFull ? "" : "*"/*safe-for-%s*/
144
+ );
145
+ if( rid>0 ){
146
+ rid = forumpost_head_rid(rid);
147
+ }
148
+ break;
149
+ case CFTYPE_WIKI:
150
+ rid = db_int(
151
+ 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
152
+ "WHERE tagname='wiki-%q'\n"
153
+ "AND x.tagtype>0\n"
154
+ "AND x.tagid=t.tagid\n"
155
+ "AND x.rid=b.rid\n"
156
+ "ORDER BY x.mtime DESC",
157
+ zTarget
158
+ );
159
+ break;
160
+ default:
161
+ break;
162
+ }
163
+ return rid;
164
+}
165
+
166
+/*
167
+** For a given aritfact ID and type (from the CFTYPE_xyz enum),
168
+** returns true if the current user could hypothetically apply and
169
+** attachment to it, else returns 0.
170
+**
171
+** The rid is currently only relevant when eArtifactType is
172
+** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
173
+** not the head RID, to keep non-admins from attaching files to
174
+** threads which have since been taken over by another user (this
175
+** happens when an admin edits another user's post).
176
+*/
177
+int attach_user_may(int rid, int eArtifactType){
178
+ if( g.perm.Admin ) return 1;
179
+ if( !login_is_individual() ) return 0;
180
+ switch(eArtifactType){
181
+ case CFTYPE_FORUM:
182
+ return g.perm.AttachForum && forumpost_is_owner(rid, 0);
183
+ case CFTYPE_WIKI:
184
+ return g.perm.ApndWiki && g.perm.Attach;
185
+ case CFTYPE_TICKET:
186
+ return g.perm.ApndTkt && g.perm.Attach;
187
+ case CFTYPE_EVENT:
188
+ return g.perm.Write && g.perm.ApndWiki && g.perm.Attach;
189
+ default:
190
+ return 0;
55191
}
56
- db_reset(&q);
57
- return rc;
192
+}
193
+
194
+/*
195
+** Emits a single-button FORM which invokes
196
+** /attachadd with target=$zTarget.
197
+*/
198
+void attach_render_attachadd_button(const char *zTarget){
199
+ /* This could be changed from POST to GET, and arguably should so
200
+ ** that the target=X part becomes part of the resulting URL. */
201
+ @ <form method="post" action="%R/attachadd">\
202
+ @ <input type="hidden" name="target" value="%T(zTarget)">\
203
+ @ <input type="submit" value="Attach...">
204
+ @ </form>\
58205
}
59206
60207
/*
61208
** WEBPAGE: attachlist
62209
** List attachments.
@@ -69,20 +216,21 @@
69216
** At most one of technote=, tkt=, forumpost=, or page= may be supplied.
70217
**
71218
** If none are given, all attachments are listed. If one is given, only
72219
** attachments for the designated technote, ticket or wiki page are shown.
73220
**
74
-** HASH may be just a prefix of the relevant technical note or ticket
75
-** artifact hash, in which case all attachments of all technical notes or
76
-** tickets with the prefix will be listed. Forum posts, on the other hand,
77
-** require a unique hash prefix.
221
+** HASH may be just a prefix of the relevant forum post, technical
222
+** note, or ticket artifact hash, in which case all attachments of all
223
+** technical notes or tickets with the prefix will be listed. Forum
224
+** posts, on the other hand, require a unique hash or hash prefix.
78225
*/
79226
void attachlist_page(void){
80227
const char *zPage = P("page");
81228
const char *zTkt = P("tkt");
82229
const char *zTechNote = P("technote");
83230
const char *zForumPost = P("forumpost");
231
+ char *zLink = 0;
84232
Blob sql;
85233
Stmt q;
86234
87235
if( zPage && zTkt ) zTkt = 0;
88236
login_check_credentials();
@@ -102,32 +250,47 @@
102250
if( fnid<=0 ){
103251
webpage_error("Invalid forum post ID: %h", zForumPost);
104252
}
105253
blob_append_sql(&sql, " WHERE target="
106254
"(SELECT uuid FROM blob WHERE rid=%d)", fnid);
255
+ zLink = mprintf("forum post <a href='%R/forumpost/%t'>%#h</a>",
256
+ zForumPost, hash_digits(0), zForumPost);
107257
}else if( zPage ){
108258
if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
109259
style_header("Attachments To Wiki page %h", zPage);
110260
blob_append_sql(&sql, " WHERE target=%Q", zPage);
261
+ zLink = mprintf("wiki page <a href='%R/wiki?name=%t'>%h</a>",
262
+ zPage, zPage);
111263
}else if( zTkt ){
112264
if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; }
113265
style_header("Attachments To Ticket %S", zTkt);
114266
blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt);
267
+ zLink = mprintf("ticket <a href='%R/tktview?name=%t'>%#h</a>",
268
+ zTkt, hash_digits(0), zTkt);
115269
}else if( zTechNote ){
116270
if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
117271
style_header("Attachments To Tech Note %S", zTechNote);
118272
blob_append_sql(&sql, " WHERE target GLOB '%q*'",
119273
zTechNote);
274
+ zLink = mprintf("tech-note <a href='%R/technote?name=%t'>%#h</a>",
275
+ zTechNote, hash_digits(0), zTechNote);
120276
}else{
121277
if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){
122278
login_needed(g.anon.RdTkt || g.anon.RdWiki);
123279
return;
124280
}
125281
style_header("All Attachments");
126282
}
127283
blob_append_sql(&sql, " ORDER BY mtime DESC");
128284
db_prepare(&q, "%s", blob_sql_text(&sql));
285
+
286
+ if( zLink ){
287
+ @ <h2>Attachments for %s(zLink)</h2>
288
+ fossil_free(zLink);
289
+ zLink = 0;
290
+ }
291
+
129292
@ <ol>
130293
while( db_step(&q)==SQLITE_ROW ){
131294
const char *zDate;
132295
const char *zSrc;
133296
const char *zTarget;
@@ -137,10 +300,11 @@
137300
const char *zUuid;
138301
const char *zDispUser;
139302
const int attachid = db_column_int(&q, 7);
140303
int type;
141304
int i;
305
+ int bDeleted;
142306
char *zUrlTail = 0;
143307
144308
if( moderation_pending(attachid)
145309
&& !moderation_user_could(attachid, 1, 0) ){
146310
/* Elide entries which are currently pending moderation unless
@@ -160,11 +324,12 @@
160324
if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
161325
zFilename = &zFilename[i+1];
162326
i = -1;
163327
}
164328
}
165
- type = attachment_target_type(zTarget);
329
+ bDeleted = 0==zSrc || 0==zSrc[0];
330
+ type = attachment_target_type(zTarget, 1);
166331
switch( type ){
167332
case CFTYPE_TICKET:
168333
zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
169334
break;
170335
case CFTYPE_EVENT:
@@ -176,20 +341,32 @@
176341
case CFTYPE_WIKI:
177342
zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename);
178343
break;
179344
}
180345
@ <li><p>
181
- @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>
346
+ if( bDeleted ){
347
+ @ <s>\
348
+ }
349
+ @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>\
182350
moderation_pending_www(attachid);
183
- @ <br><a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
184
- @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]<br>
351
+ @ <br>\
352
+ @ <a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
353
+ @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]\
354
+ if( bDeleted ){
355
+ @ </s>
356
+ }
357
+ @ <br>
185358
if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++;
186359
if( zComment && zComment[0] ){
187
- @ %!W(zComment)<br>
360
+ /* FIXME (2026-06-05): Honor the N-card (comment mimetype). %W
361
+ ** (historically used here) assumes fossil-wiki and the
362
+ ** fileformat.wiki doc has always claimed that it defaults to
363
+ ** text/plain. /ainfo assumes it is plain text. */
364
+ @ %h(zComment)<br>
188365
}
189366
if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){
190
- if( zSrc==0 || zSrc[0]==0 ){
367
+ if( bDeleted ){
191368
zSrc = "Deleted from";
192369
}else {
193370
zSrc = "Added to";
194371
}
195372
switch( type ){
@@ -362,41 +539,81 @@
362539
Manifest *pManifest;
363540
364541
db_begin_transaction();
365542
blob_init(&content, aContent, szContent);
366543
pManifest = manifest_parse(&content, 0, 0);
544
+ addCompress = pManifest!=0;
367545
manifest_destroy(pManifest);
368546
blob_init(&content, aContent, szContent);
369
- if( pManifest ){
547
+ if( addCompress ){
370548
blob_compress(&content, &content);
371
- addCompress = 1;
372549
}
373550
rid = content_put_ex(&content, 0, 0, 0, needModerator);
374
- zUUID = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
551
+ zUUID = rid_to_uuid(rid);
375552
blob_zero(&manifest);
376553
for(i=n=0; zName[i]; i++){
377554
if( zName[i]=='/' || zName[i]=='\\' ) n = i+1;
378555
}
379556
zName += n;
380557
if( zName[0]==0 ) zName = "unknown";
381558
blob_appendf(&manifest, "A %F%s %F %s\n",
382559
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);
560
+ if( zComment!=0 && zComment[0]!=0 ){
561
+ while( fossil_isspace(zComment[0]) ) zComment++;
562
+ n = strlen(zComment);
563
+ while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
564
+ if( n>0 ){
565
+ blob_appendf(&manifest, "C %#F\n", n, zComment);
566
+ }
388567
}
389568
zDate = date_in_standard_format("now");
390
- blob_appendf(&manifest, "D %s\n", zDate);
569
+ blob_appendf(&manifest, "D %z\n", zDate);
391570
blob_appendf(&manifest, "U %F\n", login_name());
392571
md5sum_blob(&manifest, &cksum);
393572
blob_appendf(&manifest, "Z %b\n", &cksum);
394573
attach_put(&manifest, rid, needModerator);
395574
assert( blob_is_reset(&manifest) );
396575
db_end_transaction(0);
397576
}
577
+
578
+/*
579
+** Renders the "legacy" (static) /attachadd form. One of the first
580
+** four arguments must be non-NULL and the other three must be NULL.
581
+** zComment may be NULL, as may zFrom. See the call sites for more
582
+** context.
583
+*/
584
+static void attach_render_legacy_form(const char *zForumPost,
585
+ const char *zTechNote,
586
+ const char *zTicket,
587
+ const char *zWikiPage,
588
+ const char *zComment,
589
+ const char *zFrom){
590
+ form_begin("enctype='multipart/form-data' id='attachadd-legacy-form'",
591
+ "%R/attachadd");
592
+ @ <div>\
593
+ @ File to Attach:
594
+ @ <input type="file" name="f" size="60"><br>
595
+ @ Description:<br>
596
+ @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
597
+ @ >%h(zComment)</textarea><br>
598
+ if( zForumPost ){
599
+ @ <input type="hidden" name="forumpost" value="%h(zForumPost)">\
600
+ }else if( zTicket ){
601
+ @ <input type="hidden" name="tkt" value="%h(zTicket)">\
602
+ }else if( zTechNote ){
603
+ @ <input type="hidden" name="technote" value="%h(zTechNote)">\
604
+ }else if( zWikiPage ){
605
+ @ <input type="hidden" name="page" value="%h(zWikiPage)">\
606
+ }
607
+ @ <input type="hidden" name="from" value="%h(zFrom)">\
608
+ @ <input type="submit" name="ok" value="Add Attachment">\
609
+ @ <input type="submit" name="cancel" value="Cancel">\
610
+ @ </div>
611
+ captcha_generate(0);
612
+ login_insert_csrf_secret();
613
+ @ </form>
614
+}
398615
399616
/*
400617
** WEBPAGE: attachadd
401618
** Add a new attachment.
402619
**
@@ -404,33 +621,54 @@
404621
** page=WIKIPAGE
405622
** technote=HASH
406623
** forumpost=HASH
407624
** from=URL
408625
**
626
+** Adds a POSTed file attachment to the given target.
627
+**
628
+** Or the "version 2" interface:
629
+**
630
+** target=ATTACHMENT_TARGET
631
+**
632
+** Behaves as documented for attachaddV2_page().
409633
*/
410634
void attachadd_page(void){
411
- const char *zPage = P("page");
412
- const char *zForumPost = P("forumpost");
413
- const char *zTkt = P("tkt");
414
- const char *zTechNote = P("technote");
415
- const char *zFrom = P("from");
416
- const char *aContent = P("f");
417
- const char *zName = PD("f:filename","unknown");
418
- const char *zComment = PD("comment", "");
635
+ const char *zPage;
636
+ const char *zForumPost;
637
+ const char *zTkt;
638
+ const char *zTechNote;
639
+ const char *aContent;
640
+ const char *zName;
641
+ const char *zComment;
419642
const char *zTarget;
420
- char * zTo = 0;
643
+ const char *zFrom; /* Origin page - redirect here after saving */
644
+ char *zTo = 0; /* Optionally redirect here after saving */
421645
char *zTargetType = 0;
422646
char *zExtraFree = 0;
423
- int szContent = atoi(PD("f:bytes","0"));
647
+ int szContent;
424648
int goodCaptcha = 1;
425649
int szLimit = 0;
426650
651
+ if( P("target")!=0 ){
652
+ attachaddV2_page();
653
+ return;
654
+ }
655
+ zPage = P("page");
656
+ zForumPost = P("forumpost");
657
+ zTkt = P("tkt");
658
+ zTechNote = P("technote");
659
+ zFrom = P("from");
660
+ aContent = P("f");
661
+ zName = PD("f:filename","unknown");
662
+ zComment = PD("comment", "");
663
+ szContent = atoi(PD("f:bytes","0"));
664
+
427665
if( zFrom==0 ) zFrom = mprintf("%R/home");
428666
if( P("cancel") ) cgi_redirect(zFrom);
429667
if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){
430668
webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X,"
431
- " or technote=X");
669
+ " technote=X, or target=X");
432670
}
433671
login_check_credentials();
434672
if( zForumPost ){
435673
int fpid;
436674
if( g.perm.AttachForum==0 ){
@@ -445,26 +683,23 @@
445683
"forum posts.");
446684
}
447685
zTarget = zExtraFree = rid_to_uuid(fpid);
448686
zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>",
449687
zTarget, zForumPost);
450
- zTo = 1
451
- ? mprintf("%R/forumpost/%S", zTarget)
452
- : mprintf("%R/attachview?forumpost=%T&file=%T",
453
- zTarget, zName)
454
- /* Or we could return directly to the forum post. */;
688
+ zTo = zFrom ? 0 : mprintf("%R/forumpost/%S", zTarget);
455689
}else if( zPage ){
456690
if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
457691
login_needed(g.anon.ApndWiki && g.anon.Attach);
458692
return;
459693
}
460694
if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){
461695
fossil_redirect_home();
462696
}
463697
zTarget = zPage;
464
- zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%h\">%h</a>",
698
+ zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%t\">%h</a>",
465699
zPage, zPage);
700
+ zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
466701
}else if ( zTechNote ){
467702
if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
468703
login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
469704
return;
470705
}
@@ -474,11 +709,11 @@
474709
if( zTechNote==0) fossil_redirect_home();
475710
}
476711
zTarget = zTechNote;
477712
zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>",
478713
zTechNote, zTechNote);
479
-
714
+ zTo = zFrom ? 0 : mprintf("%R/technote/%S", zTarget);
480715
}else{
481716
assert( zTkt );
482717
if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
483718
login_needed(g.anon.ApndTkt && g.anon.Attach);
484719
return;
@@ -487,21 +722,25 @@
487722
zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag"
488723
" WHERE tagname GLOB 'tkt-%q*'", zTkt);
489724
if( zTkt==0 ) fossil_redirect_home();
490725
}
491726
zTarget = zTkt;
492
- zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
727
+ zTargetType = mprintf("Ticket <a href=\"%R/tktview/%S\">%S</a>",
493728
zTkt, zTkt);
729
+ zTo = zFrom ? 0 : mprintf("%R/tktview/%S", zTarget);
494730
}
495731
szLimit = db_get_int("attachment-size-limit", 0);
496732
if( szContent<0 || (szLimit && szContent>szLimit) ){
497733
/* This check must be done late so that zTargetType is set up. */
498734
@ <p class="generalError">Attachment %h(zName) is too large.
499735
@ <a href="%R/help/attachment-size-limit">Limit</a> is
500736
@ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
501737
/* Fall through and render form. */
502
- }else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
738
+ }else if( P("ok")
739
+ && cgi_csrf_safe(2)
740
+ && szContent>0
741
+ && (goodCaptcha = captcha_is_correct(0)) ){
503742
int needModerator = (zForumPost!=0 && forum_need_moderation()) ||
504743
(zTkt!=0 && ticket_need_moderation(0)) ||
505744
(zPage!=0 && wiki_need_moderation(0));
506745
attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment);
507746
cgi_redirect(zTo ? zTo : zFrom);
@@ -511,35 +750,374 @@
511750
style_header("Add Attachment");
512751
if( !goodCaptcha ){
513752
@ <p class="generalError">Error: Incorrect security code.</p>
514753
}
515754
@ <h2>Add Attachment To %s(zTargetType)</h2>
516
- form_begin("enctype='multipart/form-data'", "%R/attachadd");
517
- @ <div>
518
- @ File to Attach:
519
- @ <input type="file" name="f" size="60"><br>
520
- @ Description:<br>
521
- @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
522
- @ >%h(zComment)</textarea><br>
523
- if( zForumPost ){
524
- @ <input type="hidden" name="forumpost" value="%h(zTarget)">
525
- }else if( zTkt ){
526
- @ <input type="hidden" name="tkt" value="%h(zTkt)">
527
- }else if( zTechNote ){
528
- @ <input type="hidden" name="technote" value="%h(zTechNote)">
755
+ attach_render_legacy_form(zForumPost, zTechNote, zTkt, zPage,
756
+ zComment, zFrom);
757
+ builtin_fossil_js_bundle_or("attach", NULL);
758
+ style_finish_page();
759
+ fossil_free(zTargetType);
760
+ fossil_free(zExtraFree);
761
+}
762
+
763
+/*
764
+** WEBPAGE: attachadd_ajax_post hidden
765
+**
766
+** Used by attachadd V2 to handle attachments via POST requests with:
767
+**
768
+** target=ATTACHMENT_TARGET
769
+** file1..fileN=FILE_OBJECTS
770
+** dryrun=0|1
771
+**
772
+** Each posted file in the set file1..fileN gets attached to the given
773
+** target, permissions permitting. If dryrun>0 then the change is
774
+** rolled back instead of committed. target=X must refer to a full
775
+** target ID, not a prefix.
776
+**
777
+** Responds with JSON: an empty object on success and
778
+** {error:"message"} on error. The on-success response structure is
779
+** subject to amendment.
780
+*/
781
+void attachadd_ajax_post(void){
782
+ const char *zTarget;
783
+ char *zExtraFree = 0;
784
+ int eTgtType = 0;
785
+ int bNeedsModeration = 0;
786
+ int goodCaptcha = 1;
787
+ int bRollback = 0; /* Roll back if true. */
788
+
789
+ if( ! ajax_route_bootstrap(0, 1) ){
790
+ return;
791
+ }else if( !(goodCaptcha = captcha_is_correct(0)) ){
792
+ goto ajax_err_403;
793
+ }else if( !ajax_check_csrf(2) ){
794
+ return;
795
+ }
796
+ db_begin_transaction();
797
+ zTarget = P("target");
798
+ eTgtType = attachment_target_type(zTarget, 1);
799
+ CX("{");
800
+ switch( eTgtType ){
801
+ default:
802
+ case 0:
803
+ ajax_route_error(400, "Invalid attachment target.");
804
+ db_rollback_transaction();
805
+ return;
806
+ case CFTYPE_FORUM:{
807
+ int fpid;
808
+ if( g.perm.AttachForum==0 ){
809
+ goto ajax_err_403;
810
+ }
811
+ fpid = forumpost_head_rid2(zTarget);
812
+ if( fpid<=0 ){
813
+ goto ajax_err_404;
814
+ }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
815
+ ajax_route_error(403, "Only admins can attach files to "
816
+ "other users' forum posts.");
817
+ db_rollback_transaction();
818
+ return;
819
+ }
820
+ zTarget = zExtraFree = rid_to_uuid(fpid);
821
+ bNeedsModeration = forum_need_moderation();
822
+ break;
823
+ }
824
+ case CFTYPE_EVENT:{
825
+ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
826
+ goto ajax_err_403;
827
+ }
828
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
829
+ zTarget) ){
830
+ zTarget = zExtraFree =
831
+ db_text(0, "SELECT substr(tagname,7) FROM tag"
832
+ " WHERE tagname GLOB 'event-%q*'", zTarget);
833
+ if( zTarget==0){
834
+ goto ajax_err_404;
835
+ }
836
+ }
837
+ bNeedsModeration = 0;
838
+ break;
839
+ }
840
+ case CFTYPE_TICKET:{
841
+ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
842
+ goto ajax_err_403;
843
+ }
844
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
845
+ zTarget) ){
846
+ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
847
+ " WHERE tagname GLOB 'tkt-%q*'", zTarget);
848
+ if( zTarget==0 ){
849
+ goto ajax_err_404;
850
+ }
851
+ }
852
+ bNeedsModeration = ticket_need_moderation(0);
853
+ break;
854
+ }
855
+ case CFTYPE_WIKI:{
856
+ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
857
+ goto ajax_err_403;
858
+ }
859
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
860
+ zTarget) ){
861
+ goto ajax_err_404;
862
+ }
863
+ bNeedsModeration = wiki_need_moderation(0);
864
+ break;
865
+ }
866
+ }
867
+
868
+ if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){
869
+ CX("}");
870
+ if( atoi(PD("dryrun","0"))>0 ){
871
+ bRollback = 1;
872
+ }
873
+ }/*else error response was set up*/
874
+ fossil_free(zExtraFree);
875
+ db_end_transaction(bRollback);
876
+ return;
877
+ajax_err_403:
878
+ if( db_transaction_nesting_depth()>0 ){
879
+ db_rollback_transaction();
880
+ }
881
+ ajax_route_error_forbidden();
882
+ return;
883
+ajax_err_404:
884
+ assert( db_transaction_nesting_depth()>0 );
885
+ db_rollback_transaction();
886
+ ajax_route_error(404, "Target not found.");
887
+ return;
888
+}
889
+
890
+/*
891
+** A helper for AJAX-style routines which accept file attachments via
892
+** POST. zTarget must be a full attachment target. bNeedsModeration
893
+** must be true if the attachment requires moderation.
894
+**
895
+** It is up to the caller to have validated all security measures
896
+** before calling this.
897
+**
898
+** This looks for POSTed files names "file1".."fileN", stopping when
899
+** it finds no entry. Returns the number of entries attached to the
900
+** target or a negative value on error (in which case the current db
901
+** transaction will be in a rollback state).
902
+**
903
+** The only errors are currently attachment size limit violations:
904
+** attachments must have a non-0 size and if the attachment-size-limit
905
+** setting is >0 then each file's size must be <= that.
906
+**
907
+** If this returns a negative value, it will have populated an error
908
+** response using ajax_route_error(). On success it produces no
909
+** output.
910
+**
911
+** ACHTUNG: if zTarget is a forum post, it "really should" be the ID
912
+** of the first version of that post, as that's where attachments are
913
+** intended to be applied so that they can be found and removed
914
+** consistently. Potential TODO is have this function do that if
915
+** attachment_target_type(zTarget,1)!=0 but it would (for current
916
+** uses) require duplicating work already done in the callers.
917
+*/
918
+int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){
919
+ int i;
920
+ int rc = 0;
921
+ int n = 0;
922
+ int szLimit; /* attachment-max-size setting */
923
+ char aKeyPrefix[20]; /* Buffer for key "file%d" */
924
+ char aKeySize[30]; /* Buffer for key "file%d:bytes" */
925
+ char aKeyName[30]; /* Buffer for key "file%d:filename" */
926
+ char aKeyDesc[30]; /* Buffer for key "file%d_desc" */
927
+
928
+ db_begin_transaction();
929
+ szLimit = db_get_int("attachment-size-limit", 0);
930
+
931
+ for(i = 1; ; ++i, ++n){
932
+ /* Look for P("fileN"), where N=1..n */
933
+ const char *zContent;
934
+ const char *zFilename;
935
+ int szContent;
936
+ sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i);
937
+ zContent = P(aKeyPrefix);
938
+ if( !zContent ){
939
+ /* End of the list. */
940
+ break;
941
+ }
942
+ sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes",
943
+ aKeyPrefix);
944
+ szContent = atoi(PD(aKeySize,"-1"));
945
+ if( szContent<=0 ){
946
+ rc = -ajax_route_error(400,"Invalid file size: %d", szContent);
947
+ break;
948
+ }else if( szLimit>0 && szContent>szLimit ){
949
+ rc = -ajax_route_error(413, "File size limit is %d bytes.", szLimit);
950
+ break;
951
+ }else{
952
+ sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
953
+ aKeyPrefix);
954
+ sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
955
+ aKeyPrefix);
956
+ if( 0==(zFilename=P(aKeyName)) ){
957
+ rc = -ajax_route_error(400, "Missing filename.");
958
+ break;
959
+ }
960
+ attach_commit(zFilename, zTarget, zContent, szContent,
961
+ bNeedsModeration, P(aKeyDesc));
962
+ }
963
+ }
964
+ if( rc<0 ){
965
+ db_rollback_transaction();
966
+ return rc;
529967
}else{
530
- @ <input type="hidden" name="page" value="%h(zPage)">
968
+ db_commit_transaction();
969
+ return n;
970
+ }
971
+}
972
+
973
+/*
974
+** Proxy for /attachadd?target=X
975
+**
976
+** Lists attachments for, and can add them to, a target artifact.
977
+**
978
+** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
979
+** from=ORIGINATING_URL
980
+**
981
+** Works like /attachadd but uses a JS-based interactive attachment
982
+** selector.
983
+**
984
+** from=X tells it where to redirect to when it's done.
985
+**
986
+** This page requires a post-2018-ish JS-capable browser.
987
+*/
988
+void attachaddV2_page(void){
989
+ const char *zFrom = P("from");
990
+ const char *zTarget = P("target");
991
+ char *zTo = 0;
992
+ char *zTargetType = 0;
993
+ char *zExtraFree = 0;
994
+ int eTgtType = 0;
995
+ int goodCaptcha = 1;
996
+ char const * noJsArgs[] = {0,0,0,0}; /* Args for noscript form */
997
+
998
+ if( P("cancel") ) cgi_redirect(zFrom);
999
+ if( 0==zTarget ){
1000
+ webpage_error("Requires target=X");
1001
+ }
1002
+ login_check_credentials();
1003
+ eTgtType = attachment_target_type(zTarget, 1);
1004
+ switch( eTgtType ){
1005
+ default:
1006
+ case 0:
1007
+ webpage_error("Cannot resolve target=%h.", zTarget);
1008
+ break;
1009
+ case CFTYPE_FORUM:{
1010
+ int fpid;
1011
+ if( g.perm.AttachForum==0 ){
1012
+ login_needed(g.anon.AttachForum);
1013
+ return;
1014
+ }
1015
+ fpid = forumpost_head_rid2(zTarget);
1016
+ if( fpid<=0 ){
1017
+ webpage_error("Invalid forum post ID: %h", zTarget);
1018
+ }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
1019
+ webpage_error("Only admins can attach files to other users' "
1020
+ "forum posts.");
1021
+ }
1022
+ zTarget = zExtraFree = rid_to_uuid(fpid);
1023
+ noJsArgs[0] = zTarget;
1024
+ zTargetType = mprintf(
1025
+ "Forum post <a href=\"%R/forumpost/%S\">%.16h</a>",
1026
+ zTarget, zTarget
1027
+ );
1028
+ zTo = mprintf("%R/forumpost/%S", zTarget);
1029
+ break;
1030
+ }
1031
+ case CFTYPE_EVENT:{
1032
+ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1033
+ login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
1034
+ return;
1035
+ }
1036
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1037
+ zTarget) ){
1038
+ zTarget = db_text(0, "SELECT substr(tagname,7) FROM tag"
1039
+ " WHERE tagname GLOB 'event-%q*'",
1040
+ zTarget);
1041
+ if( zTarget==0) fossil_redirect_home();
1042
+ }
1043
+ zTo = zFrom ? 0 : mprintf("%R/technote?name=%T", zTarget);
1044
+ zTargetType = mprintf("Tech-note <a href=\"%R/technote/%s\">%S</a>",
1045
+ zTarget, zTarget);
1046
+ noJsArgs[1] = zTarget;
1047
+ break;
1048
+ }
1049
+ case CFTYPE_TICKET:{
1050
+ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
1051
+ login_needed(g.anon.ApndTkt && g.anon.Attach);
1052
+ return;
1053
+ }
1054
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
1055
+ zTarget) ){
1056
+ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
1057
+ " WHERE tagname GLOB 'tkt-%q*'", zTarget);
1058
+ if( zTarget==0 ) fossil_redirect_home();
1059
+ }
1060
+ zTo = zFrom ? 0 : mprintf("%R/tktview/%t", zTarget);
1061
+ zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
1062
+ zTarget, zTarget);
1063
+ noJsArgs[2] = zTarget;
1064
+ break;
1065
+ }
1066
+ case CFTYPE_WIKI:{
1067
+ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1068
+ login_needed(g.anon.ApndWiki && g.anon.Attach);
1069
+ return;
1070
+ }
1071
+ if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1072
+ zTarget) ){
1073
+ fossil_redirect_home();
1074
+ }
1075
+ zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
1076
+ zTargetType = mprintf(
1077
+ "Wiki page <a href=\"%R/wiki?name=%h\">%h</a>",
1078
+ zTarget, zTarget
1079
+ );
1080
+ noJsArgs[3] = zTarget;
1081
+ break;
1082
+ }
1083
+ }
1084
+
1085
+ db_begin_transaction();
1086
+
1087
+ style_set_current_feature("attach");
1088
+ style_header("Add Attachment");
1089
+ if( !goodCaptcha ){
1090
+ @ <p class="generalError">Error: Incorrect security code.</p>
1091
+ }
1092
+ @ <h2>Attachments for %s(zTargetType)</h2>
1093
+ attachment_list(zTarget, NULL,
1094
+ ATTACHLIST_SIZE | ATTACHLIST_HIDE_UNAPPROVED);
1095
+ attach_render_legacy_form(
1096
+ noJsArgs[0], noJsArgs[1], noJsArgs[2],
1097
+ noJsArgs[3], 0,
1098
+ zFrom ? zFrom : (zTo ? zTo : (zTo=mprintf("%R/home")))
1099
+ );
1100
+ @ <div id='attachadd-form-wrapper' class='hidden'>
1101
+ /* fossil.attach.js populates this DIV with the attachment widget,
1102
+ ** imports these hidden fields, and removes the legacy form. */
1103
+ @ <input type="hidden" name="target" value="%h(zTarget)">
1104
+ if( zFrom ){
1105
+ @ <input type="hidden" name="from" value="%h(zFrom)">
5311106
}
532
- @ <input type="hidden" name="from" value="%h(zFrom)">
533
- @ <input type="submit" name="ok" value="Add Attachment">
534
- @ <input type="submit" name="cancel" value="Cancel">
535
- @ </div>
1107
+ if( zTo ){
1108
+ @ <input type="hidden" name="to" value="%h(zTo)">
1109
+ }
5361110
captcha_generate(0);
537
- @ </form>
1111
+ login_insert_csrf_secret();
1112
+ @ </div>
1113
+ builtin_fossil_js_bundle_or("attach", NULL);
1114
+ db_end_transaction(0);
5381115
style_finish_page();
5391116
fossil_free(zTargetType);
5401117
fossil_free(zExtraFree);
1118
+ fossil_free(zTo);
5411119
}
5421120
5431121
/*
5441122
** WEBPAGE: ainfo
5451123
** URL: /ainfo?name=ARTIFACTID
@@ -600,15 +1178,17 @@
6001178
&& db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget)
6011179
){
6021180
if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; }
6031181
zTktUuid = zTarget;
6041182
showDelMenu = g.perm.WrTkt;
605
- }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",zTarget) ){
1183
+ }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1184
+ zTarget) ){
6061185
if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
6071186
zWikiName = zTarget;
6081187
showDelMenu = g.perm.WrWiki;
609
- }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",zTarget) ){
1188
+ }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1189
+ zTarget) ){
6101190
if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
6111191
zTNUuid = zTarget;
6121192
showDelMenu = g.perm.Write && g.perm.WrWiki;
6131193
}
6141194
if( showDelMenu ){
@@ -632,11 +1212,13 @@
6321212
Blob cksum;
6331213
const char *zFile = zName;
6341214
6351215
if( !bUserIsOwner ){
6361216
if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){
637
- webpage_error("Only admins can delete other users' attachments.");
1217
+ webpage_error(
1218
+ "Only admins can delete other users' attachments."
1219
+ );
6381220
}
6391221
}
6401222
db_begin_transaction();
6411223
blob_zero(&manifest);
6421224
for(i=n=0; zFile[i]; i++){
@@ -656,14 +1238,14 @@
6561238
@ <p>The attachment below has been deleted.</p>
6571239
fossil_free(zNewDate);
6581240
}
6591241
6601242
if( P("del")
661
- && ((zForumPost && (bUserIsOwner || forumpost_may_close())) ||
662
- (zTktUuid && g.perm.WrTkt) ||
663
- (zWikiName && g.perm.WrWiki) ||
664
- (zTNUuid && g.perm.Write && g.perm.WrWiki))
1243
+ && ((zForumPost && (bUserIsOwner || forumpost_may_close()))
1244
+ || (zTktUuid && g.perm.WrTkt)
1245
+ || (zWikiName && g.perm.WrWiki)
1246
+ || (zTNUuid && g.perm.Write && g.perm.WrWiki))
6651247
){
6661248
form_begin(0, "%R/ainfo/%!S", zUuid);
6671249
@ <p>Confirm you want to delete the attachment shown below.
6681250
@ <input type="submit" name="confirm" value="Confirm">
6691251
login_insert_csrf_secret();
@@ -711,20 +1293,22 @@
7111293
@ (%d(rid))
7121294
}
7131295
modPending = moderation_pending_www(rid);
7141296
if( zForumPost ){
7151297
@ <tr><th>Forum&nbsp;Post:</th>
716
- @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a></td></tr>
1298
+ @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a>\
1299
+ @ </td></tr>
7171300
}else if( zTktUuid ){
7181301
@ <tr><th>Ticket:</th>
7191302
@ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr>
7201303
}else if( zTNUuid ){
7211304
@ <tr><th>Tech Note:</th>
7221305
@ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr>
7231306
}else if( zWikiName ){
7241307
@ <tr><th>Wiki&nbsp;Page:</th>
725
- @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a></td></tr>
1308
+ @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a>\
1309
+ @ </td></tr>
7261310
}
7271311
@ <tr><th>Date:</th><td>
7281312
hyperlink_to_date(zDate, "</td></tr>");
7291313
@ <tr><th>User:</th><td>
7301314
hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>");
@@ -735,22 +1319,42 @@
7351319
}
7361320
@ <tr><th>Filename:</th><td>%h(zName)</td></tr>
7371321
if( g.perm.Setup ){
7381322
@ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr>
7391323
}
740
- @ <tr><th valign="top">Description:</th><td valign="top">%h(zDesc)</td></tr>
1324
+ @ <tr><th valign="top">Description:</th>\
1325
+ /* FIXME (2026-06-05): Honor the N-card (comment mimetype). */
1326
+ @ <td valign="top">%h(zDesc)</td></tr>
7411327
@ </table>
7421328
7431329
if( modPending && (isModerator || bUserIsOwner) ){
7441330
@ <div class="section">Moderation</div>
7451331
@ <blockquote>
7461332
form_begin(0, "%R/ainfo/%s", zUuid);
7471333
@ <label><input type="radio" name="modaction" value="delete">
7481334
@ Delete this attachment</label><br>
7491335
if( isModerator ){
750
- @ <label><input type="radio" name="modaction" value="approve">
751
- @ Approve this attachment</label><br>
1336
+#if 0
1337
+ /* TODO/FIXME (2026-06-03): only allow approval of an attachment
1338
+ ** if its target has been approved. Without this, we can end up
1339
+ ** with stale attachments which refer to rejected targets. We
1340
+ ** need a type-specific RID/UUID here, which requires
1341
+ ** refactoring above to get it. */
1342
+ const int tgtid = 0;
1343
+ if( moderation_pending(tgtid) ){
1344
+ @ <label><input type="radio" name="modaction" \
1345
+ @ disabled value="approve">
1346
+ @ <span class='modpending'>Cannot approve:
1347
+ @ target is pending moderation</span>\
1348
+ @ </label><br>
1349
+ }else
1350
+#else
1351
+ {
1352
+ @ <label><input type="radio" name="modaction" value="approve">
1353
+ @ Approve this attachment</label><br>
1354
+ }
1355
+#endif
7521356
}
7531357
@ <input type="submit" value="Submit">
7541358
login_insert_csrf_secret();
7551359
@ </form>
7561360
@ </blockquote>
@@ -767,11 +1371,12 @@
7671371
const char *z;
7681372
content_get(ridSrc, &attach);
7691373
blob_to_utf8_no_bom(&attach, 0);
7701374
z = blob_str(&attach);
7711375
if( zLn ){
772
- output_text_with_line_numbers(z, blob_size(&attach), zName, zLn, 1);
1376
+ output_text_with_line_numbers(z, blob_size(&attach),
1377
+ zName, zLn, 1);
7731378
}else{
7741379
@ <pre>
7751380
@ %h(z)
7761381
@ </pre>
7771382
}
@@ -798,10 +1403,13 @@
7981403
*/
7991404
#define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */
8001405
#define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */
8011406
#define ATTACHLIST_SIZE 0x04 /* add size */
8021407
#define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */
1408
+#define ATTACHLIST_DETAILS_CLOSED 0x10 /* Wrap in a closed DETAILS element */
1409
+#define ATTACHLIST_DETAILS_OPEN 0x20 /* Wrap in an open DETAILS element */
1410
+#define ATTACHLIST_HIDE_EMPTY 0x40 /* Skip if size<1 */
8031411
#endif
8041412
8051413
/*
8061414
** Output HTML to show a list of attachments.
8071415
*/
@@ -810,19 +1418,25 @@
8101418
const char *zHeader, /* Header to display with attachments */
8111419
const int flags /* ATTACHLIST_... flags */
8121420
){
8131421
int cnt = 0;
8141422
char szBuf[36] = {0}; /* scratchpad for attachment size value */
815
- const char * zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
1423
+ const char *zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
8161424
? " target=\"_blank\"" : "";
1425
+ const int bUseDetail = flags &
1426
+ (ATTACHLIST_DETAILS_CLOSED | ATTACHLIST_DETAILS_OPEN);
8171427
Stmt q;
1428
+
8181429
db_prepare(&q,
819
- "SELECT datetime(mtime,toLocal()), filename, user,"
820
- " (SELECT uuid FROM blob WHERE rid=attachid), src, target, "
821
- " attachid "
822
- " FROM attachment"
823
- " WHERE isLatest AND src!='' AND target=%Q"
1430
+ "SELECT datetime(mtime,toLocal()), a.filename, a.user,"
1431
+ " b1.uuid, a.src, a.target, a.attachid, b2.size\n"
1432
+ " FROM attachment a, blob b1, blob b2\n"
1433
+ " WHERE a.isLatest\n"
1434
+ " AND a.src IS NOT NULL\n"
1435
+ " AND a.target=%Q\n"
1436
+ " AND b1.rid=a.attachid\n"
1437
+ " AND b2.uuid=a.src\n"
8241438
" ORDER BY mtime DESC",
8251439
zTarget
8261440
);
8271441
while( db_step(&q)==SQLITE_ROW ){
8281442
const char *zDate = db_column_text(&q, 0);
@@ -832,36 +1446,51 @@
8321446
const char *zSrc = db_column_text(&q, 4);
8331447
const char *zTarget = db_column_text(&q, 5);
8341448
const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
8351449
const char *zTypeArg = 0; /* URL arg name for /attachdownload */
8361450
const int aid = db_column_int(&q, 6);
837
- const int iAType = attachment_target_type(zTarget);
1451
+ const int sz = db_column_int(&q, 7);
8381452
if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
8391453
&& moderation_pending(aid)
8401454
&& !moderation_user_could(aid, 1, 0) ){
8411455
continue;
1456
+ }
1457
+ if( sz<1 && (flags & ATTACHLIST_HIDE_EMPTY) ){
1458
+ /* Deleted or phantom items. */
1459
+ continue;
8421460
}
8431461
if( cnt==0 ){
844
- @ <section class='attachlist'>
1462
+ if( bUseDetail ){
1463
+ @ <details class='attachlist'
1464
+ if( ATTACHLIST_DETAILS_OPEN & flags ){
1465
+ @ open
1466
+ }
1467
+ @ >
1468
+ }else{
1469
+ @ <section class='attachlist'>
1470
+ }
8451471
if( flags & ATTACHLIST_HRULE_ABOVE ){
8461472
@ <hr>
8471473
}
848
- @ %s(zHeader)
1474
+ if( bUseDetail ){
1475
+ @ <summary>%s(zHeader)</summary>
1476
+ }else{
1477
+ @ %s(zHeader)
1478
+ }
8491479
@ <ul>
8501480
}
8511481
cnt++;
852
- switch( iAType ){
1482
+ switch( attachment_target_type(zTarget, 1) ){
8531483
case CFTYPE_TICKET: zTypeArg = "tkt"; break;
8541484
case CFTYPE_FORUM: zTypeArg = "forumpost"; break;
8551485
case CFTYPE_EVENT: zTypeArg = "technote"; break;
8561486
case CFTYPE_WIKI:
8571487
default: zTypeArg = "page"; break;
8581488
}
8591489
@ <li>
8601490
@ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a>
8611491
if( flags & ATTACHLIST_SIZE ){
862
- const int sz = db_int(0,"SELECT size FROM blob WHERE uuid=%Q", zSrc);
8631492
sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz);
8641493
}
8651494
@ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\
8661495
@&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)]
8671496
@ added by %h(zDispUser) on
@@ -870,11 +1499,15 @@
8701499
moderation_pending_www(aid);
8711500
@ </li>
8721501
}
8731502
if( cnt ){
8741503
@ </ul>
875
- @ </section>
1504
+ if( bUseDetail ){
1505
+ @ </details>
1506
+ }else{
1507
+ @ </section>
1508
+ }
8761509
}
8771510
db_finalize(&q);
8781511
}
8791512
8801513
/*
@@ -1023,15 +1656,204 @@
10231656
}
10241657
for(i = 2; i < g.argc; ++i){
10251658
const char *zPage = g.argv[i];
10261659
db_bind_text(&q, ":tgtname", zPage);
10271660
while(SQLITE_ROW == db_step(&q)){
1028
- const char * zTime = db_column_text(&q, 0);
1029
- const char * zSrc = db_column_text(&q, 1);
1030
- const char * zTarget = db_column_text(&q, 2);
1031
- const char * zName = db_column_text(&q, 3);
1661
+ const char *zTime = db_column_text(&q, 0);
1662
+ const char *zSrc = db_column_text(&q, 1);
1663
+ const char *zTarget = db_column_text(&q, 2);
1664
+ const char *zName = db_column_text(&q, 3);
10321665
printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName);
10331666
}
10341667
db_reset(&q);
10351668
}
10361669
db_finalize(&q);
10371670
}
1671
+
1672
+/*
1673
+** Renders the list of attachments for artifact pManifest as JSON to
1674
+** blob pOut. If pManifest->type is not one of (CFTYPE_TICKET,
1675
+** CFTYPE_FORUM, CFTYPE_EVENT, CFTYPE_WIKI) then it behaves as if the
1676
+** result set is empty.
1677
+**
1678
+** If there are no matching attachments then its behavior depends on
1679
+** emptyPolicy:
1680
+**
1681
+** <0 = emit a JSON NULL
1682
+** 0 = emit no output
1683
+** >0 = emit an empty JSON array
1684
+**
1685
+** If bLatestOnly is true then only the most recent entry for a given
1686
+** attachment is emitted, else all versions are emitted in descending
1687
+** mtime order.
1688
+**
1689
+** Returns the number of attachments.
1690
+**
1691
+** Output format:
1692
+**
1693
+** [{
1694
+** "uuid": attachment artifact hash,
1695
+** "src": hash of the attachment blob,
1696
+** "target": wiki page name or ticket/event ID,
1697
+** "filename": filename of attachment,
1698
+** "mtime": ISO-8601 timestamp UTC,
1699
+** "isLatest": true if this is the latest version of this file
1700
+** else false,
1701
+** }, ...once per attachment]
1702
+**
1703
+*/
1704
+int attachments_to_json(const Manifest *pManifest,
1705
+ Blob *pOut, int bLatestOnly,
1706
+ int emptyPolicy){
1707
+ int i = 0;
1708
+ Stmt q = empty_Stmt;
1709
+ char *zToFree = 0;
1710
+ const char *zTgt = 0;
1711
+ switch(pManifest->type){
1712
+ case CFTYPE_FORUM: zTgt = zToFree = rid_to_uuid(pManifest->rid);
1713
+ break;
1714
+ case CFTYPE_WIKI: zTgt = pManifest->zWikiTitle; break;
1715
+ case CFTYPE_EVENT: zTgt = pManifest->zEventId; break;
1716
+ case CFTYPE_TICKET: zTgt = pManifest->zTicketUuid; break;
1717
+ default:
1718
+ goto empty_result;
1719
+ }
1720
+ db_prepare(&q,
1721
+ "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1722
+ " b2.size, b1.uuid, a.user, a.comment\n"
1723
+ " FROM attachment a, blob b1, blob b2\n"
1724
+ " WHERE a.target=%Q\n"
1725
+ " AND a.src IS NOT NULL\n"
1726
+ " AND b1.rid=a.attachid\n"
1727
+ " AND b2.uuid=a.src\n"
1728
+ " AND (a.isLatest OR %d)\n"
1729
+ " ORDER BY a.target, a.isLatest DESC, a.mtime DESC\n",
1730
+ zTgt, !bLatestOnly
1731
+ );
1732
+ while(SQLITE_ROW == db_step(&q)){
1733
+ const char *zTime = db_column_text(&q, 0);
1734
+ const char *zSrc = db_column_text(&q, 1);
1735
+ const char *zTarget = db_column_text(&q, 2);
1736
+ const char *zName = db_column_text(&q, 3);
1737
+ const int isLatest = db_column_int(&q, 4);
1738
+ const int sz = db_column_int(&q, 5);
1739
+ const char *zUuid = db_column_text(&q, 6);
1740
+ const char *zUser = db_column_text(&q, 7);
1741
+ const char *zComment = db_column_text(&q, 8);
1742
+ if(!i++){
1743
+ blob_append_char(pOut, '[');
1744
+ }else{
1745
+ blob_append_char(pOut, ',');
1746
+ }
1747
+ blob_appendf(
1748
+ pOut,
1749
+ "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
1750
+ "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1751
+ "\"isLatest\": %s, \"user\": %!j, \"comment\": ",
1752
+ zUuid, zSrc, zTarget,
1753
+ zName, sz, zTime, isLatest ? "true" : "false",
1754
+ zUser
1755
+ );
1756
+ if( zComment && zComment[0] ){
1757
+ blob_appendf(pOut, "%!j", zComment);
1758
+ }else{
1759
+ blob_append_literal(pOut, "null");
1760
+ }
1761
+ blob_append_char(pOut, '}');
1762
+ }
1763
+ fossil_free(zToFree);
1764
+ db_finalize(&q);
1765
+ if(!i){
1766
+ empty_result:
1767
+ if( emptyPolicy>0 ){
1768
+ blob_append_literal(pOut, "[]");
1769
+ }else if( emptyPolicy<0 ){
1770
+ blob_append_literal(pOut, "null");
1771
+ }
1772
+ }else{
1773
+ blob_append_char(pOut, ']');
1774
+ }
1775
+ return i;
1776
+}
1777
+
1778
+/*
1779
+** COMMAND: test-attachment-target
1780
+**
1781
+** Usage: %fossil test-attachment-target TARGET_ID...
1782
+*/
1783
+void test_attachment_target_type_cmd(void){
1784
+ int i;
1785
+ verify_all_options();
1786
+ db_find_and_open_repository(0, 0);
1787
+ if( g.argc<3 ){
1788
+ usage("test-attachment-target TARGET_ID");
1789
+ return;
1790
+ }
1791
+ for( i = 2; i < g.argc; ++i ){
1792
+ const char *zTarget = g.argv[i];
1793
+ const int rid = attachment_target_rid(zTarget, 0);
1794
+ const int type = attachment_target_type(zTarget, 0);
1795
+ const char *zType = "<invalid>";
1796
+ switch(type){
1797
+ case CFTYPE_EVENT: zType = "technote"; break;
1798
+ case CFTYPE_FORUM: zType = "forumpost"; break;
1799
+ case CFTYPE_TICKET: zType = "ticket"; break;
1800
+ case CFTYPE_WIKI: zType = "wiki"; break;
1801
+ }
1802
+ fossil_print("%-20s = %-9s #%d %z\n",
1803
+ zTarget, zType, rid,
1804
+ rid>0 ? rid_to_uuid(rid) : 0);
1805
+ }
1806
+}
1807
+
1808
+
1809
+/*
1810
+** COMMAND: test-attachments-to-json
1811
+**
1812
+** Usage: %fossil test-attachments-to-json TARGET_ID...
1813
+**
1814
+** Options:
1815
+** --old List all versions of attachments. Default is to
1816
+** list only the latest.
1817
+** --full Require a full target ID, not a prefix.
1818
+**
1819
+** Emits a JSON array of attachments for the given attachment targets.
1820
+** The given IDs must be wiki page names, ticket hashes, tech-note
1821
+** hashes, or forum post hashes. By default it accepts hash prefixes
1822
+** but does no detection of ambiguity or cross-type prefix collisions
1823
+** so may emit curious results if given short, colliding IDs.
1824
+*/
1825
+void test_attachments_to_json_cmd(void){
1826
+ const int emptyPolicy = 1;
1827
+ const int bLatestOnly = find_option("old",0,0)==0;
1828
+ const int bFullId = find_option("full",0,0)!=0;
1829
+ int i;
1830
+
1831
+ verify_all_options();
1832
+ db_find_and_open_repository(0, 0);
1833
+ if( g.argc<3 ){
1834
+ usage("test-attachments-to-json TARGET_ID");
1835
+ return;
1836
+ }
1837
+ for( i = 2; i < g.argc; ++i ){
1838
+ const char *zTarget = g.argv[i];
1839
+ const int rid = attachment_target_rid(zTarget, bFullId);
1840
+ if( 0==rid ){
1841
+ fossil_print("** cannot resolve %s\n", zTarget);
1842
+ }else{
1843
+ Blob b = BLOB_INITIALIZER;
1844
+ Manifest *pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
1845
+ assert( pManifest );
1846
+ attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
1847
+ fossil_print("Attachments for %s: ", zTarget);
1848
+ if( b.nUsed ){
1849
+ char *zPretty = db_text(0,"SELECT json_pretty(%B)", &b);
1850
+ fossil_print("%s\n", zPretty);
1851
+ fossil_free(zPretty);
1852
+ }else{
1853
+ fossil_print("none\n");
1854
+ }
1855
+ blob_reset(&b);
1856
+ manifest_destroy(pManifest);
1857
+ }
1858
+ }
1859
+}
10381860
--- src/attach.c
+++ src/attach.c
@@ -22,41 +22,188 @@
22 #include <assert.h>
23
24 /*
25 ** Given a presumedly legal attachment target name, this guesses the
26 ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27 ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
28 ** distinguish the target type.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29 **
30 ** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
31 ** if needed, they resolve zTarget using forumpost_head_rid2() so that
32 ** they get the RID of the earliest version of the post, as that is
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 "
47 "WHEN 'wiki-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
 
 
 
48 "ELSE 0 END",
49 CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI
 
 
50 );
51 }
52 db_bind_text(&q, ":tgt", zTarget);
53 if( SQLITE_ROW==db_step(&q) ){
54 rc = db_column_int(&q, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55 }
56 db_reset(&q);
57 return rc;
 
 
 
 
 
 
 
 
 
 
 
58 }
59
60 /*
61 ** WEBPAGE: attachlist
62 ** List attachments.
@@ -69,20 +216,21 @@
69 ** At most one of technote=, tkt=, forumpost=, or page= may be supplied.
70 **
71 ** If none are given, all attachments are listed. If one is given, only
72 ** attachments for the designated technote, ticket or wiki page are shown.
73 **
74 ** HASH may be just a prefix of the relevant technical note or ticket
75 ** artifact hash, in which case all attachments of all technical notes or
76 ** tickets with the prefix will be listed. Forum posts, on the other hand,
77 ** require a unique hash prefix.
78 */
79 void attachlist_page(void){
80 const char *zPage = P("page");
81 const char *zTkt = P("tkt");
82 const char *zTechNote = P("technote");
83 const char *zForumPost = P("forumpost");
 
84 Blob sql;
85 Stmt q;
86
87 if( zPage && zTkt ) zTkt = 0;
88 login_check_credentials();
@@ -102,32 +250,47 @@
102 if( fnid<=0 ){
103 webpage_error("Invalid forum post ID: %h", zForumPost);
104 }
105 blob_append_sql(&sql, " WHERE target="
106 "(SELECT uuid FROM blob WHERE rid=%d)", fnid);
 
 
107 }else if( zPage ){
108 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
109 style_header("Attachments To Wiki page %h", zPage);
110 blob_append_sql(&sql, " WHERE target=%Q", zPage);
 
 
111 }else if( zTkt ){
112 if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; }
113 style_header("Attachments To Ticket %S", zTkt);
114 blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt);
 
 
115 }else if( zTechNote ){
116 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
117 style_header("Attachments To Tech Note %S", zTechNote);
118 blob_append_sql(&sql, " WHERE target GLOB '%q*'",
119 zTechNote);
 
 
120 }else{
121 if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){
122 login_needed(g.anon.RdTkt || g.anon.RdWiki);
123 return;
124 }
125 style_header("All Attachments");
126 }
127 blob_append_sql(&sql, " ORDER BY mtime DESC");
128 db_prepare(&q, "%s", blob_sql_text(&sql));
 
 
 
 
 
 
 
129 @ <ol>
130 while( db_step(&q)==SQLITE_ROW ){
131 const char *zDate;
132 const char *zSrc;
133 const char *zTarget;
@@ -137,10 +300,11 @@
137 const char *zUuid;
138 const char *zDispUser;
139 const int attachid = db_column_int(&q, 7);
140 int type;
141 int i;
 
142 char *zUrlTail = 0;
143
144 if( moderation_pending(attachid)
145 && !moderation_user_could(attachid, 1, 0) ){
146 /* Elide entries which are currently pending moderation unless
@@ -160,11 +324,12 @@
160 if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
161 zFilename = &zFilename[i+1];
162 i = -1;
163 }
164 }
165 type = attachment_target_type(zTarget);
 
166 switch( type ){
167 case CFTYPE_TICKET:
168 zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
169 break;
170 case CFTYPE_EVENT:
@@ -176,20 +341,32 @@
176 case CFTYPE_WIKI:
177 zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename);
178 break;
179 }
180 @ <li><p>
181 @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>
 
 
 
182 moderation_pending_www(attachid);
183 @ <br><a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
184 @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]<br>
 
 
 
 
 
185 if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++;
186 if( zComment && zComment[0] ){
187 @ %!W(zComment)<br>
 
 
 
 
188 }
189 if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){
190 if( zSrc==0 || zSrc[0]==0 ){
191 zSrc = "Deleted from";
192 }else {
193 zSrc = "Added to";
194 }
195 switch( type ){
@@ -362,41 +539,81 @@
362 Manifest *pManifest;
363
364 db_begin_transaction();
365 blob_init(&content, aContent, szContent);
366 pManifest = manifest_parse(&content, 0, 0);
 
367 manifest_destroy(pManifest);
368 blob_init(&content, aContent, szContent);
369 if( pManifest ){
370 blob_compress(&content, &content);
371 addCompress = 1;
372 }
373 rid = content_put_ex(&content, 0, 0, 0, needModerator);
374 zUUID = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
375 blob_zero(&manifest);
376 for(i=n=0; zName[i]; i++){
377 if( zName[i]=='/' || zName[i]=='\\' ) n = i+1;
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);
393 blob_appendf(&manifest, "Z %b\n", &cksum);
394 attach_put(&manifest, rid, needModerator);
395 assert( blob_is_reset(&manifest) );
396 db_end_transaction(0);
397 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
399 /*
400 ** WEBPAGE: attachadd
401 ** Add a new attachment.
402 **
@@ -404,33 +621,54 @@
404 ** page=WIKIPAGE
405 ** technote=HASH
406 ** forumpost=HASH
407 ** from=URL
408 **
 
 
 
 
 
 
 
409 */
410 void attachadd_page(void){
411 const char *zPage = P("page");
412 const char *zForumPost = P("forumpost");
413 const char *zTkt = P("tkt");
414 const char *zTechNote = P("technote");
415 const char *zFrom = P("from");
416 const char *aContent = P("f");
417 const char *zName = PD("f:filename","unknown");
418 const char *zComment = PD("comment", "");
419 const char *zTarget;
420 char * zTo = 0;
 
421 char *zTargetType = 0;
422 char *zExtraFree = 0;
423 int szContent = atoi(PD("f:bytes","0"));
424 int goodCaptcha = 1;
425 int szLimit = 0;
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427 if( zFrom==0 ) zFrom = mprintf("%R/home");
428 if( P("cancel") ) cgi_redirect(zFrom);
429 if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){
430 webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X,"
431 " or technote=X");
432 }
433 login_check_credentials();
434 if( zForumPost ){
435 int fpid;
436 if( g.perm.AttachForum==0 ){
@@ -445,26 +683,23 @@
445 "forum posts.");
446 }
447 zTarget = zExtraFree = rid_to_uuid(fpid);
448 zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>",
449 zTarget, zForumPost);
450 zTo = 1
451 ? mprintf("%R/forumpost/%S", zTarget)
452 : mprintf("%R/attachview?forumpost=%T&file=%T",
453 zTarget, zName)
454 /* Or we could return directly to the forum post. */;
455 }else if( zPage ){
456 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
457 login_needed(g.anon.ApndWiki && g.anon.Attach);
458 return;
459 }
460 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){
461 fossil_redirect_home();
462 }
463 zTarget = zPage;
464 zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%h\">%h</a>",
465 zPage, zPage);
 
466 }else if ( zTechNote ){
467 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
468 login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
469 return;
470 }
@@ -474,11 +709,11 @@
474 if( zTechNote==0) fossil_redirect_home();
475 }
476 zTarget = zTechNote;
477 zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>",
478 zTechNote, zTechNote);
479
480 }else{
481 assert( zTkt );
482 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
483 login_needed(g.anon.ApndTkt && g.anon.Attach);
484 return;
@@ -487,21 +722,25 @@
487 zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag"
488 " WHERE tagname GLOB 'tkt-%q*'", zTkt);
489 if( zTkt==0 ) fossil_redirect_home();
490 }
491 zTarget = zTkt;
492 zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
493 zTkt, zTkt);
 
494 }
495 szLimit = db_get_int("attachment-size-limit", 0);
496 if( szContent<0 || (szLimit && szContent>szLimit) ){
497 /* This check must be done late so that zTargetType is set up. */
498 @ <p class="generalError">Attachment %h(zName) is too large.
499 @ <a href="%R/help/attachment-size-limit">Limit</a> is
500 @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
501 /* Fall through and render form. */
502 }else if( P("ok") && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){
 
 
 
503 int needModerator = (zForumPost!=0 && forum_need_moderation()) ||
504 (zTkt!=0 && ticket_need_moderation(0)) ||
505 (zPage!=0 && wiki_need_moderation(0));
506 attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment);
507 cgi_redirect(zTo ? zTo : zFrom);
@@ -511,35 +750,374 @@
511 style_header("Add Attachment");
512 if( !goodCaptcha ){
513 @ <p class="generalError">Error: Incorrect security code.</p>
514 }
515 @ <h2>Add Attachment To %s(zTargetType)</h2>
516 form_begin("enctype='multipart/form-data'", "%R/attachadd");
517 @ <div>
518 @ File to Attach:
519 @ <input type="file" name="f" size="60"><br>
520 @ Description:<br>
521 @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
522 @ >%h(zComment)</textarea><br>
523 if( zForumPost ){
524 @ <input type="hidden" name="forumpost" value="%h(zTarget)">
525 }else if( zTkt ){
526 @ <input type="hidden" name="tkt" value="%h(zTkt)">
527 }else if( zTechNote ){
528 @ <input type="hidden" name="technote" value="%h(zTechNote)">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529 }else{
530 @ <input type="hidden" name="page" value="%h(zPage)">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531 }
532 @ <input type="hidden" name="from" value="%h(zFrom)">
533 @ <input type="submit" name="ok" value="Add Attachment">
534 @ <input type="submit" name="cancel" value="Cancel">
535 @ </div>
536 captcha_generate(0);
537 @ </form>
 
 
 
538 style_finish_page();
539 fossil_free(zTargetType);
540 fossil_free(zExtraFree);
 
541 }
542
543 /*
544 ** WEBPAGE: ainfo
545 ** URL: /ainfo?name=ARTIFACTID
@@ -600,15 +1178,17 @@
600 && db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget)
601 ){
602 if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; }
603 zTktUuid = zTarget;
604 showDelMenu = g.perm.WrTkt;
605 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",zTarget) ){
 
606 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
607 zWikiName = zTarget;
608 showDelMenu = g.perm.WrWiki;
609 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",zTarget) ){
 
610 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
611 zTNUuid = zTarget;
612 showDelMenu = g.perm.Write && g.perm.WrWiki;
613 }
614 if( showDelMenu ){
@@ -632,11 +1212,13 @@
632 Blob cksum;
633 const char *zFile = zName;
634
635 if( !bUserIsOwner ){
636 if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){
637 webpage_error("Only admins can delete other users' attachments.");
 
 
638 }
639 }
640 db_begin_transaction();
641 blob_zero(&manifest);
642 for(i=n=0; zFile[i]; i++){
@@ -656,14 +1238,14 @@
656 @ <p>The attachment below has been deleted.</p>
657 fossil_free(zNewDate);
658 }
659
660 if( P("del")
661 && ((zForumPost && (bUserIsOwner || forumpost_may_close())) ||
662 (zTktUuid && g.perm.WrTkt) ||
663 (zWikiName && g.perm.WrWiki) ||
664 (zTNUuid && g.perm.Write && g.perm.WrWiki))
665 ){
666 form_begin(0, "%R/ainfo/%!S", zUuid);
667 @ <p>Confirm you want to delete the attachment shown below.
668 @ <input type="submit" name="confirm" value="Confirm">
669 login_insert_csrf_secret();
@@ -711,20 +1293,22 @@
711 @ (%d(rid))
712 }
713 modPending = moderation_pending_www(rid);
714 if( zForumPost ){
715 @ <tr><th>Forum&nbsp;Post:</th>
716 @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a></td></tr>
 
717 }else if( zTktUuid ){
718 @ <tr><th>Ticket:</th>
719 @ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr>
720 }else if( zTNUuid ){
721 @ <tr><th>Tech Note:</th>
722 @ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr>
723 }else if( zWikiName ){
724 @ <tr><th>Wiki&nbsp;Page:</th>
725 @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a></td></tr>
 
726 }
727 @ <tr><th>Date:</th><td>
728 hyperlink_to_date(zDate, "</td></tr>");
729 @ <tr><th>User:</th><td>
730 hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>");
@@ -735,22 +1319,42 @@
735 }
736 @ <tr><th>Filename:</th><td>%h(zName)</td></tr>
737 if( g.perm.Setup ){
738 @ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr>
739 }
740 @ <tr><th valign="top">Description:</th><td valign="top">%h(zDesc)</td></tr>
 
 
741 @ </table>
742
743 if( modPending && (isModerator || bUserIsOwner) ){
744 @ <div class="section">Moderation</div>
745 @ <blockquote>
746 form_begin(0, "%R/ainfo/%s", zUuid);
747 @ <label><input type="radio" name="modaction" value="delete">
748 @ Delete this attachment</label><br>
749 if( isModerator ){
750 @ <label><input type="radio" name="modaction" value="approve">
751 @ Approve this attachment</label><br>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752 }
753 @ <input type="submit" value="Submit">
754 login_insert_csrf_secret();
755 @ </form>
756 @ </blockquote>
@@ -767,11 +1371,12 @@
767 const char *z;
768 content_get(ridSrc, &attach);
769 blob_to_utf8_no_bom(&attach, 0);
770 z = blob_str(&attach);
771 if( zLn ){
772 output_text_with_line_numbers(z, blob_size(&attach), zName, zLn, 1);
 
773 }else{
774 @ <pre>
775 @ %h(z)
776 @ </pre>
777 }
@@ -798,10 +1403,13 @@
798 */
799 #define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */
800 #define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */
801 #define ATTACHLIST_SIZE 0x04 /* add size */
802 #define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */
 
 
 
803 #endif
804
805 /*
806 ** Output HTML to show a list of attachments.
807 */
@@ -810,19 +1418,25 @@
810 const char *zHeader, /* Header to display with attachments */
811 const int flags /* ATTACHLIST_... flags */
812 ){
813 int cnt = 0;
814 char szBuf[36] = {0}; /* scratchpad for attachment size value */
815 const char * zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
816 ? " target=\"_blank\"" : "";
 
 
817 Stmt q;
 
818 db_prepare(&q,
819 "SELECT datetime(mtime,toLocal()), filename, user,"
820 " (SELECT uuid FROM blob WHERE rid=attachid), src, target, "
821 " attachid "
822 " FROM attachment"
823 " WHERE isLatest AND src!='' AND target=%Q"
 
 
 
824 " ORDER BY mtime DESC",
825 zTarget
826 );
827 while( db_step(&q)==SQLITE_ROW ){
828 const char *zDate = db_column_text(&q, 0);
@@ -832,36 +1446,51 @@
832 const char *zSrc = db_column_text(&q, 4);
833 const char *zTarget = db_column_text(&q, 5);
834 const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
835 const char *zTypeArg = 0; /* URL arg name for /attachdownload */
836 const int aid = db_column_int(&q, 6);
837 const int iAType = attachment_target_type(zTarget);
838 if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
839 && moderation_pending(aid)
840 && !moderation_user_could(aid, 1, 0) ){
841 continue;
 
 
 
 
842 }
843 if( cnt==0 ){
844 @ <section class='attachlist'>
 
 
 
 
 
 
 
 
845 if( flags & ATTACHLIST_HRULE_ABOVE ){
846 @ <hr>
847 }
848 @ %s(zHeader)
 
 
 
 
849 @ <ul>
850 }
851 cnt++;
852 switch( iAType ){
853 case CFTYPE_TICKET: zTypeArg = "tkt"; break;
854 case CFTYPE_FORUM: zTypeArg = "forumpost"; break;
855 case CFTYPE_EVENT: zTypeArg = "technote"; break;
856 case CFTYPE_WIKI:
857 default: zTypeArg = "page"; break;
858 }
859 @ <li>
860 @ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a>
861 if( flags & ATTACHLIST_SIZE ){
862 const int sz = db_int(0,"SELECT size FROM blob WHERE uuid=%Q", zSrc);
863 sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz);
864 }
865 @ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\
866 @&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)]
867 @ added by %h(zDispUser) on
@@ -870,11 +1499,15 @@
870 moderation_pending_www(aid);
871 @ </li>
872 }
873 if( cnt ){
874 @ </ul>
875 @ </section>
 
 
 
 
876 }
877 db_finalize(&q);
878 }
879
880 /*
@@ -1023,15 +1656,204 @@
1023 }
1024 for(i = 2; i < g.argc; ++i){
1025 const char *zPage = g.argv[i];
1026 db_bind_text(&q, ":tgtname", zPage);
1027 while(SQLITE_ROW == db_step(&q)){
1028 const char * zTime = db_column_text(&q, 0);
1029 const char * zSrc = db_column_text(&q, 1);
1030 const char * zTarget = db_column_text(&q, 2);
1031 const char * zName = db_column_text(&q, 3);
1032 printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName);
1033 }
1034 db_reset(&q);
1035 }
1036 db_finalize(&q);
1037 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1038
--- src/attach.c
+++ src/attach.c
@@ -22,41 +22,188 @@
22 #include <assert.h>
23
24 /*
25 ** Given a presumedly legal attachment target name, this guesses the
26 ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27 ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot distinguish
28 ** the target type.
29 **
30 ** zTarget is an attachment target name: wiki page name, tech-note ID,
31 ** ticket ID, or forumpost hash.
32 **
33 ** If bFull is true then it requires zTarget to be a full ID for
34 ** tech-notes and tickets, otherwise such IDs may be prefixes. If
35 ** bFull is false then tech-notes and tickets will perform a prefix
36 ** match, but it is up to the caller to provide enough of a prefix to
37 ** rule out ambiguity[^1]. When called repeatedly, this routine can
38 ** run a bit faster and more efficiently if bFull is true, but some
39 ** historical use cases call for prefix matches.
40 **
41 ** Wiki page names always require an exact match.
42 **
43 ** Forum posts are a special case:
44 **
45 ** - They ignore the bFull flag. That is, they will do prefix matches
46 ** but will not match an ambiguous prefix.
47 **
48 ** - It is up to the caller to, if needed, resolve zTarget using
49 ** forumpost_head_rid2() to resolve the RID of the earliest version
50 ** of the post, as that is the only one which attachments should
51 ** target.
52 **
53 ** [^1]: Historically (from the perspective of 2026-06) attachment
54 ** target lookups have used GLOB prefix matching but have taken no
55 ** measures to ensure that the prefix is unambiguous. Ergo we do the
56 ** same here. It is assumed that the caller passes enough of a prefix
57 ** to be unambiguous and that's worked out fine so far.
58 */
59 int attachment_target_type(const char *zTarget, int bFull){
60 if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
61 return 0;
62 }
63 if( symbolic_name_to_rid(zTarget, "f")>0 ){
64 /* Check forum posts first because they are the most likely target
65 ** as of 2026. We should arguably use something more
66 ** specialized/efficient than symbolic_name_to_rid(). */
67 return CFTYPE_FORUM;
68 }
69 if( bFull ){
70 static Stmt q = empty_Stmt_m;
71 int rc = 0;
72 if( !q.pStmt ){
73 db_static_prepare(
74 &q,
75 "SELECT CASE "
76 /* Ordered by presumed likelihood of attachments. */
77 "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n"
78 "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n"
79 "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n"
80 "ELSE 0 END",
81 CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT
82 );
83 }
84 db_bind_text(&q, ":tgt", zTarget);
85 if( SQLITE_ROW==db_step(&q) ){
86 rc = db_column_int(&q, 0);
87 }
88 db_reset(&q);
89 return rc;
90 }else{
91 return db_int(
92 0,
93 "SELECT CASE "
94 "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')"
95 " THEN %d\n"
96 "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')"
97 " THEN %d\n"
98 "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')"
99 " THEN %d\n"
100 "ELSE 0 END",
101 zTarget, CFTYPE_TICKET,
102 zTarget, CFTYPE_WIKI,
103 zTarget, CFTYPE_EVENT
104 );
105 }
106 }
107
108 /*
109 ** Given an attachment target name, returns the target's blob.rid.
110 ** zTarget and bFull work as described for attachment_target_type().
111 **
112 ** For forum posts, this always returns the RID of the first version
113 ** of the post, as attachments should always target that instance.
114 */
115 int attachment_target_rid(const char *zTarget, int bFull){
116 int rid = 0;
117 const int eType = attachment_target_type(zTarget, bFull);
118 switch(eType){
119 case CFTYPE_TICKET:
120 case CFTYPE_EVENT:{
121 const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt";
122 rid = db_int(
123 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
124 "WHERE tagname %s '%s-%q%s'\n"
125 "AND x.tagtype>0\n"
126 "AND x.tagid=t.tagid\n"
127 "AND x.rid=b.rid\n"
128 "ORDER BY x.mtime DESC",
129 bFull ? "=" : "GLOB"/*safe-for-%s*/,
130 zTagPrefix/*safe-for-%s*/,
131 zTarget,
132 bFull ? "" : "*"/*safe-for-%s*/
133 );
134 break;
135 }
136 case CFTYPE_FORUM:
137 rid = db_int(
138 0, "SELECT f.fpid FROM forumpost f, blob b\n"
139 "WHERE f.fpid=b.rid\n"
140 "AND b.uuid %s '%q%s'",
141 bFull ? "=" : "GLOB"/*safe-for-%s*/,
142 zTarget,
143 bFull ? "" : "*"/*safe-for-%s*/
144 );
145 if( rid>0 ){
146 rid = forumpost_head_rid(rid);
147 }
148 break;
149 case CFTYPE_WIKI:
150 rid = db_int(
151 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
152 "WHERE tagname='wiki-%q'\n"
153 "AND x.tagtype>0\n"
154 "AND x.tagid=t.tagid\n"
155 "AND x.rid=b.rid\n"
156 "ORDER BY x.mtime DESC",
157 zTarget
158 );
159 break;
160 default:
161 break;
162 }
163 return rid;
164 }
165
166 /*
167 ** For a given aritfact ID and type (from the CFTYPE_xyz enum),
168 ** returns true if the current user could hypothetically apply and
169 ** attachment to it, else returns 0.
170 **
171 ** The rid is currently only relevant when eArtifactType is
172 ** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
173 ** not the head RID, to keep non-admins from attaching files to
174 ** threads which have since been taken over by another user (this
175 ** happens when an admin edits another user's post).
176 */
177 int attach_user_may(int rid, int eArtifactType){
178 if( g.perm.Admin ) return 1;
179 if( !login_is_individual() ) return 0;
180 switch(eArtifactType){
181 case CFTYPE_FORUM:
182 return g.perm.AttachForum && forumpost_is_owner(rid, 0);
183 case CFTYPE_WIKI:
184 return g.perm.ApndWiki && g.perm.Attach;
185 case CFTYPE_TICKET:
186 return g.perm.ApndTkt && g.perm.Attach;
187 case CFTYPE_EVENT:
188 return g.perm.Write && g.perm.ApndWiki && g.perm.Attach;
189 default:
190 return 0;
191 }
192 }
193
194 /*
195 ** Emits a single-button FORM which invokes
196 ** /attachadd with target=$zTarget.
197 */
198 void attach_render_attachadd_button(const char *zTarget){
199 /* This could be changed from POST to GET, and arguably should so
200 ** that the target=X part becomes part of the resulting URL. */
201 @ <form method="post" action="%R/attachadd">\
202 @ <input type="hidden" name="target" value="%T(zTarget)">\
203 @ <input type="submit" value="Attach...">
204 @ </form>\
205 }
206
207 /*
208 ** WEBPAGE: attachlist
209 ** List attachments.
@@ -69,20 +216,21 @@
216 ** At most one of technote=, tkt=, forumpost=, or page= may be supplied.
217 **
218 ** If none are given, all attachments are listed. If one is given, only
219 ** attachments for the designated technote, ticket or wiki page are shown.
220 **
221 ** HASH may be just a prefix of the relevant forum post, technical
222 ** note, or ticket artifact hash, in which case all attachments of all
223 ** technical notes or tickets with the prefix will be listed. Forum
224 ** posts, on the other hand, require a unique hash or hash prefix.
225 */
226 void attachlist_page(void){
227 const char *zPage = P("page");
228 const char *zTkt = P("tkt");
229 const char *zTechNote = P("technote");
230 const char *zForumPost = P("forumpost");
231 char *zLink = 0;
232 Blob sql;
233 Stmt q;
234
235 if( zPage && zTkt ) zTkt = 0;
236 login_check_credentials();
@@ -102,32 +250,47 @@
250 if( fnid<=0 ){
251 webpage_error("Invalid forum post ID: %h", zForumPost);
252 }
253 blob_append_sql(&sql, " WHERE target="
254 "(SELECT uuid FROM blob WHERE rid=%d)", fnid);
255 zLink = mprintf("forum post <a href='%R/forumpost/%t'>%#h</a>",
256 zForumPost, hash_digits(0), zForumPost);
257 }else if( zPage ){
258 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
259 style_header("Attachments To Wiki page %h", zPage);
260 blob_append_sql(&sql, " WHERE target=%Q", zPage);
261 zLink = mprintf("wiki page <a href='%R/wiki?name=%t'>%h</a>",
262 zPage, zPage);
263 }else if( zTkt ){
264 if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; }
265 style_header("Attachments To Ticket %S", zTkt);
266 blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt);
267 zLink = mprintf("ticket <a href='%R/tktview?name=%t'>%#h</a>",
268 zTkt, hash_digits(0), zTkt);
269 }else if( zTechNote ){
270 if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; }
271 style_header("Attachments To Tech Note %S", zTechNote);
272 blob_append_sql(&sql, " WHERE target GLOB '%q*'",
273 zTechNote);
274 zLink = mprintf("tech-note <a href='%R/technote?name=%t'>%#h</a>",
275 zTechNote, hash_digits(0), zTechNote);
276 }else{
277 if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){
278 login_needed(g.anon.RdTkt || g.anon.RdWiki);
279 return;
280 }
281 style_header("All Attachments");
282 }
283 blob_append_sql(&sql, " ORDER BY mtime DESC");
284 db_prepare(&q, "%s", blob_sql_text(&sql));
285
286 if( zLink ){
287 @ <h2>Attachments for %s(zLink)</h2>
288 fossil_free(zLink);
289 zLink = 0;
290 }
291
292 @ <ol>
293 while( db_step(&q)==SQLITE_ROW ){
294 const char *zDate;
295 const char *zSrc;
296 const char *zTarget;
@@ -137,10 +300,11 @@
300 const char *zUuid;
301 const char *zDispUser;
302 const int attachid = db_column_int(&q, 7);
303 int type;
304 int i;
305 int bDeleted;
306 char *zUrlTail = 0;
307
308 if( moderation_pending(attachid)
309 && !moderation_user_could(attachid, 1, 0) ){
310 /* Elide entries which are currently pending moderation unless
@@ -160,11 +324,12 @@
324 if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
325 zFilename = &zFilename[i+1];
326 i = -1;
327 }
328 }
329 bDeleted = 0==zSrc || 0==zSrc[0];
330 type = attachment_target_type(zTarget, 1);
331 switch( type ){
332 case CFTYPE_TICKET:
333 zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
334 break;
335 case CFTYPE_EVENT:
@@ -176,20 +341,32 @@
341 case CFTYPE_WIKI:
342 zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename);
343 break;
344 }
345 @ <li><p>
346 if( bDeleted ){
347 @ <s>\
348 }
349 @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)</a>\
350 moderation_pending_www(attachid);
351 @ <br>\
352 @ <a href="%R/attachview?%s(zUrlTail)">%h(zFilename)</a>
353 @ [<a href="%R/attachdownload/%t(zFilename)?%s(zUrlTail)">download</a>]\
354 if( bDeleted ){
355 @ </s>
356 }
357 @ <br>
358 if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++;
359 if( zComment && zComment[0] ){
360 /* FIXME (2026-06-05): Honor the N-card (comment mimetype). %W
361 ** (historically used here) assumes fossil-wiki and the
362 ** fileformat.wiki doc has always claimed that it defaults to
363 ** text/plain. /ainfo assumes it is plain text. */
364 @ %h(zComment)<br>
365 }
366 if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){
367 if( bDeleted ){
368 zSrc = "Deleted from";
369 }else {
370 zSrc = "Added to";
371 }
372 switch( type ){
@@ -362,41 +539,81 @@
539 Manifest *pManifest;
540
541 db_begin_transaction();
542 blob_init(&content, aContent, szContent);
543 pManifest = manifest_parse(&content, 0, 0);
544 addCompress = pManifest!=0;
545 manifest_destroy(pManifest);
546 blob_init(&content, aContent, szContent);
547 if( addCompress ){
548 blob_compress(&content, &content);
 
549 }
550 rid = content_put_ex(&content, 0, 0, 0, needModerator);
551 zUUID = rid_to_uuid(rid);
552 blob_zero(&manifest);
553 for(i=n=0; zName[i]; i++){
554 if( zName[i]=='/' || zName[i]=='\\' ) n = i+1;
555 }
556 zName += n;
557 if( zName[0]==0 ) zName = "unknown";
558 blob_appendf(&manifest, "A %F%s %F %s\n",
559 zName, addCompress ? ".gz" : "", zTarget, zUUID);
560 if( zComment!=0 && zComment[0]!=0 ){
561 while( fossil_isspace(zComment[0]) ) zComment++;
562 n = strlen(zComment);
563 while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; }
564 if( n>0 ){
565 blob_appendf(&manifest, "C %#F\n", n, zComment);
566 }
567 }
568 zDate = date_in_standard_format("now");
569 blob_appendf(&manifest, "D %z\n", zDate);
570 blob_appendf(&manifest, "U %F\n", login_name());
571 md5sum_blob(&manifest, &cksum);
572 blob_appendf(&manifest, "Z %b\n", &cksum);
573 attach_put(&manifest, rid, needModerator);
574 assert( blob_is_reset(&manifest) );
575 db_end_transaction(0);
576 }
577
578 /*
579 ** Renders the "legacy" (static) /attachadd form. One of the first
580 ** four arguments must be non-NULL and the other three must be NULL.
581 ** zComment may be NULL, as may zFrom. See the call sites for more
582 ** context.
583 */
584 static void attach_render_legacy_form(const char *zForumPost,
585 const char *zTechNote,
586 const char *zTicket,
587 const char *zWikiPage,
588 const char *zComment,
589 const char *zFrom){
590 form_begin("enctype='multipart/form-data' id='attachadd-legacy-form'",
591 "%R/attachadd");
592 @ <div>\
593 @ File to Attach:
594 @ <input type="file" name="f" size="60"><br>
595 @ Description:<br>
596 @ <textarea name="comment" cols="80" rows="5" wrap="virtual"\
597 @ >%h(zComment)</textarea><br>
598 if( zForumPost ){
599 @ <input type="hidden" name="forumpost" value="%h(zForumPost)">\
600 }else if( zTicket ){
601 @ <input type="hidden" name="tkt" value="%h(zTicket)">\
602 }else if( zTechNote ){
603 @ <input type="hidden" name="technote" value="%h(zTechNote)">\
604 }else if( zWikiPage ){
605 @ <input type="hidden" name="page" value="%h(zWikiPage)">\
606 }
607 @ <input type="hidden" name="from" value="%h(zFrom)">\
608 @ <input type="submit" name="ok" value="Add Attachment">\
609 @ <input type="submit" name="cancel" value="Cancel">\
610 @ </div>
611 captcha_generate(0);
612 login_insert_csrf_secret();
613 @ </form>
614 }
615
616 /*
617 ** WEBPAGE: attachadd
618 ** Add a new attachment.
619 **
@@ -404,33 +621,54 @@
621 ** page=WIKIPAGE
622 ** technote=HASH
623 ** forumpost=HASH
624 ** from=URL
625 **
626 ** Adds a POSTed file attachment to the given target.
627 **
628 ** Or the "version 2" interface:
629 **
630 ** target=ATTACHMENT_TARGET
631 **
632 ** Behaves as documented for attachaddV2_page().
633 */
634 void attachadd_page(void){
635 const char *zPage;
636 const char *zForumPost;
637 const char *zTkt;
638 const char *zTechNote;
639 const char *aContent;
640 const char *zName;
641 const char *zComment;
 
642 const char *zTarget;
643 const char *zFrom; /* Origin page - redirect here after saving */
644 char *zTo = 0; /* Optionally redirect here after saving */
645 char *zTargetType = 0;
646 char *zExtraFree = 0;
647 int szContent;
648 int goodCaptcha = 1;
649 int szLimit = 0;
650
651 if( P("target")!=0 ){
652 attachaddV2_page();
653 return;
654 }
655 zPage = P("page");
656 zForumPost = P("forumpost");
657 zTkt = P("tkt");
658 zTechNote = P("technote");
659 zFrom = P("from");
660 aContent = P("f");
661 zName = PD("f:filename","unknown");
662 zComment = PD("comment", "");
663 szContent = atoi(PD("f:bytes","0"));
664
665 if( zFrom==0 ) zFrom = mprintf("%R/home");
666 if( P("cancel") ) cgi_redirect(zFrom);
667 if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){
668 webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X,"
669 " technote=X, or target=X");
670 }
671 login_check_credentials();
672 if( zForumPost ){
673 int fpid;
674 if( g.perm.AttachForum==0 ){
@@ -445,26 +683,23 @@
683 "forum posts.");
684 }
685 zTarget = zExtraFree = rid_to_uuid(fpid);
686 zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>",
687 zTarget, zForumPost);
688 zTo = zFrom ? 0 : mprintf("%R/forumpost/%S", zTarget);
 
 
 
 
689 }else if( zPage ){
690 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
691 login_needed(g.anon.ApndWiki && g.anon.Attach);
692 return;
693 }
694 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){
695 fossil_redirect_home();
696 }
697 zTarget = zPage;
698 zTargetType = mprintf("Wiki Page <a href=\"%R/wiki?name=%t\">%h</a>",
699 zPage, zPage);
700 zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
701 }else if ( zTechNote ){
702 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
703 login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
704 return;
705 }
@@ -474,11 +709,11 @@
709 if( zTechNote==0) fossil_redirect_home();
710 }
711 zTarget = zTechNote;
712 zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>",
713 zTechNote, zTechNote);
714 zTo = zFrom ? 0 : mprintf("%R/technote/%S", zTarget);
715 }else{
716 assert( zTkt );
717 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
718 login_needed(g.anon.ApndTkt && g.anon.Attach);
719 return;
@@ -487,21 +722,25 @@
722 zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag"
723 " WHERE tagname GLOB 'tkt-%q*'", zTkt);
724 if( zTkt==0 ) fossil_redirect_home();
725 }
726 zTarget = zTkt;
727 zTargetType = mprintf("Ticket <a href=\"%R/tktview/%S\">%S</a>",
728 zTkt, zTkt);
729 zTo = zFrom ? 0 : mprintf("%R/tktview/%S", zTarget);
730 }
731 szLimit = db_get_int("attachment-size-limit", 0);
732 if( szContent<0 || (szLimit && szContent>szLimit) ){
733 /* This check must be done late so that zTargetType is set up. */
734 @ <p class="generalError">Attachment %h(zName) is too large.
735 @ <a href="%R/help/attachment-size-limit">Limit</a> is
736 @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p>
737 /* Fall through and render form. */
738 }else if( P("ok")
739 && cgi_csrf_safe(2)
740 && szContent>0
741 && (goodCaptcha = captcha_is_correct(0)) ){
742 int needModerator = (zForumPost!=0 && forum_need_moderation()) ||
743 (zTkt!=0 && ticket_need_moderation(0)) ||
744 (zPage!=0 && wiki_need_moderation(0));
745 attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment);
746 cgi_redirect(zTo ? zTo : zFrom);
@@ -511,35 +750,374 @@
750 style_header("Add Attachment");
751 if( !goodCaptcha ){
752 @ <p class="generalError">Error: Incorrect security code.</p>
753 }
754 @ <h2>Add Attachment To %s(zTargetType)</h2>
755 attach_render_legacy_form(zForumPost, zTechNote, zTkt, zPage,
756 zComment, zFrom);
757 builtin_fossil_js_bundle_or("attach", NULL);
758 style_finish_page();
759 fossil_free(zTargetType);
760 fossil_free(zExtraFree);
761 }
762
763 /*
764 ** WEBPAGE: attachadd_ajax_post hidden
765 **
766 ** Used by attachadd V2 to handle attachments via POST requests with:
767 **
768 ** target=ATTACHMENT_TARGET
769 ** file1..fileN=FILE_OBJECTS
770 ** dryrun=0|1
771 **
772 ** Each posted file in the set file1..fileN gets attached to the given
773 ** target, permissions permitting. If dryrun>0 then the change is
774 ** rolled back instead of committed. target=X must refer to a full
775 ** target ID, not a prefix.
776 **
777 ** Responds with JSON: an empty object on success and
778 ** {error:"message"} on error. The on-success response structure is
779 ** subject to amendment.
780 */
781 void attachadd_ajax_post(void){
782 const char *zTarget;
783 char *zExtraFree = 0;
784 int eTgtType = 0;
785 int bNeedsModeration = 0;
786 int goodCaptcha = 1;
787 int bRollback = 0; /* Roll back if true. */
788
789 if( ! ajax_route_bootstrap(0, 1) ){
790 return;
791 }else if( !(goodCaptcha = captcha_is_correct(0)) ){
792 goto ajax_err_403;
793 }else if( !ajax_check_csrf(2) ){
794 return;
795 }
796 db_begin_transaction();
797 zTarget = P("target");
798 eTgtType = attachment_target_type(zTarget, 1);
799 CX("{");
800 switch( eTgtType ){
801 default:
802 case 0:
803 ajax_route_error(400, "Invalid attachment target.");
804 db_rollback_transaction();
805 return;
806 case CFTYPE_FORUM:{
807 int fpid;
808 if( g.perm.AttachForum==0 ){
809 goto ajax_err_403;
810 }
811 fpid = forumpost_head_rid2(zTarget);
812 if( fpid<=0 ){
813 goto ajax_err_404;
814 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
815 ajax_route_error(403, "Only admins can attach files to "
816 "other users' forum posts.");
817 db_rollback_transaction();
818 return;
819 }
820 zTarget = zExtraFree = rid_to_uuid(fpid);
821 bNeedsModeration = forum_need_moderation();
822 break;
823 }
824 case CFTYPE_EVENT:{
825 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
826 goto ajax_err_403;
827 }
828 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
829 zTarget) ){
830 zTarget = zExtraFree =
831 db_text(0, "SELECT substr(tagname,7) FROM tag"
832 " WHERE tagname GLOB 'event-%q*'", zTarget);
833 if( zTarget==0){
834 goto ajax_err_404;
835 }
836 }
837 bNeedsModeration = 0;
838 break;
839 }
840 case CFTYPE_TICKET:{
841 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
842 goto ajax_err_403;
843 }
844 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
845 zTarget) ){
846 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
847 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
848 if( zTarget==0 ){
849 goto ajax_err_404;
850 }
851 }
852 bNeedsModeration = ticket_need_moderation(0);
853 break;
854 }
855 case CFTYPE_WIKI:{
856 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
857 goto ajax_err_403;
858 }
859 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
860 zTarget) ){
861 goto ajax_err_404;
862 }
863 bNeedsModeration = wiki_need_moderation(0);
864 break;
865 }
866 }
867
868 if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){
869 CX("}");
870 if( atoi(PD("dryrun","0"))>0 ){
871 bRollback = 1;
872 }
873 }/*else error response was set up*/
874 fossil_free(zExtraFree);
875 db_end_transaction(bRollback);
876 return;
877 ajax_err_403:
878 if( db_transaction_nesting_depth()>0 ){
879 db_rollback_transaction();
880 }
881 ajax_route_error_forbidden();
882 return;
883 ajax_err_404:
884 assert( db_transaction_nesting_depth()>0 );
885 db_rollback_transaction();
886 ajax_route_error(404, "Target not found.");
887 return;
888 }
889
890 /*
891 ** A helper for AJAX-style routines which accept file attachments via
892 ** POST. zTarget must be a full attachment target. bNeedsModeration
893 ** must be true if the attachment requires moderation.
894 **
895 ** It is up to the caller to have validated all security measures
896 ** before calling this.
897 **
898 ** This looks for POSTed files names "file1".."fileN", stopping when
899 ** it finds no entry. Returns the number of entries attached to the
900 ** target or a negative value on error (in which case the current db
901 ** transaction will be in a rollback state).
902 **
903 ** The only errors are currently attachment size limit violations:
904 ** attachments must have a non-0 size and if the attachment-size-limit
905 ** setting is >0 then each file's size must be <= that.
906 **
907 ** If this returns a negative value, it will have populated an error
908 ** response using ajax_route_error(). On success it produces no
909 ** output.
910 **
911 ** ACHTUNG: if zTarget is a forum post, it "really should" be the ID
912 ** of the first version of that post, as that's where attachments are
913 ** intended to be applied so that they can be found and removed
914 ** consistently. Potential TODO is have this function do that if
915 ** attachment_target_type(zTarget,1)!=0 but it would (for current
916 ** uses) require duplicating work already done in the callers.
917 */
918 int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){
919 int i;
920 int rc = 0;
921 int n = 0;
922 int szLimit; /* attachment-max-size setting */
923 char aKeyPrefix[20]; /* Buffer for key "file%d" */
924 char aKeySize[30]; /* Buffer for key "file%d:bytes" */
925 char aKeyName[30]; /* Buffer for key "file%d:filename" */
926 char aKeyDesc[30]; /* Buffer for key "file%d_desc" */
927
928 db_begin_transaction();
929 szLimit = db_get_int("attachment-size-limit", 0);
930
931 for(i = 1; ; ++i, ++n){
932 /* Look for P("fileN"), where N=1..n */
933 const char *zContent;
934 const char *zFilename;
935 int szContent;
936 sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i);
937 zContent = P(aKeyPrefix);
938 if( !zContent ){
939 /* End of the list. */
940 break;
941 }
942 sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes",
943 aKeyPrefix);
944 szContent = atoi(PD(aKeySize,"-1"));
945 if( szContent<=0 ){
946 rc = -ajax_route_error(400,"Invalid file size: %d", szContent);
947 break;
948 }else if( szLimit>0 && szContent>szLimit ){
949 rc = -ajax_route_error(413, "File size limit is %d bytes.", szLimit);
950 break;
951 }else{
952 sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
953 aKeyPrefix);
954 sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
955 aKeyPrefix);
956 if( 0==(zFilename=P(aKeyName)) ){
957 rc = -ajax_route_error(400, "Missing filename.");
958 break;
959 }
960 attach_commit(zFilename, zTarget, zContent, szContent,
961 bNeedsModeration, P(aKeyDesc));
962 }
963 }
964 if( rc<0 ){
965 db_rollback_transaction();
966 return rc;
967 }else{
968 db_commit_transaction();
969 return n;
970 }
971 }
972
973 /*
974 ** Proxy for /attachadd?target=X
975 **
976 ** Lists attachments for, and can add them to, a target artifact.
977 **
978 ** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH
979 ** from=ORIGINATING_URL
980 **
981 ** Works like /attachadd but uses a JS-based interactive attachment
982 ** selector.
983 **
984 ** from=X tells it where to redirect to when it's done.
985 **
986 ** This page requires a post-2018-ish JS-capable browser.
987 */
988 void attachaddV2_page(void){
989 const char *zFrom = P("from");
990 const char *zTarget = P("target");
991 char *zTo = 0;
992 char *zTargetType = 0;
993 char *zExtraFree = 0;
994 int eTgtType = 0;
995 int goodCaptcha = 1;
996 char const * noJsArgs[] = {0,0,0,0}; /* Args for noscript form */
997
998 if( P("cancel") ) cgi_redirect(zFrom);
999 if( 0==zTarget ){
1000 webpage_error("Requires target=X");
1001 }
1002 login_check_credentials();
1003 eTgtType = attachment_target_type(zTarget, 1);
1004 switch( eTgtType ){
1005 default:
1006 case 0:
1007 webpage_error("Cannot resolve target=%h.", zTarget);
1008 break;
1009 case CFTYPE_FORUM:{
1010 int fpid;
1011 if( g.perm.AttachForum==0 ){
1012 login_needed(g.anon.AttachForum);
1013 return;
1014 }
1015 fpid = forumpost_head_rid2(zTarget);
1016 if( fpid<=0 ){
1017 webpage_error("Invalid forum post ID: %h", zTarget);
1018 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
1019 webpage_error("Only admins can attach files to other users' "
1020 "forum posts.");
1021 }
1022 zTarget = zExtraFree = rid_to_uuid(fpid);
1023 noJsArgs[0] = zTarget;
1024 zTargetType = mprintf(
1025 "Forum post <a href=\"%R/forumpost/%S\">%.16h</a>",
1026 zTarget, zTarget
1027 );
1028 zTo = mprintf("%R/forumpost/%S", zTarget);
1029 break;
1030 }
1031 case CFTYPE_EVENT:{
1032 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1033 login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach);
1034 return;
1035 }
1036 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1037 zTarget) ){
1038 zTarget = db_text(0, "SELECT substr(tagname,7) FROM tag"
1039 " WHERE tagname GLOB 'event-%q*'",
1040 zTarget);
1041 if( zTarget==0) fossil_redirect_home();
1042 }
1043 zTo = zFrom ? 0 : mprintf("%R/technote?name=%T", zTarget);
1044 zTargetType = mprintf("Tech-note <a href=\"%R/technote/%s\">%S</a>",
1045 zTarget, zTarget);
1046 noJsArgs[1] = zTarget;
1047 break;
1048 }
1049 case CFTYPE_TICKET:{
1050 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
1051 login_needed(g.anon.ApndTkt && g.anon.Attach);
1052 return;
1053 }
1054 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
1055 zTarget) ){
1056 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
1057 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
1058 if( zTarget==0 ) fossil_redirect_home();
1059 }
1060 zTo = zFrom ? 0 : mprintf("%R/tktview/%t", zTarget);
1061 zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>",
1062 zTarget, zTarget);
1063 noJsArgs[2] = zTarget;
1064 break;
1065 }
1066 case CFTYPE_WIKI:{
1067 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
1068 login_needed(g.anon.ApndWiki && g.anon.Attach);
1069 return;
1070 }
1071 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1072 zTarget) ){
1073 fossil_redirect_home();
1074 }
1075 zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget);
1076 zTargetType = mprintf(
1077 "Wiki page <a href=\"%R/wiki?name=%h\">%h</a>",
1078 zTarget, zTarget
1079 );
1080 noJsArgs[3] = zTarget;
1081 break;
1082 }
1083 }
1084
1085 db_begin_transaction();
1086
1087 style_set_current_feature("attach");
1088 style_header("Add Attachment");
1089 if( !goodCaptcha ){
1090 @ <p class="generalError">Error: Incorrect security code.</p>
1091 }
1092 @ <h2>Attachments for %s(zTargetType)</h2>
1093 attachment_list(zTarget, NULL,
1094 ATTACHLIST_SIZE | ATTACHLIST_HIDE_UNAPPROVED);
1095 attach_render_legacy_form(
1096 noJsArgs[0], noJsArgs[1], noJsArgs[2],
1097 noJsArgs[3], 0,
1098 zFrom ? zFrom : (zTo ? zTo : (zTo=mprintf("%R/home")))
1099 );
1100 @ <div id='attachadd-form-wrapper' class='hidden'>
1101 /* fossil.attach.js populates this DIV with the attachment widget,
1102 ** imports these hidden fields, and removes the legacy form. */
1103 @ <input type="hidden" name="target" value="%h(zTarget)">
1104 if( zFrom ){
1105 @ <input type="hidden" name="from" value="%h(zFrom)">
1106 }
1107 if( zTo ){
1108 @ <input type="hidden" name="to" value="%h(zTo)">
1109 }
 
1110 captcha_generate(0);
1111 login_insert_csrf_secret();
1112 @ </div>
1113 builtin_fossil_js_bundle_or("attach", NULL);
1114 db_end_transaction(0);
1115 style_finish_page();
1116 fossil_free(zTargetType);
1117 fossil_free(zExtraFree);
1118 fossil_free(zTo);
1119 }
1120
1121 /*
1122 ** WEBPAGE: ainfo
1123 ** URL: /ainfo?name=ARTIFACTID
@@ -600,15 +1178,17 @@
1178 && db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget)
1179 ){
1180 if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; }
1181 zTktUuid = zTarget;
1182 showDelMenu = g.perm.WrTkt;
1183 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
1184 zTarget) ){
1185 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
1186 zWikiName = zTarget;
1187 showDelMenu = g.perm.WrWiki;
1188 }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
1189 zTarget) ){
1190 if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; }
1191 zTNUuid = zTarget;
1192 showDelMenu = g.perm.Write && g.perm.WrWiki;
1193 }
1194 if( showDelMenu ){
@@ -632,11 +1212,13 @@
1212 Blob cksum;
1213 const char *zFile = zName;
1214
1215 if( !bUserIsOwner ){
1216 if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){
1217 webpage_error(
1218 "Only admins can delete other users' attachments."
1219 );
1220 }
1221 }
1222 db_begin_transaction();
1223 blob_zero(&manifest);
1224 for(i=n=0; zFile[i]; i++){
@@ -656,14 +1238,14 @@
1238 @ <p>The attachment below has been deleted.</p>
1239 fossil_free(zNewDate);
1240 }
1241
1242 if( P("del")
1243 && ((zForumPost && (bUserIsOwner || forumpost_may_close()))
1244 || (zTktUuid && g.perm.WrTkt)
1245 || (zWikiName && g.perm.WrWiki)
1246 || (zTNUuid && g.perm.Write && g.perm.WrWiki))
1247 ){
1248 form_begin(0, "%R/ainfo/%!S", zUuid);
1249 @ <p>Confirm you want to delete the attachment shown below.
1250 @ <input type="submit" name="confirm" value="Confirm">
1251 login_insert_csrf_secret();
@@ -711,20 +1293,22 @@
1293 @ (%d(rid))
1294 }
1295 modPending = moderation_pending_www(rid);
1296 if( zForumPost ){
1297 @ <tr><th>Forum&nbsp;Post:</th>
1298 @ <td>%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)</a>\
1299 @ </td></tr>
1300 }else if( zTktUuid ){
1301 @ <tr><th>Ticket:</th>
1302 @ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr>
1303 }else if( zTNUuid ){
1304 @ <tr><th>Tech Note:</th>
1305 @ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr>
1306 }else if( zWikiName ){
1307 @ <tr><th>Wiki&nbsp;Page:</th>
1308 @ <td>%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)</a>\
1309 @ </td></tr>
1310 }
1311 @ <tr><th>Date:</th><td>
1312 hyperlink_to_date(zDate, "</td></tr>");
1313 @ <tr><th>User:</th><td>
1314 hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>");
@@ -735,22 +1319,42 @@
1319 }
1320 @ <tr><th>Filename:</th><td>%h(zName)</td></tr>
1321 if( g.perm.Setup ){
1322 @ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr>
1323 }
1324 @ <tr><th valign="top">Description:</th>\
1325 /* FIXME (2026-06-05): Honor the N-card (comment mimetype). */
1326 @ <td valign="top">%h(zDesc)</td></tr>
1327 @ </table>
1328
1329 if( modPending && (isModerator || bUserIsOwner) ){
1330 @ <div class="section">Moderation</div>
1331 @ <blockquote>
1332 form_begin(0, "%R/ainfo/%s", zUuid);
1333 @ <label><input type="radio" name="modaction" value="delete">
1334 @ Delete this attachment</label><br>
1335 if( isModerator ){
1336 #if 0
1337 /* TODO/FIXME (2026-06-03): only allow approval of an attachment
1338 ** if its target has been approved. Without this, we can end up
1339 ** with stale attachments which refer to rejected targets. We
1340 ** need a type-specific RID/UUID here, which requires
1341 ** refactoring above to get it. */
1342 const int tgtid = 0;
1343 if( moderation_pending(tgtid) ){
1344 @ <label><input type="radio" name="modaction" \
1345 @ disabled value="approve">
1346 @ <span class='modpending'>Cannot approve:
1347 @ target is pending moderation</span>\
1348 @ </label><br>
1349 }else
1350 #else
1351 {
1352 @ <label><input type="radio" name="modaction" value="approve">
1353 @ Approve this attachment</label><br>
1354 }
1355 #endif
1356 }
1357 @ <input type="submit" value="Submit">
1358 login_insert_csrf_secret();
1359 @ </form>
1360 @ </blockquote>
@@ -767,11 +1371,12 @@
1371 const char *z;
1372 content_get(ridSrc, &attach);
1373 blob_to_utf8_no_bom(&attach, 0);
1374 z = blob_str(&attach);
1375 if( zLn ){
1376 output_text_with_line_numbers(z, blob_size(&attach),
1377 zName, zLn, 1);
1378 }else{
1379 @ <pre>
1380 @ %h(z)
1381 @ </pre>
1382 }
@@ -798,10 +1403,13 @@
1403 */
1404 #define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */
1405 #define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */
1406 #define ATTACHLIST_SIZE 0x04 /* add size */
1407 #define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */
1408 #define ATTACHLIST_DETAILS_CLOSED 0x10 /* Wrap in a closed DETAILS element */
1409 #define ATTACHLIST_DETAILS_OPEN 0x20 /* Wrap in an open DETAILS element */
1410 #define ATTACHLIST_HIDE_EMPTY 0x40 /* Skip if size<1 */
1411 #endif
1412
1413 /*
1414 ** Output HTML to show a list of attachments.
1415 */
@@ -810,19 +1418,25 @@
1418 const char *zHeader, /* Header to display with attachments */
1419 const int flags /* ATTACHLIST_... flags */
1420 ){
1421 int cnt = 0;
1422 char szBuf[36] = {0}; /* scratchpad for attachment size value */
1423 const char *zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags)
1424 ? " target=\"_blank\"" : "";
1425 const int bUseDetail = flags &
1426 (ATTACHLIST_DETAILS_CLOSED | ATTACHLIST_DETAILS_OPEN);
1427 Stmt q;
1428
1429 db_prepare(&q,
1430 "SELECT datetime(mtime,toLocal()), a.filename, a.user,"
1431 " b1.uuid, a.src, a.target, a.attachid, b2.size\n"
1432 " FROM attachment a, blob b1, blob b2\n"
1433 " WHERE a.isLatest\n"
1434 " AND a.src IS NOT NULL\n"
1435 " AND a.target=%Q\n"
1436 " AND b1.rid=a.attachid\n"
1437 " AND b2.uuid=a.src\n"
1438 " ORDER BY mtime DESC",
1439 zTarget
1440 );
1441 while( db_step(&q)==SQLITE_ROW ){
1442 const char *zDate = db_column_text(&q, 0);
@@ -832,36 +1446,51 @@
1446 const char *zSrc = db_column_text(&q, 4);
1447 const char *zTarget = db_column_text(&q, 5);
1448 const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
1449 const char *zTypeArg = 0; /* URL arg name for /attachdownload */
1450 const int aid = db_column_int(&q, 6);
1451 const int sz = db_column_int(&q, 7);
1452 if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
1453 && moderation_pending(aid)
1454 && !moderation_user_could(aid, 1, 0) ){
1455 continue;
1456 }
1457 if( sz<1 && (flags & ATTACHLIST_HIDE_EMPTY) ){
1458 /* Deleted or phantom items. */
1459 continue;
1460 }
1461 if( cnt==0 ){
1462 if( bUseDetail ){
1463 @ <details class='attachlist'
1464 if( ATTACHLIST_DETAILS_OPEN & flags ){
1465 @ open
1466 }
1467 @ >
1468 }else{
1469 @ <section class='attachlist'>
1470 }
1471 if( flags & ATTACHLIST_HRULE_ABOVE ){
1472 @ <hr>
1473 }
1474 if( bUseDetail ){
1475 @ <summary>%s(zHeader)</summary>
1476 }else{
1477 @ %s(zHeader)
1478 }
1479 @ <ul>
1480 }
1481 cnt++;
1482 switch( attachment_target_type(zTarget, 1) ){
1483 case CFTYPE_TICKET: zTypeArg = "tkt"; break;
1484 case CFTYPE_FORUM: zTypeArg = "forumpost"; break;
1485 case CFTYPE_EVENT: zTypeArg = "technote"; break;
1486 case CFTYPE_WIKI:
1487 default: zTypeArg = "page"; break;
1488 }
1489 @ <li>
1490 @ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a>
1491 if( flags & ATTACHLIST_SIZE ){
 
1492 sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz);
1493 }
1494 @ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\
1495 @&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)]
1496 @ added by %h(zDispUser) on
@@ -870,11 +1499,15 @@
1499 moderation_pending_www(aid);
1500 @ </li>
1501 }
1502 if( cnt ){
1503 @ </ul>
1504 if( bUseDetail ){
1505 @ </details>
1506 }else{
1507 @ </section>
1508 }
1509 }
1510 db_finalize(&q);
1511 }
1512
1513 /*
@@ -1023,15 +1656,204 @@
1656 }
1657 for(i = 2; i < g.argc; ++i){
1658 const char *zPage = g.argv[i];
1659 db_bind_text(&q, ":tgtname", zPage);
1660 while(SQLITE_ROW == db_step(&q)){
1661 const char *zTime = db_column_text(&q, 0);
1662 const char *zSrc = db_column_text(&q, 1);
1663 const char *zTarget = db_column_text(&q, 2);
1664 const char *zName = db_column_text(&q, 3);
1665 printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName);
1666 }
1667 db_reset(&q);
1668 }
1669 db_finalize(&q);
1670 }
1671
1672 /*
1673 ** Renders the list of attachments for artifact pManifest as JSON to
1674 ** blob pOut. If pManifest->type is not one of (CFTYPE_TICKET,
1675 ** CFTYPE_FORUM, CFTYPE_EVENT, CFTYPE_WIKI) then it behaves as if the
1676 ** result set is empty.
1677 **
1678 ** If there are no matching attachments then its behavior depends on
1679 ** emptyPolicy:
1680 **
1681 ** <0 = emit a JSON NULL
1682 ** 0 = emit no output
1683 ** >0 = emit an empty JSON array
1684 **
1685 ** If bLatestOnly is true then only the most recent entry for a given
1686 ** attachment is emitted, else all versions are emitted in descending
1687 ** mtime order.
1688 **
1689 ** Returns the number of attachments.
1690 **
1691 ** Output format:
1692 **
1693 ** [{
1694 ** "uuid": attachment artifact hash,
1695 ** "src": hash of the attachment blob,
1696 ** "target": wiki page name or ticket/event ID,
1697 ** "filename": filename of attachment,
1698 ** "mtime": ISO-8601 timestamp UTC,
1699 ** "isLatest": true if this is the latest version of this file
1700 ** else false,
1701 ** }, ...once per attachment]
1702 **
1703 */
1704 int attachments_to_json(const Manifest *pManifest,
1705 Blob *pOut, int bLatestOnly,
1706 int emptyPolicy){
1707 int i = 0;
1708 Stmt q = empty_Stmt;
1709 char *zToFree = 0;
1710 const char *zTgt = 0;
1711 switch(pManifest->type){
1712 case CFTYPE_FORUM: zTgt = zToFree = rid_to_uuid(pManifest->rid);
1713 break;
1714 case CFTYPE_WIKI: zTgt = pManifest->zWikiTitle; break;
1715 case CFTYPE_EVENT: zTgt = pManifest->zEventId; break;
1716 case CFTYPE_TICKET: zTgt = pManifest->zTicketUuid; break;
1717 default:
1718 goto empty_result;
1719 }
1720 db_prepare(&q,
1721 "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1722 " b2.size, b1.uuid, a.user, a.comment\n"
1723 " FROM attachment a, blob b1, blob b2\n"
1724 " WHERE a.target=%Q\n"
1725 " AND a.src IS NOT NULL\n"
1726 " AND b1.rid=a.attachid\n"
1727 " AND b2.uuid=a.src\n"
1728 " AND (a.isLatest OR %d)\n"
1729 " ORDER BY a.target, a.isLatest DESC, a.mtime DESC\n",
1730 zTgt, !bLatestOnly
1731 );
1732 while(SQLITE_ROW == db_step(&q)){
1733 const char *zTime = db_column_text(&q, 0);
1734 const char *zSrc = db_column_text(&q, 1);
1735 const char *zTarget = db_column_text(&q, 2);
1736 const char *zName = db_column_text(&q, 3);
1737 const int isLatest = db_column_int(&q, 4);
1738 const int sz = db_column_int(&q, 5);
1739 const char *zUuid = db_column_text(&q, 6);
1740 const char *zUser = db_column_text(&q, 7);
1741 const char *zComment = db_column_text(&q, 8);
1742 if(!i++){
1743 blob_append_char(pOut, '[');
1744 }else{
1745 blob_append_char(pOut, ',');
1746 }
1747 blob_appendf(
1748 pOut,
1749 "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
1750 "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1751 "\"isLatest\": %s, \"user\": %!j, \"comment\": ",
1752 zUuid, zSrc, zTarget,
1753 zName, sz, zTime, isLatest ? "true" : "false",
1754 zUser
1755 );
1756 if( zComment && zComment[0] ){
1757 blob_appendf(pOut, "%!j", zComment);
1758 }else{
1759 blob_append_literal(pOut, "null");
1760 }
1761 blob_append_char(pOut, '}');
1762 }
1763 fossil_free(zToFree);
1764 db_finalize(&q);
1765 if(!i){
1766 empty_result:
1767 if( emptyPolicy>0 ){
1768 blob_append_literal(pOut, "[]");
1769 }else if( emptyPolicy<0 ){
1770 blob_append_literal(pOut, "null");
1771 }
1772 }else{
1773 blob_append_char(pOut, ']');
1774 }
1775 return i;
1776 }
1777
1778 /*
1779 ** COMMAND: test-attachment-target
1780 **
1781 ** Usage: %fossil test-attachment-target TARGET_ID...
1782 */
1783 void test_attachment_target_type_cmd(void){
1784 int i;
1785 verify_all_options();
1786 db_find_and_open_repository(0, 0);
1787 if( g.argc<3 ){
1788 usage("test-attachment-target TARGET_ID");
1789 return;
1790 }
1791 for( i = 2; i < g.argc; ++i ){
1792 const char *zTarget = g.argv[i];
1793 const int rid = attachment_target_rid(zTarget, 0);
1794 const int type = attachment_target_type(zTarget, 0);
1795 const char *zType = "<invalid>";
1796 switch(type){
1797 case CFTYPE_EVENT: zType = "technote"; break;
1798 case CFTYPE_FORUM: zType = "forumpost"; break;
1799 case CFTYPE_TICKET: zType = "ticket"; break;
1800 case CFTYPE_WIKI: zType = "wiki"; break;
1801 }
1802 fossil_print("%-20s = %-9s #%d %z\n",
1803 zTarget, zType, rid,
1804 rid>0 ? rid_to_uuid(rid) : 0);
1805 }
1806 }
1807
1808
1809 /*
1810 ** COMMAND: test-attachments-to-json
1811 **
1812 ** Usage: %fossil test-attachments-to-json TARGET_ID...
1813 **
1814 ** Options:
1815 ** --old List all versions of attachments. Default is to
1816 ** list only the latest.
1817 ** --full Require a full target ID, not a prefix.
1818 **
1819 ** Emits a JSON array of attachments for the given attachment targets.
1820 ** The given IDs must be wiki page names, ticket hashes, tech-note
1821 ** hashes, or forum post hashes. By default it accepts hash prefixes
1822 ** but does no detection of ambiguity or cross-type prefix collisions
1823 ** so may emit curious results if given short, colliding IDs.
1824 */
1825 void test_attachments_to_json_cmd(void){
1826 const int emptyPolicy = 1;
1827 const int bLatestOnly = find_option("old",0,0)==0;
1828 const int bFullId = find_option("full",0,0)!=0;
1829 int i;
1830
1831 verify_all_options();
1832 db_find_and_open_repository(0, 0);
1833 if( g.argc<3 ){
1834 usage("test-attachments-to-json TARGET_ID");
1835 return;
1836 }
1837 for( i = 2; i < g.argc; ++i ){
1838 const char *zTarget = g.argv[i];
1839 const int rid = attachment_target_rid(zTarget, bFullId);
1840 if( 0==rid ){
1841 fossil_print("** cannot resolve %s\n", zTarget);
1842 }else{
1843 Blob b = BLOB_INITIALIZER;
1844 Manifest *pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
1845 assert( pManifest );
1846 attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
1847 fossil_print("Attachments for %s: ", zTarget);
1848 if( b.nUsed ){
1849 char *zPretty = db_text(0,"SELECT json_pretty(%B)", &b);
1850 fossil_print("%s\n", zPretty);
1851 fossil_free(zPretty);
1852 }else{
1853 fossil_print("none\n");
1854 }
1855 blob_reset(&b);
1856 manifest_destroy(pManifest);
1857 }
1858 }
1859 }
1860
+20 -1
--- src/builtin.c
+++ src/builtin.c
@@ -668,22 +668,39 @@
668668
CX("editStateMarkers: {"
669669
"/*Symbolic markers to denote certain edit states.*/"
670670
"isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n");
671671
CX("confirmerButtonTicks: 3 "
672672
"/*default fossil.confirmer tick count.*/,\n");
673
+ CX("attachmentSizeLimit: %d,\n",
674
+ db_get_int("attachment-size-limit",0));
673675
/* Inject certain info about the current skin... */
674676
CX("skin:{");
675677
/* can leak a local filesystem path:
676678
CX("name: %!j,", skin_in_use());*/
677679
CX("isDark: %s"
678680
"/*true if the current skin has the 'white-foreground' detail*/",
679681
skin_detail_boolean("white-foreground") ? "true" : "false");
680682
CX("}\n"/*fossil.config.skin*/);
681683
CX("};\n"/* fossil.config */);
684
+ if( forum_statuses()->n>1 ){
685
+ const ForumStatusList * fsl = forum_statuses();
686
+ int i;
687
+ CX("window.fossil.config.forumStatuses = [");
688
+ for(i = 0; i < fsl->n; ++i){
689
+ const ForumStatus *fs = &fsl->aStatus[i];
690
+ if(i) CX(",");
691
+ CX("{label:%!j, value:%!j}", fs->zLabel, fs->zValue);
692
+ }
693
+ CX("];\n");
694
+ }
695
+#define JBOOL(COND) ((COND) ? "true" : "false")
682696
CX("window.fossil.user = {");
683697
CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest");
684
- CX("isAdmin: %s", (g.perm.Admin || g.perm.Setup) ? "true" : "false");
698
+ CX("isAdmin: %s,", JBOOL(g.perm.Admin || g.perm.Setup));
699
+ CX("mayAttachForum: %s,", JBOOL(g.perm.AttachForum));
700
+ CX("enableDebug: %s,", JBOOL(g.perm.Debug || g.perm.Admin));
701
+ CX("isIndividual: %s", JBOOL(login_is_individual()));
685702
CX("};\n"/*fossil.user*/);
686703
CX("if(fossil.config.skin.isDark) "
687704
"document.body.classList.add('fossil-dark-style');\n");
688705
/*
689706
** fossil.page holds info about the current page. This is also
@@ -699,10 +716,11 @@
699716
}
700717
/* The remaining window.fossil bootstrap code is not dependent on
701718
** C-runtime state... */
702719
builtin_request_js("fossil.bootstrap.js");
703720
}
721
+#undef JBOOL
704722
}
705723
706724
/*
707725
** Given the NAME part of fossil.NAME.js, this function checks whether
708726
** that module has been emitted by this function before. If it has,
@@ -732,10 +750,11 @@
732750
** entries: all known deps of this one. Each
733751
** REQUIRES an EXPLICIT trailing \0, including
734752
** the final one! */
735753
} fjs[] = {
736754
/* This list ordering isn't strictly important. */
755
+ {"attach", 0, "dom\0"},
737756
{"confirmer", 0, 0},
738757
{"copybutton", 0, "dom\0"},
739758
{"diff", 0, "dom\0fetch\0storage\0"
740759
/* maintenance note: "diff" needs "storage" for storing the
741760
** sbs-sync-scroll toggle. */},
742761
--- src/builtin.c
+++ src/builtin.c
@@ -668,22 +668,39 @@
668 CX("editStateMarkers: {"
669 "/*Symbolic markers to denote certain edit states.*/"
670 "isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n");
671 CX("confirmerButtonTicks: 3 "
672 "/*default fossil.confirmer tick count.*/,\n");
 
 
673 /* Inject certain info about the current skin... */
674 CX("skin:{");
675 /* can leak a local filesystem path:
676 CX("name: %!j,", skin_in_use());*/
677 CX("isDark: %s"
678 "/*true if the current skin has the 'white-foreground' detail*/",
679 skin_detail_boolean("white-foreground") ? "true" : "false");
680 CX("}\n"/*fossil.config.skin*/);
681 CX("};\n"/* fossil.config */);
 
 
 
 
 
 
 
 
 
 
 
 
682 CX("window.fossil.user = {");
683 CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest");
684 CX("isAdmin: %s", (g.perm.Admin || g.perm.Setup) ? "true" : "false");
 
 
 
685 CX("};\n"/*fossil.user*/);
686 CX("if(fossil.config.skin.isDark) "
687 "document.body.classList.add('fossil-dark-style');\n");
688 /*
689 ** fossil.page holds info about the current page. This is also
@@ -699,10 +716,11 @@
699 }
700 /* The remaining window.fossil bootstrap code is not dependent on
701 ** C-runtime state... */
702 builtin_request_js("fossil.bootstrap.js");
703 }
 
704 }
705
706 /*
707 ** Given the NAME part of fossil.NAME.js, this function checks whether
708 ** that module has been emitted by this function before. If it has,
@@ -732,10 +750,11 @@
732 ** entries: all known deps of this one. Each
733 ** REQUIRES an EXPLICIT trailing \0, including
734 ** the final one! */
735 } fjs[] = {
736 /* This list ordering isn't strictly important. */
 
737 {"confirmer", 0, 0},
738 {"copybutton", 0, "dom\0"},
739 {"diff", 0, "dom\0fetch\0storage\0"
740 /* maintenance note: "diff" needs "storage" for storing the
741 ** sbs-sync-scroll toggle. */},
742
--- src/builtin.c
+++ src/builtin.c
@@ -668,22 +668,39 @@
668 CX("editStateMarkers: {"
669 "/*Symbolic markers to denote certain edit states.*/"
670 "isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n");
671 CX("confirmerButtonTicks: 3 "
672 "/*default fossil.confirmer tick count.*/,\n");
673 CX("attachmentSizeLimit: %d,\n",
674 db_get_int("attachment-size-limit",0));
675 /* Inject certain info about the current skin... */
676 CX("skin:{");
677 /* can leak a local filesystem path:
678 CX("name: %!j,", skin_in_use());*/
679 CX("isDark: %s"
680 "/*true if the current skin has the 'white-foreground' detail*/",
681 skin_detail_boolean("white-foreground") ? "true" : "false");
682 CX("}\n"/*fossil.config.skin*/);
683 CX("};\n"/* fossil.config */);
684 if( forum_statuses()->n>1 ){
685 const ForumStatusList * fsl = forum_statuses();
686 int i;
687 CX("window.fossil.config.forumStatuses = [");
688 for(i = 0; i < fsl->n; ++i){
689 const ForumStatus *fs = &fsl->aStatus[i];
690 if(i) CX(",");
691 CX("{label:%!j, value:%!j}", fs->zLabel, fs->zValue);
692 }
693 CX("];\n");
694 }
695 #define JBOOL(COND) ((COND) ? "true" : "false")
696 CX("window.fossil.user = {");
697 CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest");
698 CX("isAdmin: %s,", JBOOL(g.perm.Admin || g.perm.Setup));
699 CX("mayAttachForum: %s,", JBOOL(g.perm.AttachForum));
700 CX("enableDebug: %s,", JBOOL(g.perm.Debug || g.perm.Admin));
701 CX("isIndividual: %s", JBOOL(login_is_individual()));
702 CX("};\n"/*fossil.user*/);
703 CX("if(fossil.config.skin.isDark) "
704 "document.body.classList.add('fossil-dark-style');\n");
705 /*
706 ** fossil.page holds info about the current page. This is also
@@ -699,10 +716,11 @@
716 }
717 /* The remaining window.fossil bootstrap code is not dependent on
718 ** C-runtime state... */
719 builtin_request_js("fossil.bootstrap.js");
720 }
721 #undef JBOOL
722 }
723
724 /*
725 ** Given the NAME part of fossil.NAME.js, this function checks whether
726 ** that module has been emitted by this function before. If it has,
@@ -732,10 +750,11 @@
750 ** entries: all known deps of this one. Each
751 ** REQUIRES an EXPLICIT trailing \0, including
752 ** the final one! */
753 } fjs[] = {
754 /* This list ordering isn't strictly important. */
755 {"attach", 0, "dom\0"},
756 {"confirmer", 0, 0},
757 {"copybutton", 0, "dom\0"},
758 {"diff", 0, "dom\0fetch\0storage\0"
759 /* maintenance note: "diff" needs "storage" for storing the
760 ** sbs-sync-scroll toggle. */},
761
+3 -2
--- src/cgi.c
+++ src/cgi.c
@@ -1335,12 +1335,13 @@
13351335
}
13361336
fossil_free(zErr);
13371337
}
13381338
}
13391339
if( !g.syncInfo.zLoginCard && 0!=(z=(char*)P("x-f-l-c")) ){
1340
- /* x-f-l-c (X-Fossil-Login-Card card transmitted via cookie
1341
- ** instead of in the sync payload. */
1340
+ /* x-f-l-c (X-Fossil-Login-Card) transmitted via cookie instead of
1341
+ ** in the sync payload. The format of this value is the same as a
1342
+ ** "login" card, as parsed by xfer.c:page_xfer(). */
13421343
rc |= 0x04;
13431344
g.syncInfo.zLoginCard = fossil_strdup(z);
13441345
g.syncInfo.fLoginCardMode |= 0x02;
13451346
cgi_delete_parameter("x-f-l-c");
13461347
}
13471348
--- src/cgi.c
+++ src/cgi.c
@@ -1335,12 +1335,13 @@
1335 }
1336 fossil_free(zErr);
1337 }
1338 }
1339 if( !g.syncInfo.zLoginCard && 0!=(z=(char*)P("x-f-l-c")) ){
1340 /* x-f-l-c (X-Fossil-Login-Card card transmitted via cookie
1341 ** instead of in the sync payload. */
 
1342 rc |= 0x04;
1343 g.syncInfo.zLoginCard = fossil_strdup(z);
1344 g.syncInfo.fLoginCardMode |= 0x02;
1345 cgi_delete_parameter("x-f-l-c");
1346 }
1347
--- src/cgi.c
+++ src/cgi.c
@@ -1335,12 +1335,13 @@
1335 }
1336 fossil_free(zErr);
1337 }
1338 }
1339 if( !g.syncInfo.zLoginCard && 0!=(z=(char*)P("x-f-l-c")) ){
1340 /* x-f-l-c (X-Fossil-Login-Card) transmitted via cookie instead of
1341 ** in the sync payload. The format of this value is the same as a
1342 ** "login" card, as parsed by xfer.c:page_xfer(). */
1343 rc |= 0x04;
1344 g.syncInfo.zLoginCard = fossil_strdup(z);
1345 g.syncInfo.fLoginCardMode |= 0x02;
1346 cgi_delete_parameter("x-f-l-c");
1347 }
1348
+160 -1
--- src/default.css
+++ src/default.css
@@ -1693,11 +1693,11 @@
16931693
-0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
16941694
0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
16951695
0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
16961696
L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
16971697
9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698
-style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1698
+style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
16991699
background-repeat: no-repeat;
17001700
background-position: center;
17011701
/* When not using a background image, this additional style works
17021702
reasonably well along with a ::before content of "?": */
17031703
/*border-width: 1px;
@@ -2007,10 +2007,169 @@
20072007
margin: 0;
20082008
}
20092009
div.helpPage blockquote {
20102010
margin-left: 0.2em;
20112011
}
2012
+
2013
+/* .attach* = styles for file attachments */
2014
+section.attachlist {}
2015
+details.attachlist > summary {
2016
+ cursor: pointer;
2017
+}
2018
+/* .Attacher is the top container element used by the JS Attacher
2019
+ class in fossil.attach.js. */
2020
+.Attacher {
2021
+ margin-bottom: 1em;
2022
+ display: flex;
2023
+ flex-direction: column;
2024
+ gap: 0.75em;
2025
+}
2026
+.Attacher.reverse {
2027
+ flex-direction: column-reverse;
2028
+}
2029
+.Attacher .attach-row {
2030
+ display: flex;
2031
+ flex-direction: column;
2032
+ gap: 0.5em;
2033
+ padding: 0.75em;
2034
+ border: 1px dashed #ccc;
2035
+ border-radius: 0.25em;
2036
+ background-color: #fafafa;
2037
+}
2038
+.Attacher .error {
2039
+ padding: 0.5em;
2040
+ background-color: #d32f2f;
2041
+ color: #fff;
2042
+}
2043
+.Attacher .error a {
2044
+ color: inherit;
2045
+ text-decoration: underline;
2046
+}
2047
+body.fossil-dark-style .Attacher .attach-row {
2048
+ background-color: initial;
2049
+}
2050
+.Attacher .attach-dropzone {
2051
+ padding: 1em;
2052
+ text-align: center;
2053
+ background: #ffffff;
2054
+ border: 1px solid #ddd;
2055
+ cursor: pointer;
2056
+ border-radius: 0.25em;
2057
+ transition: background-color 0.15s linear;
2058
+ display: flex;
2059
+ flex-direction: row;
2060
+ flex-wrap: nowrap;
2061
+}
2062
+body.fossil-dark-style .Attacher .attach-dropzone{
2063
+ background: initial;
2064
+}
2065
+.Attacher .attach-dropzone.populated {
2066
+ background-color: #f1f8e9;
2067
+ border-color: #8bc34a;
2068
+ border-style: solid;
2069
+ text-align: left;
2070
+}
2071
+body.fossil-dark-style .Attacher .attach-dropzone.populated{
2072
+ background-color: initial;
2073
+}
2074
+.Attacher .attach-dropzone.dragover {
2075
+ background-color: #e1f5fe;
2076
+ color: black;
2077
+ border-color: #03a9f4;
2078
+}
2079
+body.fossil-dark-style .Attacher .attach-dropzone.dragover{
2080
+ background-color: #e1f5fe;
2081
+ border-color: #03a9f4;
2082
+}
2083
+.Attacher .thumbnail {
2084
+ max-width: 10em;
2085
+ max-height: 10em;
2086
+ margin: 0 1em;
2087
+}
2088
+.Attacher .attach-row-info{
2089
+ font-family: monospace;
2090
+ flex-grow: 1;
2091
+ display: flex;
2092
+ flex-direction: column;
2093
+}
2094
+.Attacher .attach-filename {}
2095
+.Attacher .attach-size {/*size and mimetype*/}
2096
+.Attacher .attach-desc {
2097
+ max-width: initial;
2098
+ width: 100%;
2099
+ box-sizing: border-box;
2100
+ min-height: 4em;
2101
+ padding: 0.5em;
2102
+ font-family: inherit;
2103
+ resize: vertical;
2104
+}
2105
+.Attacher .attach-row-remove {
2106
+ align-self: center;
2107
+ padding: 0.25em 0.75em;
2108
+ margin-left: 1em;
2109
+ background-color: #d32f2f;
2110
+ color: #fff;
2111
+ border: none;
2112
+ border-radius: 0.25em;
2113
+ cursor: pointer;
2114
+ font-weight: bold;
2115
+}
2116
+.Attacher .attach-row-remove:hover {
2117
+ background-color: #b71c1c;
2118
+}
2119
+.Attacher .attach-controls {
2120
+ display: flex;
2121
+ flex-direction: row;
2122
+ gap: 1em;
2123
+}
2124
+.Attacher .attach-controls .attach-add-button {
2125
+ padding: 0.5em 1em;
2126
+ cursor: pointer;
2127
+ flex-grow: 2;
2128
+ line-height: initial/*work around an inherited alignment quirk*/;
2129
+}
2130
+
2131
+/* .animate-X and their associated @keyframes are used by various
2132
+ widgets to animate their comings and goings. */
2133
+.animate-entrance {
2134
+ animation: slideFadeIn 0.25s linear forwards;
2135
+ transform-origin: top;
2136
+ overflow: hidden /*prevent content bleeding during expansion*/;
2137
+}
2138
+.animate-exit {
2139
+ animation: slideFadeOut 0.25s linear forwards;
2140
+ transform-origin: top;
2141
+ overflow: hidden;
2142
+}
2143
+@keyframes slideFadeIn {
2144
+ 0% {
2145
+ opacity: 0;
2146
+ transform: translateY(-1em/*must match slideFadeOut*/);
2147
+ max-height: 0;
2148
+ }
2149
+ 100% {
2150
+ opacity: 1;
2151
+ transform: translateY(0);
2152
+ max-height: 100em /*a value safely larger than the widget*/;
2153
+ }
2154
+}
2155
+@keyframes slideFadeOut {
2156
+ 0% {
2157
+ opacity: 1;
2158
+ transform: translateY(0);
2159
+ max-height: 100em /*must match slideFadeIn*/;
2160
+ }
2161
+ 100% {
2162
+ opacity: 0;
2163
+ transform: translateY(-1em/*must match slideFadeIn*/);
2164
+ max-height: 0;
2165
+ padding-top: 0;
2166
+ padding-bottom: 0;
2167
+ margin-top: 0;
2168
+ margin-bottom: 0;
2169
+ }
2170
+}
20122171
20132172
/* Objects in the "desktoponly" class are invisible on mobile */
20142173
@media screen and (max-width: 600px) {
20152174
.desktoponly {
20162175
display: none;
20172176
--- src/default.css
+++ src/default.css
@@ -1693,11 +1693,11 @@
1693 -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
1694 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
1695 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
1696 L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
1697 9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698 style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1699 background-repeat: no-repeat;
1700 background-position: center;
1701 /* When not using a background image, this additional style works
1702 reasonably well along with a ::before content of "?": */
1703 /*border-width: 1px;
@@ -2007,10 +2007,169 @@
2007 margin: 0;
2008 }
2009 div.helpPage blockquote {
2010 margin-left: 0.2em;
2011 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2012
2013 /* Objects in the "desktoponly" class are invisible on mobile */
2014 @media screen and (max-width: 600px) {
2015 .desktoponly {
2016 display: none;
2017
--- src/default.css
+++ src/default.css
@@ -1693,11 +1693,11 @@
1693 -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \
1694 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \
1695 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \
1696 L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \
1697 9.2253789,9.535657 Z M 6.5997716,10.939376 h 2.6256073 v 2.589241 H 6.5997716 Z' \
1698 style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e ");
1699 background-repeat: no-repeat;
1700 background-position: center;
1701 /* When not using a background image, this additional style works
1702 reasonably well along with a ::before content of "?": */
1703 /*border-width: 1px;
@@ -2007,10 +2007,169 @@
2007 margin: 0;
2008 }
2009 div.helpPage blockquote {
2010 margin-left: 0.2em;
2011 }
2012
2013 /* .attach* = styles for file attachments */
2014 section.attachlist {}
2015 details.attachlist > summary {
2016 cursor: pointer;
2017 }
2018 /* .Attacher is the top container element used by the JS Attacher
2019 class in fossil.attach.js. */
2020 .Attacher {
2021 margin-bottom: 1em;
2022 display: flex;
2023 flex-direction: column;
2024 gap: 0.75em;
2025 }
2026 .Attacher.reverse {
2027 flex-direction: column-reverse;
2028 }
2029 .Attacher .attach-row {
2030 display: flex;
2031 flex-direction: column;
2032 gap: 0.5em;
2033 padding: 0.75em;
2034 border: 1px dashed #ccc;
2035 border-radius: 0.25em;
2036 background-color: #fafafa;
2037 }
2038 .Attacher .error {
2039 padding: 0.5em;
2040 background-color: #d32f2f;
2041 color: #fff;
2042 }
2043 .Attacher .error a {
2044 color: inherit;
2045 text-decoration: underline;
2046 }
2047 body.fossil-dark-style .Attacher .attach-row {
2048 background-color: initial;
2049 }
2050 .Attacher .attach-dropzone {
2051 padding: 1em;
2052 text-align: center;
2053 background: #ffffff;
2054 border: 1px solid #ddd;
2055 cursor: pointer;
2056 border-radius: 0.25em;
2057 transition: background-color 0.15s linear;
2058 display: flex;
2059 flex-direction: row;
2060 flex-wrap: nowrap;
2061 }
2062 body.fossil-dark-style .Attacher .attach-dropzone{
2063 background: initial;
2064 }
2065 .Attacher .attach-dropzone.populated {
2066 background-color: #f1f8e9;
2067 border-color: #8bc34a;
2068 border-style: solid;
2069 text-align: left;
2070 }
2071 body.fossil-dark-style .Attacher .attach-dropzone.populated{
2072 background-color: initial;
2073 }
2074 .Attacher .attach-dropzone.dragover {
2075 background-color: #e1f5fe;
2076 color: black;
2077 border-color: #03a9f4;
2078 }
2079 body.fossil-dark-style .Attacher .attach-dropzone.dragover{
2080 background-color: #e1f5fe;
2081 border-color: #03a9f4;
2082 }
2083 .Attacher .thumbnail {
2084 max-width: 10em;
2085 max-height: 10em;
2086 margin: 0 1em;
2087 }
2088 .Attacher .attach-row-info{
2089 font-family: monospace;
2090 flex-grow: 1;
2091 display: flex;
2092 flex-direction: column;
2093 }
2094 .Attacher .attach-filename {}
2095 .Attacher .attach-size {/*size and mimetype*/}
2096 .Attacher .attach-desc {
2097 max-width: initial;
2098 width: 100%;
2099 box-sizing: border-box;
2100 min-height: 4em;
2101 padding: 0.5em;
2102 font-family: inherit;
2103 resize: vertical;
2104 }
2105 .Attacher .attach-row-remove {
2106 align-self: center;
2107 padding: 0.25em 0.75em;
2108 margin-left: 1em;
2109 background-color: #d32f2f;
2110 color: #fff;
2111 border: none;
2112 border-radius: 0.25em;
2113 cursor: pointer;
2114 font-weight: bold;
2115 }
2116 .Attacher .attach-row-remove:hover {
2117 background-color: #b71c1c;
2118 }
2119 .Attacher .attach-controls {
2120 display: flex;
2121 flex-direction: row;
2122 gap: 1em;
2123 }
2124 .Attacher .attach-controls .attach-add-button {
2125 padding: 0.5em 1em;
2126 cursor: pointer;
2127 flex-grow: 2;
2128 line-height: initial/*work around an inherited alignment quirk*/;
2129 }
2130
2131 /* .animate-X and their associated @keyframes are used by various
2132 widgets to animate their comings and goings. */
2133 .animate-entrance {
2134 animation: slideFadeIn 0.25s linear forwards;
2135 transform-origin: top;
2136 overflow: hidden /*prevent content bleeding during expansion*/;
2137 }
2138 .animate-exit {
2139 animation: slideFadeOut 0.25s linear forwards;
2140 transform-origin: top;
2141 overflow: hidden;
2142 }
2143 @keyframes slideFadeIn {
2144 0% {
2145 opacity: 0;
2146 transform: translateY(-1em/*must match slideFadeOut*/);
2147 max-height: 0;
2148 }
2149 100% {
2150 opacity: 1;
2151 transform: translateY(0);
2152 max-height: 100em /*a value safely larger than the widget*/;
2153 }
2154 }
2155 @keyframes slideFadeOut {
2156 0% {
2157 opacity: 1;
2158 transform: translateY(0);
2159 max-height: 100em /*must match slideFadeIn*/;
2160 }
2161 100% {
2162 opacity: 0;
2163 transform: translateY(-1em/*must match slideFadeIn*/);
2164 max-height: 0;
2165 padding-top: 0;
2166 padding-bottom: 0;
2167 margin-top: 0;
2168 margin-bottom: 0;
2169 }
2170 }
2171
2172 /* Objects in the "desktoponly" class are invisible on mobile */
2173 @media screen and (max-width: 600px) {
2174 .desktoponly {
2175 display: none;
2176
+14 -7
--- src/event.c
+++ src/event.c
@@ -117,11 +117,15 @@
117117
style_header("No Such Tech-Note");
118118
@ Cannot locate a technical note called <b>%h(zId)</b>.
119119
style_finish_page();
120120
return;
121121
}
122
- zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
122
+ zUuid = rid_to_uuid(rid);
123
+ zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
124
+ " FROM tag"
125
+ " WHERE tagname GLOB 'event-%q*'",
126
+ zId);
123127
zVerbose = P("v");
124128
if( !zVerbose ){
125129
zVerbose = P("verbose");
126130
}
127131
if( !zVerbose ){
@@ -157,11 +161,11 @@
157161
style_header("%s", blob_str(&title));
158162
if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){
159163
style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId);
160164
if( g.perm.Attach ){
161165
style_submenu_element("Attach",
162
- "%R/attachadd?technote=%!S&from=%R/technote/%!S", zId, zId);
166
+ "%R/attachadd?target=%s&from=%R/technote/%!S", zFullId, zId);
163167
}
164168
}
165169
zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate);
166170
style_submenu_element("Context", "%R/timeline?c=%.20s", zId);
167171
if( g.perm.Hyperlink ){
@@ -225,15 +229,18 @@
225229
}else{
226230
@ <pre>
227231
@ %h(blob_str(&fullbody))
228232
@ </pre>
229233
}
230
- zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
231
- " FROM tag"
232
- " WHERE tagname GLOB 'event-%q*'",
233
- zId);
234
- attachment_list(zFullId, "<h2>Attachments:</h2>", 1);
234
+ {
235
+ char * z = mprintf(
236
+ "<h2><a href='%R/attachlist?technote=%t'>Attachments</a>:</h2>",
237
+ zFullId
238
+ );
239
+ attachment_list(zFullId, z, 1);
240
+ fossil_free(z);
241
+ }
235242
document_emit_js();
236243
style_finish_page();
237244
manifest_destroy(pTNote);
238245
}
239246
240247
--- src/event.c
+++ src/event.c
@@ -117,11 +117,15 @@
117 style_header("No Such Tech-Note");
118 @ Cannot locate a technical note called <b>%h(zId)</b>.
119 style_finish_page();
120 return;
121 }
122 zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
 
 
 
 
123 zVerbose = P("v");
124 if( !zVerbose ){
125 zVerbose = P("verbose");
126 }
127 if( !zVerbose ){
@@ -157,11 +161,11 @@
157 style_header("%s", blob_str(&title));
158 if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){
159 style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId);
160 if( g.perm.Attach ){
161 style_submenu_element("Attach",
162 "%R/attachadd?technote=%!S&from=%R/technote/%!S", zId, zId);
163 }
164 }
165 zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate);
166 style_submenu_element("Context", "%R/timeline?c=%.20s", zId);
167 if( g.perm.Hyperlink ){
@@ -225,15 +229,18 @@
225 }else{
226 @ <pre>
227 @ %h(blob_str(&fullbody))
228 @ </pre>
229 }
230 zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
231 " FROM tag"
232 " WHERE tagname GLOB 'event-%q*'",
233 zId);
234 attachment_list(zFullId, "<h2>Attachments:</h2>", 1);
 
 
 
235 document_emit_js();
236 style_finish_page();
237 manifest_destroy(pTNote);
238 }
239
240
--- src/event.c
+++ src/event.c
@@ -117,11 +117,15 @@
117 style_header("No Such Tech-Note");
118 @ Cannot locate a technical note called <b>%h(zId)</b>.
119 style_finish_page();
120 return;
121 }
122 zUuid = rid_to_uuid(rid);
123 zFullId = db_text(0, "SELECT SUBSTR(tagname,7)"
124 " FROM tag"
125 " WHERE tagname GLOB 'event-%q*'",
126 zId);
127 zVerbose = P("v");
128 if( !zVerbose ){
129 zVerbose = P("verbose");
130 }
131 if( !zVerbose ){
@@ -157,11 +161,11 @@
161 style_header("%s", blob_str(&title));
162 if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){
163 style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId);
164 if( g.perm.Attach ){
165 style_submenu_element("Attach",
166 "%R/attachadd?target=%s&from=%R/technote/%!S", zFullId, zId);
167 }
168 }
169 zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate);
170 style_submenu_element("Context", "%R/timeline?c=%.20s", zId);
171 if( g.perm.Hyperlink ){
@@ -225,15 +229,18 @@
229 }else{
230 @ <pre>
231 @ %h(blob_str(&fullbody))
232 @ </pre>
233 }
234 {
235 char * z = mprintf(
236 "<h2><a href='%R/attachlist?technote=%t'>Attachments</a>:</h2>",
237 zFullId
238 );
239 attachment_list(zFullId, z, 1);
240 fossil_free(z);
241 }
242 document_emit_js();
243 style_finish_page();
244 manifest_destroy(pTNote);
245 }
246
247
+536 -77
--- src/forum.c
+++ src/forum.c
@@ -96,11 +96,11 @@
9696
9797
/*
9898
** Returns a high-level representation of the forum-statuses setting.
9999
** This is a singleton, cached across calls.
100100
*/
101
-static const ForumStatusList * forum_statuses(void){
101
+const ForumStatusList * forum_statuses(void){
102102
static ForumStatusList fses = {0,0};
103103
static int once = 0;
104104
while( !once ){
105105
++once;
106106
/* Read `forum-statuses` setting and transform it into the
@@ -151,11 +151,13 @@
151151
** found, the corresponding object is returned. If no match is found
152152
** then (A) if bFirst is false then 0 is returned, else (B) the first
153153
** entry in the list is returned, noting that the list may be empty,
154154
** in which case 0 is returned.
155155
*/
156
-const ForumStatus * forum_status_by_value(const char *z, int bFirst){
156
+static const ForumStatus * forum_status_by_value(
157
+ const char *z, int bFirst
158
+){
157159
const ForumStatusList * const fses = forum_statuses();
158160
const ForumStatus * fs0 = 0;
159161
unsigned int i;
160162
if( !fses->n ) return 0;
161163
for( i = 0; i < fses->n; ++i ){
@@ -227,18 +229,18 @@
227229
}
228230
229231
/*
230232
** Works like forumpost_head_rid() but expects zUuid to be an
231233
** 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
+** it's unambiguous. Returns the rid of the head post, -1 if the name
235
+** is ambiguous, and 0 if the name cannot be resolved as a forum post.
234236
*/
235237
int forumpost_head_rid2(const char *zUuid){
236238
const int fpid = symbolic_name_to_rid(zUuid, "f");
237239
return fpid>0
238240
? forumpost_head_rid(fpid)
239
- : 0;
241
+ : fpid;
240242
}
241243
242244
/*
243245
** Given a forum post RID and user name, returns true if zUserName
244246
** matches the event.(euser,user) field for a formpost entry with the
@@ -408,18 +410,17 @@
408410
** no tag is added. Similarly, it will only remove a tag from a post
409411
** which has its own tag, and will not remove an inherited one from a
410412
** parent post.
411413
**
412414
** If addTag is true and frid is already tagged, this is a
413
-** no-op. Likewise, if addTag is false and frid is not tagged
414
-** (not accounting for an inherited closed tag), this is a no-op.
415
-**
416
-** If bCheckIrt is true then the forum post IRT hierarchy is searched
417
-** for the tag, otherwise only the given RID is checked.
418
-**
419
-** Returns true if it actually creates a new tag, else false. Fails
420
-** fatally on error.
415
+** no-op. Likewise, if addTag is false and frid is not tagged (not
416
+** accounting for a tag inherited via an in-response-to post), this is
417
+** a no-op.
418
+**
419
+** Returns a positive value (a new tag.tagid value) if it actually
420
+** creates a new tag, else 0. On error it returns a negative alue
421
+** and g.zErrMsg "should" contain details.
421422
**
422423
** If it returns true then state from previously-loaded posts may be
423424
** invalidated if they refer to the amended post or a response to it.
424425
** e.g. if zTagName is "closed" then ForumPost::iClosed values may be
425426
** stale.
@@ -438,11 +439,12 @@
438439
**
439440
** - The applied tag is propagating so so that "closed" tags can
440441
** account for how edits of posts are handled. This differs from
441442
** closure of a branch, where a non-propagating tag is used.
442443
*/
443
-static int forumpost_tag(int frid, const char *zTagName, int addTag,
444
+static int forumpost_tag(int frid, int addTag,
445
+ const char *zTagName,
444446
const char *zValue){
445447
Blob artifact = BLOB_INITIALIZER; /* Output artifact */
446448
Blob cksum = BLOB_INITIALIZER; /* Z-card */
447449
int iTagged; /* true if frid is already tagged */
448450
int trid; /* RID of new control artifact */
@@ -461,11 +463,11 @@
461463
zValue = 0;
462464
}
463465
if( addTag && iTagged ){
464466
char *zOld = 0;
465467
int cmp;
466
- rid_has_tag2(iTagged, zTagName, &zOld);
468
+ rid_has_tag2(frid, zTagName, &zOld);
467469
cmp = fossil_strcmp(zOld, zValue);
468470
fossil_free(zOld);
469471
if( 0==cmp ){
470472
/* Same value - leave it as is. */
471473
db_end_transaction(0);
@@ -481,22 +483,58 @@
481483
md5sum_blob(&artifact, &cksum);
482484
blob_appendf(&artifact, "Z %b\n", &cksum);
483485
blob_reset(&cksum);
484486
trid = content_put_ex(&artifact, 0, 0, 0, 0);
485487
if( trid==0 ){
486
- fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
488
+ return -1;
487489
}
488490
if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
489
- fossil_fatal("%s", g.zErrMsg);
491
+ return -2;
490492
}
491493
assert( blob_is_reset(&artifact) );
492494
db_add_unsent(trid);
493495
admin_log("Tag forum post %S with %c%s",
494496
zUuid, addTag ? '*' : '-', zTagName);
495497
fossil_free(zUuid);
496498
db_end_transaction(0);
497
- return 1;
499
+ return trid;
500
+}
501
+
502
+/*
503
+** COMMAND: test-forumpost-tag
504
+**
505
+** Usage: %fossil test-forumpost-tag ?-cancel? THREADID TAGNAME TAGVAL
506
+**
507
+** A tester for forumpost_tag(). It always rolls back changes.
508
+*/
509
+void test_forumpost_tag_command(void){
510
+ int fpid;
511
+ int rc;
512
+ const char *zPost;
513
+ const char *zTag;
514
+ const char *zVal;
515
+ const int bAdd = find_option("cancel","",0)==0;
516
+
517
+ db_find_and_open_repository(0,0);
518
+ verify_all_options();
519
+ if( g.argc<5 ){
520
+ usage("forum-post-id tag-name value");
521
+ }
522
+ zPost = g.argv[2];
523
+ zTag = g.argv[3];
524
+ zVal = g.argv[4];
525
+
526
+ db_begin_transaction();
527
+ fpid = forumpost_head_rid2(zPost);
528
+ if( fpid<=0 ){
529
+ fossil_fatal("Cannot resolve post ID %s", zPost);
530
+ }
531
+ fossil_print("%s => %d => %z\n", zTag, fpid,
532
+ rid_to_uuid(fpid));
533
+ rc = forumpost_tag(fpid, bAdd, zTag, zVal);
534
+ fossil_print("tag fpid=%d taxgxref.tagid=%d\n", fpid, rc);
535
+ db_end_transaction(1);
498536
}
499537
500538
/*
501539
** Returns true if the forum-close-policy setting is true, else false,
502540
** caching the result for subsequent calls.
@@ -894,11 +932,19 @@
894932
break;
895933
}
896934
}
897935
if( !sCurrent ) sCurrent = &fss->aStatus[0];
898936
assert( sCurrent );
899
- @ <span class='forum-status-selection'>
937
+ @ <fieldset class='forum-status-selection'>\
938
+ @ <legend>Status \
939
+ @ <span class='help-buttonlet initially-hidden'>\
940
+ @ Moderators and the post's owner may change \
941
+ @ the status of this thread unless it is still. \
942
+ @ pending moderation. See \
943
+ @ <a href='%R/help/forum-statuses' target='_new'>\
944
+ @ /help/forum-statuses</a></span>\
945
+ @ </legend>\
900946
if( forum_may_set_status(fp->fpid) ){
901947
@ <form method="post" action='%R/forumpost_status'>
902948
login_insert_csrf_secret();
903949
@ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
904950
@ <select name='status' data-fpid='%s(fp->zUuid)'\
@@ -917,11 +963,11 @@
917963
@ </form>
918964
/* Form is activated in fossil.page.forumpost.js */
919965
}else{
920966
@ <button disabled>Status: %h(sCurrent->zLabel)</button>
921967
}
922
- @ </span>
968
+ @ </fieldset>
923969
fossil_free(zCurrent);
924970
}
925971
}
926972
927973
/*
@@ -1045,17 +1091,26 @@
10451091
/*
10461092
** Renders the attachment list for the given forum post.
10471093
** Emits no output if there are no attachments.
10481094
*/
10491095
static void forum_render_attachment_list(const char *zUuid){
1050
- char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%s'>"
1051
- "Attachments:</a>", zUuid);
1052
- attachment_list(zUuid, zLbl,
1053
- ATTACHLIST_HRULE_ABOVE
1054
- | ATTACHLIST_SIZE
1055
- | ATTACHLIST_HIDE_UNAPPROVED);
1056
- fossil_free(zLbl);
1096
+#if 1
1097
+ attachment_list(zUuid, "&#x1f4ce; Attachments", 0
1098
+ | ATTACHLIST_SIZE
1099
+ | ATTACHLIST_HIDE_UNAPPROVED
1100
+ | ATTACHLIST_DETAILS_CLOSED
1101
+ | ATTACHLIST_HIDE_EMPTY);
1102
+#else
1103
+ char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>"
1104
+ "Attachments</a>:", zUuid);
1105
+ attachment_list(zUuid, zLbl,
1106
+ ATTACHLIST_HRULE_ABOVE
1107
+ | ATTACHLIST_SIZE
1108
+ | ATTACHLIST_HIDE_UNAPPROVED
1109
+ | ATTACHLIST_HIDE_EMPTY);
1110
+ fossil_free(zLbl);
1111
+#endif
10571112
}
10581113
10591114
/*
10601115
** Renders the attachment list for p or (if not NULL) pEditHead.
10611116
*/
@@ -1098,24 +1153,33 @@
10981153
10991154
/* Get the manifest for the post. Abort if not found (e.g. shunned). */
11001155
pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
11011156
if( !pManifest ) return;
11021157
iClosed = forumpost_is_closed(pThread, p, 1);
1158
+ bPrivate = content_is_private(p->fpid);
1159
+ bSameUser = login_is_individual()
1160
+ && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
11031161
/* When not in raw mode, create the border around the post. */
11041162
if( !bRaw ){
11051163
/* Open the <div> enclosing the post. Set the class string to mark the post
11061164
** as selected and/or obsolete. */
11071165
iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1;
1108
- @ <div id='forum%d(p->fpid)' class='forumTime\
1166
+ @ <div id='forum%d(p->fpid)' class='forumpost forumTime\
11091167
@ %s(bSelect ? " forumSel" : "")\
11101168
@ %s(iClosed ? " forumClosed" : "")\
11111169
@ %s(p->pEditTail ? " forumObs" : "")' \
11121170
if( iIndent && iIndentScale ){
1113
- @ style='margin-left:%d(iIndent*iIndentScale)ex;'>
1114
- }else{
1115
- @ >
1171
+ @ style='margin-left:%d(iIndent*iIndentScale)ex;' \
1172
+ }
1173
+ /* These data-X fields are used by the JS editor. */
1174
+ if( p->pIrt ){
1175
+ @ data-firt="%s(p->pIrt->zUuid)" \
1176
+ }
1177
+ if( p->pEditHead ){
1178
+ @ data-fedithead="%s(p->pEditHead->zUuid)" \
11161179
}
1180
+ @ data-fpid="%s(p->zUuid)">\
11171181
11181182
/* If this is the first post (or an edit thereof), emit the thread title. */
11191183
if( pManifest->zThreadTitle ){
11201184
@ <h1>%h(pManifest->zThreadTitle)</h1>
11211185
}
@@ -1196,32 +1260,33 @@
11961260
/* Provide a link to the raw source code. */
11971261
if( !bUnf ){
11981262
@ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
11991263
}
12001264
@ </h3>
1265
+
1266
+ if( bPrivate && (bSameUser || g.perm.Admin || g.perm.ModForum) ){
1267
+ moderation_pending_www(p->fpid);
1268
+ }
12011269
}/*!bRaw*/
12021270
1203
- /* Check if this post is approved, also if it's by the current user. */
1204
- bPrivate = content_is_private(p->fpid);
1205
- bSameUser = login_is_individual()
1206
- && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
1207
-
1208
- /* Render the post if the user is able to see it. */
1271
+ /* Check if this post is approved, also if it's by the current user.
1272
+ Render the post if the user is able to see it. */
12091273
if( bPrivate && !g.perm.ModForum && !bSameUser ){
12101274
@ <p><span class="modpending">Awaiting Moderator Approval</span></p>
12111275
}else{
12121276
if( bRaw || bUnf || p->pEditTail ){
12131277
zMimetype = "text/plain";
12141278
}else{
12151279
zMimetype = pManifest->zMimetype;
12161280
}
12171281
forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw);
1218
- forum_render_attachment_list2(p);
12191282
}
12201283
12211284
/* When not in raw mode, finish creating the border around the post. */
12221285
if( !bRaw ){
1286
+ int bBrBeforeAttach = 0; /* Layout kludge for Attach button */
1287
+ forum_render_attachment_list2(p);
12231288
/* If the user is able to write to the forum and if this post has not been
12241289
** edited, create a form with various interaction buttons. */
12251290
if( g.perm.WrForum && !p->pEditTail ){
12261291
@ <div class="forumpost-single-controls">\
12271292
@ <form action="%R/forumedit" method="POST">
@@ -1250,10 +1315,11 @@
12501315
@ <br><label><input type="checkbox" name="trust">
12511316
@ Trust user "%h(pManifest->zUser)" so that future posts by \
12521317
@ "%h(pManifest->zUser)" do not require moderation.
12531318
@ </label>
12541319
@ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)">
1320
+ bBrBeforeAttach = 1 /* slightly unmangle the layout */;
12551321
}
12561322
}else if( bSameUser ){
12571323
/* Allow users to delete (reject) their own pending posts. */
12581324
@ <input type="submit" name="reject" value="Delete">
12591325
}
@@ -1273,23 +1339,19 @@
12731339
@ %s(iClosed ? "action-reopen" : "action-close")'/>
12741340
/* ^^^ activated by fossil.page.forumpost.js */
12751341
}
12761342
@ </form>
12771343
}
1278
- if( g.perm.Admin ||
1279
- (login_is_individual()
1280
- && forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
1344
+ if( attach_user_may(p/*not pHead*/->fpid, CFTYPE_FORUM) ){
12811345
/* When an admin edits someone else's post, the admin
12821346
** effectively takes over ownership of it (and we currently
12831347
** have no way of passing it back). Because of this, we
12841348
** check the ownership of `p` instead of `pHead`. */
1285
- @ <form method="post" action="%R/attachadd">\
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>
1349
+ if( bBrBeforeAttach ){
1350
+ @ <br>
1351
+ }
1352
+ attach_render_attachadd_button(pHead->zUuid);
12911353
}
12921354
}
12931355
@ </div>
12941356
}
12951357
if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
@@ -1475,11 +1537,12 @@
14751537
** to all forum-related pages. It does not include page-specific
14761538
** code (e.g. "forum.js").
14771539
*/
14781540
static void forum_emit_js(void){
14791541
builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1480
- NULL);
1542
+ "attach", "tabs", "storage",
1543
+ "popupwidget", NULL);
14811544
builtin_request_js("fossil.page.forumpost.js");
14821545
}
14831546
14841547
/*
14851548
** WEBPAGE: forumpost
@@ -1635,21 +1698,13 @@
16351698
if( g.perm.WrTForum ) return 0;
16361699
if( g.perm.ModForum ) return 0;
16371700
return 1;
16381701
}
16391702
1640
-/*
1641
-** Return true if the string is white-space only.
1642
-*/
1643
-static int whitespace_only(const char *z){
1644
- if( z==0 ) return 1;
1645
- while( z[0] && fossil_isspace(z[0]) ){ z++; }
1646
- return z[0]==0;
1647
-}
1648
-
16491703
/* Flags for use with forum_post() */
16501704
#define FPOST_NO_ALERT 1 /* do not send any alerts */
1705
+#define FPOST_DRYRUN 2 /* do not save the artifact */
16511706
16521707
/*
16531708
** Return a flags value for use with the final argument to
16541709
** forum_post(), extracted from the CGI environment.
16551710
*/
@@ -1656,10 +1711,13 @@
16561711
static int forum_post_flags(void){
16571712
int iPostFlags = 0;
16581713
if( g.perm.Debug && P("fpsilent")!=0 ){
16591714
iPostFlags |= FPOST_NO_ALERT;
16601715
}
1716
+ if( P("dryrun")!=0 ){
1717
+ iPostFlags |= FPOST_DRYRUN;
1718
+ }
16611719
return iPostFlags;
16621720
}
16631721
16641722
/*
16651723
** Add a new Forum Post artifact to the repository.
@@ -1687,11 +1745,11 @@
16871745
if( !g.perm.Admin && (iEdit || iInReplyTo)
16881746
&& forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
16891747
forumpost_error_closed();
16901748
return 0;
16911749
}
1692
- if( iEdit==0 && whitespace_only(zContent) ){
1750
+ if( iEdit==0 && fossil_all_whitespace(zContent) ){
16931751
return 0;
16941752
}
16951753
if( iInReplyTo==0 && iEdit>0 ){
16961754
iBasis = iEdit;
16971755
iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
@@ -1712,17 +1770,19 @@
17121770
fossil_free(zG);
17131771
}
17141772
if( zTitle ){
17151773
blob_appendf(&x, "H %F\n", zTitle);
17161774
}
1717
- zI = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iInReplyTo);
1775
+ zI = rid_to_uuid(iInReplyTo);
17181776
if( zI ){
17191777
blob_appendf(&x, "I %s\n", zI);
17201778
fossil_free(zI);
17211779
}
1722
- if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1723
- blob_appendf(&x, "N %s\n", zMimetype);
1780
+ if( zMimetype!=0
1781
+ && zMimetype[0]!=0
1782
+ && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1783
+ blob_appendf(&x, "N %F\n", zMimetype);
17241784
}
17251785
if( iEdit>0 ){
17261786
char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
17271787
if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
17281788
blob_appendf(&x, "P %s\n", zP);
@@ -1750,11 +1810,11 @@
17501810
webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
17511811
}
17521812
webpage_assert( pPost->type==CFTYPE_FORUM );
17531813
manifest_destroy(pPost);
17541814
1755
- if( P("dryrun") ){
1815
+ if( (iFlags & FPOST_DRYRUN)!=0 ){
17561816
@ <div class='debug'>
17571817
@ This is the artifact that would have been generated:
17581818
@ <pre>%h(blob_str(&x))</pre>
17591819
@ </div>
17601820
blob_reset(&x);
@@ -1813,12 +1873,17 @@
18131873
int addTag, int validFpid){
18141874
if( !cgi_csrf_safe(2) ){
18151875
webpage_error("CSRF validation failed");
18161876
}else{
18171877
const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1818
- forumpost_tag(fpid, zTag, addTag, zVal);
1819
- cgi_redirectf("%R/forumpost/%S",P("fpid"));
1878
+ if( fpid>0 ){
1879
+ if( forumpost_tag(fpid, addTag, zTag, zVal) < 0 ){
1880
+ webpage_error("Tagging artifact failed: %s", g.zErrMsg);
1881
+ }else{
1882
+ cgi_redirectf("%R/forumpost/%S",P("fpid"));
1883
+ }
1884
+ }
18201885
}
18211886
}
18221887
18231888
/*
18241889
** WEBPAGE: forumpost_close hidden
@@ -1965,11 +2030,11 @@
19652030
@ it.</div>
19662031
}
19672032
}
19682033
19692034
/*
1970
-** WEBPAGE: forume1
2035
+** WEBPAGE: forume1 hidden
19712036
**
19722037
** Start a new forum thread.
19732038
*/
19742039
void forumnew_page(void){
19752040
const char *zTitle = PDT("title","");
@@ -1983,36 +2048,45 @@
19832048
}
19842049
if( P("submit") && cgi_csrf_safe(2) ){
19852050
if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent,
19862051
forum_post_flags()) ) return;
19872052
}
1988
- if( P("preview") && !whitespace_only(zContent) ){
2053
+ if( P("preview") && !fossil_all_whitespace(zContent) ){
19892054
@ <h1>Preview:</h1>
19902055
forum_render(zTitle, zMimetype, zContent, "forumEdit", 1);
19912056
}
19922057
style_set_current_feature("forum");
19932058
style_header("New Forum Thread");
1994
- @ <form action="%R/forume1" method="POST">
2059
+
2060
+ @ <form action="%R/forume1" method="POST" \
2061
+ @ class="remove-if-replaced">
19952062
@ <h1>New Thread:</h1>
19962063
forum_from_line();
19972064
forum_post_widget(zTitle, zMimetype, zContent);
19982065
@ <input type="submit" name="preview" value="Preview">
1999
- if( P("preview") && !whitespace_only(zContent) ){
2066
+ if( P("preview") && !fossil_all_whitespace(zContent) ){
20002067
@ <input type="submit" name="submit" value="Submit">
20012068
}else{
20022069
@ <input type="submit" name="submit" value="Submit" disabled>
20032070
}
20042071
forum_render_debug_options();
20052072
login_insert_csrf_secret();
20062073
@ </form>
2074
+ /* When JS is disabled the block above will work. When it's
2075
+ enabled, the above will be removed and JS will render the editor
2076
+ form in the next element. */
2077
+ @ <div hidden id='forumnew-placeholder'>
2078
+ @ <input type='hidden' name='title' value='%h(zTitle)'>
2079
+ login_insert_csrf_secret();
2080
+ @ </div>
20072081
forum_render_attachment_notice();
20082082
forum_emit_js();
20092083
style_finish_page();
20102084
}
20112085
20122086
/*
2013
-** WEBPAGE: forume2
2087
+** WEBPAGE: forume2 hidden
20142088
**
20152089
** Edit an existing forum message.
20162090
** Query parameters:
20172091
**
20182092
** fpid=X Hash of the post to be edited. REQUIRED
@@ -2097,11 +2171,11 @@
20972171
style_set_current_feature("forum");
20982172
isDelete = P("nullout")!=0;
20992173
if( P("submit")
21002174
&& isCsrfSafe
21012175
&& (zContent = PDT("content",""))!=0
2102
- && (!whitespace_only(zContent) || isDelete)
2176
+ && (isDelete || !fossil_all_whitespace(zContent))
21032177
){
21042178
int done = 1;
21052179
const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE);
21062180
if( bReply ){
21072181
done = forum_post(0, fpid, 0, 0, zMimetype, zContent,
@@ -2175,11 +2249,11 @@
21752249
zDisplayName = display_name_from_login(pPost->zUser);
21762250
@ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3>
21772251
fossil_free(zDisplayName);
21782252
fossil_free(zDate);
21792253
forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1);
2180
- if( bPreview && !whitespace_only(zContent) ){
2254
+ if( bPreview && !fossil_all_whitespace(zContent) ){
21812255
@ <h2>Preview:</h2>
21822256
forum_render(0, zMimetype,zContent, "forumEdit", 1);
21832257
}
21842258
@ <h2>Enter Reply:</h2>
21852259
@ <form action="%R/forume2" method="POST">
@@ -2190,11 +2264,11 @@
21902264
}
21912265
if( !isDelete ){
21922266
@ <input type="submit" name="preview" value="Preview">
21932267
}
21942268
@ <input type="submit" name="cancel" value="Cancel">
2195
- if( (bPreview && !whitespace_only(zContent)) || isDelete ){
2269
+ if( isDelete || (bPreview && !fossil_all_whitespace(zContent)) ){
21962270
if( !iClosed || g.perm.Admin ) {
21972271
@ <input type="submit" name="submit" value="Submit">
21982272
}
21992273
}
22002274
forum_render_debug_options();
@@ -2201,11 +2275,13 @@
22012275
login_insert_csrf_secret();
22022276
@ </form>
22032277
if( !bReply ){
22042278
forum_render_attachment_list(rid_to_uuid(fpid));
22052279
}
2206
- forum_render_attachment_notice();
2280
+ if( !isDelete ){
2281
+ forum_render_attachment_notice();
2282
+ }
22072283
forum_emit_js();
22082284
style_finish_page();
22092285
}
22102286
22112287
/*
@@ -2214,21 +2290,21 @@
22142290
** to closed posts. If false, only administrators may do so. Note that
22152291
** this only affects the forum web UI, not post-closing tags which
22162292
** arrive via the command-line or from synchronization with a remote.
22172293
** This policy also determines whether moderators may delete forum
22182294
** attachments.
2219
-*/
2220
-/*
2295
+**
22212296
** SETTING: forum-title width=20 default=Forum
22222297
** This is the name or "title" of the Forum for this repository. The
22232298
** default is just "Forum". But in some setups, admins might want to
22242299
** change it to "Developer Forum" or "User Forum" or whatever other name
22252300
** seems more appropriate for the particular usage.
22262301
**
22272302
** SETTING: attachment-size-limit width=16
2228
-** The maximum number of bytes for an attachment. The default (or 0) is
2229
-** unlimited but a limit may be imposed by the web server or a proxy.
2303
+** The maximum number of bytes for an attachment to a wiki page,
2304
+** ticket, tech note, or forum post. The default (or 0) is unlimited
2305
+** but a limit may be imposed by the web server or a proxy.
22302306
**
22312307
** SETTING: forum-statuses width=40 block-text
22322308
** This JSON5-formatted value defines an array of objects describing
22332309
** the available statuses of forum posts. Each entry of the array must
22342310
** be an object in the form {label:"X",value:"Y"}.
@@ -2723,5 +2799,388 @@
27232799
** URL arg when the status selection list is activated. */
27242800
forum_emit_js();
27252801
}
27262802
style_finish_page();
27272803
}
2804
+
2805
+/*
2806
+** The AJAX counterpart of forum_post().
2807
+**
2808
+** Returns the new artifact's RID on success, 0 if no changes were
2809
+** necessary (e.g. an empty new post or dry-run mode), and a negative
2810
+** value on error. If it returns a negative value then it will have
2811
+** populated the ajax response state with an error object.
2812
+**
2813
+** zTitle must be NULL if iInReplyTo>0 and must be non-empty if
2814
+** iInReplyTo==0.
2815
+**
2816
+** The caller must have started a transaction and must roll it back if
2817
+** this call returns <=0, noting that only the negative-value case is
2818
+** an error.
2819
+**
2820
+** This function does some work to try to ensure that duplicate
2821
+** entries are not save (this can happen as a side effect of the forum
2822
+** post editor added in 2026-06). If the given post will not have been
2823
+** materially edited by these changes, they are not applied and the
2824
+** rid of the existing entry is used.
2825
+**
2826
+** Maintenance reminders:
2827
+**
2828
+** - iInReplyTo==0 && iEdit==0: new thread
2829
+** - iInReplyTo==0 && iEdit>0 : edit top post or response
2830
+** - iInReplyTo>0 && iEdit==0: new response
2831
+** - iInReplyTo>0 && iEdit>0 : edit response
2832
+*/
2833
+static int forum_post_ajax(
2834
+ const char *zTitle, /* Title. NULL for replies */
2835
+ int iInReplyTo, /* Post replying to. 0 for new threads */
2836
+ int iEdit, /* Post being edited, or zero for a new post */
2837
+ const char *zUser, /* Username. NULL means use login name */
2838
+ const char *zMimetype, /* Mimetype of content. */
2839
+ const char *zContent, /* Content */
2840
+ int iFlags /* FPOST_xyz flag values */
2841
+){
2842
+ char *zI;
2843
+ char *zG;
2844
+ char *zP = 0;
2845
+ int iBasis;
2846
+ Blob x = BLOB_INITIALIZER,
2847
+ cksum = BLOB_INITIALIZER,
2848
+ formatCheck = BLOB_INITIALIZER,
2849
+ errMsg = BLOB_INITIALIZER;
2850
+ Manifest *pPost = 0;
2851
+ int nContent = zContent ? (int)strlen(zContent) : 0;
2852
+ int rc = 0;
2853
+
2854
+ assert( db_transaction_nesting_depth()>0 );
2855
+ schema_forum();
2856
+ if( iEdit==0 && fossil_all_whitespace(zContent) ){
2857
+ return 0;
2858
+ }
2859
+ if( !g.perm.Admin && (iEdit || iInReplyTo)
2860
+ && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
2861
+ return -ajax_route_error(400, "Thread is closed.");
2862
+ }
2863
+ if( 0==iInReplyTo && fossil_all_whitespace(zTitle) ){
2864
+ return -ajax_route_error(400, "Empty title is not permitted.");
2865
+ }
2866
+
2867
+ if( zUser==0 ){
2868
+ if( login_is_nobody() ){
2869
+ zUser = "anonymous";
2870
+ }else{
2871
+ zUser = login_name();
2872
+ }
2873
+ }
2874
+ if( iEdit>0
2875
+ && !g.perm.Admin
2876
+ && !forumpost_is_owner(iEdit, zUser) ){
2877
+ return -ajax_route_error(
2878
+ 403, "Only admins may edit other peoples' posts."
2879
+ );
2880
+ }
2881
+ if( iInReplyTo==0 && iEdit>0 ){
2882
+ iBasis = iEdit;
2883
+ iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d",
2884
+ iEdit);
2885
+ }else{
2886
+ iBasis = iInReplyTo;
2887
+ /* TODO (2026-06-008) If (iInReplyTo>0 && iEdit>0), validate that
2888
+ ** iInReplyTo is connected to iEdit properly, else we risk
2889
+ ** reparenting the new edit and having unrepredictable downstream
2890
+ ** side effects. */
2891
+ }
2892
+
2893
+ if( 0!=zMimetype && 0==zMimetype[0] ){
2894
+ zMimetype = 0;
2895
+ }
2896
+
2897
+ if( 0!=zTitle && 0==zTitle[0] ) zTitle = 0;
2898
+ webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 );
2899
+
2900
+ if( iEdit>0 ){
2901
+ int cmp;
2902
+ pPost = manifest_get(iEdit, CFTYPE_FORUM, 0);
2903
+ if( pPost==0 ){
2904
+ rc = -ajax_route_error(404, "Missing edit artifact %d", iEdit);
2905
+ goto post_ajax_end;
2906
+ }
2907
+ /*
2908
+ ** If the old content matches the new then do not save a new copy.
2909
+ ** It's easy to get re-posts of unedited content via the forum
2910
+ ** editor, especially since the one added in 2026-06, where a
2911
+ ** post's status and attachments may be amended from the editor
2912
+ ** without modifying any of the post's content. In the legacy
2913
+ ** editor such "out-of-band" changes weren't possible and users
2914
+ ** have never made a practice of re-posting unedited content.
2915
+ **
2916
+ ** We compare the following fields to the original: user, mimetype,
2917
+ ** content, and (for root posts only) the title.
2918
+ */
2919
+ cmp = (0==pPost->zInReplyTo)
2920
+ ? fossil_strcmp(pPost->zThreadTitle, zTitle)
2921
+ : 0;
2922
+ if( 0==cmp ){
2923
+ cmp=fossil_strcmp(pPost->zWiki, zContent);
2924
+ if( 0==cmp ){
2925
+ cmp = fossil_strcmp(pPost->zUser, zUser);
2926
+ }
2927
+ if( 0==cmp
2928
+ && 0!=(cmp=fossil_strcmp(pPost->zMimetype, zMimetype)) ){
2929
+ /* Extra mimetype checks for a common condition seen elsewhere */
2930
+ if( (0==zMimetype
2931
+ && 0==fossil_strcmp(pPost->zMimetype, "text/x-fossil-wiki"))
2932
+ || (0==pPost->zMimetype
2933
+ && 0==fossil_strcmp(zMimetype, "text/x-fossil-wiki")) ){
2934
+ cmp = 0;
2935
+ }
2936
+ }
2937
+ if( 0==cmp ){
2938
+ rc = iEdit;
2939
+ goto post_ajax_end;
2940
+ }
2941
+ }
2942
+ zP = rid_to_uuid(iEdit);
2943
+ }
2944
+
2945
+ /* Write the new artifact */
2946
+ blob_init(&x, 0, 0);
2947
+ blob_appendf(&x, "D %z\n", date_in_standard_format("now"));
2948
+ zG = db_text(
2949
+ 0,
2950
+ "SELECT uuid FROM blob, forumpost"
2951
+ " WHERE blob.rid==forumpost.froot"
2952
+ " AND forumpost.fpid=%d",
2953
+ iBasis
2954
+ );
2955
+ if( zG ){
2956
+ blob_appendf(&x, "G %z\n", zG);
2957
+ }
2958
+ if( zTitle ){
2959
+ blob_appendf(&x, "H %F\n", zTitle);
2960
+ }
2961
+ if( iInReplyTo>0 ){
2962
+ zI = rid_to_uuid(iInReplyTo);
2963
+ if( 0==zI ){
2964
+ rc = -ajax_route_error(404, "Missing in-reply-to artifact %d",
2965
+ iInReplyTo);
2966
+ goto post_ajax_end;
2967
+ }
2968
+ blob_appendf(&x, "I %z\n", zI);
2969
+ }
2970
+ if( zMimetype!=0
2971
+ && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
2972
+ blob_appendf(&x, "N %F\n", zMimetype);
2973
+ }
2974
+ if( zP ){
2975
+ blob_appendf(&x, "P %s\n", zP);
2976
+ }
2977
+
2978
+ blob_appendf(&x, "U %F\n", zUser);
2979
+ blob_appendf(&x, "W %d\n%s\n", nContent, zContent);
2980
+ md5sum_blob(&x, &cksum);
2981
+ blob_appendf(&x, "Z %b\n", &cksum);
2982
+ blob_reset(&cksum);
2983
+
2984
+ /* Verify that the artifact we are creating is well-formed */
2985
+ blob_init(&formatCheck, 0, 0);
2986
+ blob_init(&errMsg, 0, 0);
2987
+ blob_copy(&formatCheck, &x);
2988
+ pPost = manifest_parse(&formatCheck, 0, &errMsg);
2989
+ if( pPost==0 ){
2990
+ ajax_route_error(500, "Malformed forum post artifact: %b", &errMsg);
2991
+ rc = -500;
2992
+ goto post_ajax_end;
2993
+ }
2994
+ webpage_assert( pPost->type==CFTYPE_FORUM );
2995
+
2996
+ if( (iFlags & FPOST_DRYRUN)!=0 ){
2997
+ rc = 0;
2998
+ }else{
2999
+ int nrid;
3000
+ db_begin_transaction();
3001
+ nrid = wiki_put(&x, iEdit>0 ? iEdit : 0, forum_need_moderation());
3002
+ blob_reset(&x);
3003
+ if( (iFlags & FPOST_NO_ALERT)!=0 ){
3004
+ alert_unqueue('f', nrid);
3005
+ }
3006
+ rc = nrid;
3007
+ db_end_transaction(0);
3008
+ }
3009
+post_ajax_end:
3010
+ manifest_destroy(pPost);
3011
+ fossil_free(zP);
3012
+ blob_reset(&x);
3013
+ blob_reset(&cksum);
3014
+ blob_reset(&formatCheck);
3015
+ return rc;
3016
+}
3017
+/*
3018
+** WEBPAGE: forumajax_save hidden
3019
+**
3020
+** WIP
3021
+**
3022
+** Response JSON:
3023
+**
3024
+** { uuid: hash, ...tbd }
3025
+*/
3026
+void forum_ajax_save_page(void){
3027
+ const char *zFpid;
3028
+ const char *zTitle;
3029
+ const char *zIrt;
3030
+ const char *zMimetype;
3031
+ const char *zContent;
3032
+ const char *zStatus;
3033
+ const int bHasAttachment = P("file1")!=0;
3034
+ Manifest *pPost = 0;
3035
+ char *zNewUuid = 0;
3036
+ int firt = 0; /* In-reply-to rid or 0 */
3037
+ int fpid = 0; /* Post rid being edited or 0 */
3038
+ int rc = 0; /* Result code. */
3039
+ int nrid = 0; /* New artifact rid. */
3040
+ int iPostFlags; /* forum_post_flags() (after perms check) */
3041
+ int bRollback; /* True = roll back. */
3042
+ int nAttach = 0; /* Number of attachments added */
3043
+ int bStatusSet = 0; /* True if status tag set. */
3044
+
3045
+ if( !ajax_route_bootstrap(0, 1) ){
3046
+ return;
3047
+ }else if( !g.perm.WrForum
3048
+ || (bHasAttachment && !g.perm.AttachForum) ){
3049
+ ajax_route_error_forbidden();
3050
+ return;
3051
+ }else if( !ajax_check_csrf(2) ){
3052
+ ajax_route_error_csrf();
3053
+ return;
3054
+ }
3055
+
3056
+ iPostFlags = forum_post_flags(/*must come after permissions init*/);
3057
+ bRollback = (FPOST_DRYRUN & iPostFlags);
3058
+ zFpid = P("fpid");
3059
+ zIrt = P("firt");
3060
+ zMimetype = P("mimetype");
3061
+ zContent = P("content");
3062
+ zStatus = P("status");
3063
+ db_begin_transaction();
3064
+ if( zFpid && zFpid[0] ){
3065
+ fpid = symbolic_name_to_rid(zFpid, "f");
3066
+ if( fpid<0 ){
3067
+ rc = -ajax_route_error(400, "Ambiguous forum ID.");
3068
+ goto ajax_save_end;
3069
+ }else if( 0==fpid
3070
+ || 0==(pPost = manifest_get(fpid, CFTYPE_FORUM, 0)) ){
3071
+ rc = -ajax_route_error(404, "Cannot resolve forum post ID.");
3072
+ goto ajax_save_end;
3073
+ }
3074
+ }
3075
+ /*
3076
+ ** Problem: if we derive firt from fpid/pPost then there's a race
3077
+ ** condition where the IRT post is edited between the time that this
3078
+ ** edit was initiated and when it is posted: the new edit's IRT will
3079
+ ** point to the edit which was made in the meantime, not the one the
3080
+ ** user intended to respond to. However, if we accept firt from the
3081
+ ** enviornment, we "really should" validate that it's actually in
3082
+ ** the current chain, to prohibit that malicious posts could move
3083
+ ** posts around.
3084
+ **
3085
+ ** forum_post_ajax() will, if fpid>0 && !firt, select fpid's current
3086
+ ** firt.
3087
+ */
3088
+ if( zIrt && zIrt[0] ){
3089
+ firt = symbolic_name_to_rid(zIrt, "f");
3090
+ if( firt<0 ){
3091
+ rc = -ajax_route_error(400, "Ambiguous in-reply-do ID.");
3092
+ goto ajax_save_end;
3093
+ }else if( 0==firt ){
3094
+ rc = -ajax_route_error(404, "Cannot resolve in-reply-do ID.");
3095
+ goto ajax_save_end;
3096
+ }
3097
+ }
3098
+
3099
+ if( 0 ){
3100
+ rc = -ajax_route_error(400, "Save is TODO. "
3101
+ "iPostFlags=%d debug=%d",
3102
+ iPostFlags, g.perm.Debug);
3103
+ goto ajax_save_end;
3104
+ }
3105
+
3106
+ zTitle = firt ? 0 : P("title");
3107
+ nrid = forum_post_ajax(zTitle, firt, fpid, 0, zMimetype,
3108
+ zContent, iPostFlags);
3109
+ if( nrid<0 ){
3110
+ rc = nrid;
3111
+ goto ajax_save_end;
3112
+ }else if( nrid==0 ){
3113
+ if( 0==(FPOST_DRYRUN & iPostFlags) ){
3114
+ bRollback = 1;
3115
+ CX("{\"message\": \"No saving needed.\"}\n");
3116
+ }else{
3117
+ CX("{\"message\": \"Rolled back for dry-run.\","
3118
+ "\"iPostFlags\":%d}\n", iPostFlags);
3119
+ }
3120
+ goto ajax_save_end;
3121
+ }else{
3122
+ const int bNeedsModeration = forum_need_moderation();
3123
+ const int fpHead = forumpost_head_rid(nrid);
3124
+ assert( nrid>0 );
3125
+ assert( fpHead>0 );
3126
+ zNewUuid = rid_to_uuid(nrid);
3127
+ if( 0!=P("file1") ){
3128
+ /* Attachments */
3129
+ if( !g.perm.Admin && !g.perm.AttachForum ){
3130
+ rc = -ajax_route_error(403, "No permission no attach files.");
3131
+ goto ajax_save_end;
3132
+ }else{
3133
+ char *zRoot = (nrid==fpHead) ? 0 : rid_to_uuid(fpHead);
3134
+ nAttach = attachments_ajax_from_POST(zRoot ? zRoot : zNewUuid,
3135
+ bNeedsModeration);
3136
+ fossil_free(zRoot);
3137
+ if( nAttach<0 ){
3138
+ rc = nAttach;
3139
+ goto ajax_save_end;
3140
+ }
3141
+ if( nAttach>0
3142
+ && (iPostFlags & FPOST_NO_ALERT)!=0
3143
+ && db_table_exists("repository","pending_alert") ){
3144
+ /* Unqueue any alerts for these attachments. Recall that
3145
+ ** they're attached to the first version of the post, which
3146
+ ** means we actually risk cancelling _other_ pending
3147
+ ** notifications for attachments on this same post. C'est la
3148
+ ** vie.*/
3149
+ db_multi_exec(
3150
+ "WITH x(id) AS (\n"
3151
+ " SELECT 'f%d'\n"
3152
+ " UNION ALL\n"
3153
+ " SELECT 'f'||a.attachid FROM blob b, attachment a\n"
3154
+ " WHERE b.rid=%d\n"
3155
+ " AND b.uuid=a.target\n"
3156
+ ") DELETE FROM pending_alert WHERE eventid IN x",
3157
+ fpHead, fpHead
3158
+ );
3159
+ }
3160
+ }
3161
+ }
3162
+ if( 0==bNeedsModeration
3163
+ /* ^^^ Do not allow a status tag on a pending-moderation post
3164
+ ** because it will introduce a reference to an artifact which
3165
+ ** will become a phantom if it is rejected by a moderator. */
3166
+ && zStatus!=0 && zStatus[0]!=0
3167
+ && forum_may_set_status(nrid)
3168
+ && (bStatusSet=forumpost_tag(nrid, 1, "status", zStatus))<0 ){
3169
+ rc = -ajax_route_error(500, "Tagging failed: %s", g.zErrMsg);
3170
+ goto ajax_save_end;
3171
+ }
3172
+ }
3173
+
3174
+ assert( 0==rc );
3175
+ assert( zNewUuid );
3176
+ CX("{\"uuid\": %!j, \"attachedCount\": %d, "
3177
+ "\"statusModified\": %d, "
3178
+ "\"dryrun\": %s, \"iPostFlags\":%d}\n",
3179
+ zNewUuid, nAttach,
3180
+ bStatusSet, bRollback ? "true" : "false", iPostFlags);
3181
+
3182
+ajax_save_end:
3183
+ manifest_destroy(pPost);
3184
+ fossil_free(zNewUuid);
3185
+ db_end_transaction(rc || bRollback);
3186
+}
27283187
27293188
ADDED src/fossil.attach.js
--- src/forum.c
+++ src/forum.c
@@ -96,11 +96,11 @@
96
97 /*
98 ** Returns a high-level representation of the forum-statuses setting.
99 ** This is a singleton, cached across calls.
100 */
101 static const ForumStatusList * forum_statuses(void){
102 static ForumStatusList fses = {0,0};
103 static int once = 0;
104 while( !once ){
105 ++once;
106 /* Read `forum-statuses` setting and transform it into the
@@ -151,11 +151,13 @@
151 ** found, the corresponding object is returned. If no match is found
152 ** then (A) if bFirst is false then 0 is returned, else (B) the first
153 ** entry in the list is returned, noting that the list may be empty,
154 ** in which case 0 is returned.
155 */
156 const ForumStatus * forum_status_by_value(const char *z, int bFirst){
 
 
157 const ForumStatusList * const fses = forum_statuses();
158 const ForumStatus * fs0 = 0;
159 unsigned int i;
160 if( !fses->n ) return 0;
161 for( i = 0; i < fses->n; ++i ){
@@ -227,18 +229,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
@@ -408,18 +410,17 @@
408 ** no tag is added. Similarly, it will only remove a tag from a post
409 ** which has its own tag, and will not remove an inherited one from a
410 ** parent post.
411 **
412 ** If addTag is true and frid is already tagged, this is a
413 ** no-op. Likewise, if addTag is false and frid is not tagged
414 ** (not accounting for an inherited closed tag), this is a no-op.
415 **
416 ** If bCheckIrt is true then the forum post IRT hierarchy is searched
417 ** for the tag, otherwise only the given RID is checked.
418 **
419 ** Returns true if it actually creates a new tag, else false. Fails
420 ** fatally on error.
421 **
422 ** If it returns true then state from previously-loaded posts may be
423 ** invalidated if they refer to the amended post or a response to it.
424 ** e.g. if zTagName is "closed" then ForumPost::iClosed values may be
425 ** stale.
@@ -438,11 +439,12 @@
438 **
439 ** - The applied tag is propagating so so that "closed" tags can
440 ** account for how edits of posts are handled. This differs from
441 ** closure of a branch, where a non-propagating tag is used.
442 */
443 static int forumpost_tag(int frid, const char *zTagName, int addTag,
 
444 const char *zValue){
445 Blob artifact = BLOB_INITIALIZER; /* Output artifact */
446 Blob cksum = BLOB_INITIALIZER; /* Z-card */
447 int iTagged; /* true if frid is already tagged */
448 int trid; /* RID of new control artifact */
@@ -461,11 +463,11 @@
461 zValue = 0;
462 }
463 if( addTag && iTagged ){
464 char *zOld = 0;
465 int cmp;
466 rid_has_tag2(iTagged, zTagName, &zOld);
467 cmp = fossil_strcmp(zOld, zValue);
468 fossil_free(zOld);
469 if( 0==cmp ){
470 /* Same value - leave it as is. */
471 db_end_transaction(0);
@@ -481,22 +483,58 @@
481 md5sum_blob(&artifact, &cksum);
482 blob_appendf(&artifact, "Z %b\n", &cksum);
483 blob_reset(&cksum);
484 trid = content_put_ex(&artifact, 0, 0, 0, 0);
485 if( trid==0 ){
486 fossil_fatal("Error saving tag artifact: %s", g.zErrMsg);
487 }
488 if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
489 fossil_fatal("%s", g.zErrMsg);
490 }
491 assert( blob_is_reset(&artifact) );
492 db_add_unsent(trid);
493 admin_log("Tag forum post %S with %c%s",
494 zUuid, addTag ? '*' : '-', zTagName);
495 fossil_free(zUuid);
496 db_end_transaction(0);
497 return 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498 }
499
500 /*
501 ** Returns true if the forum-close-policy setting is true, else false,
502 ** caching the result for subsequent calls.
@@ -894,11 +932,19 @@
894 break;
895 }
896 }
897 if( !sCurrent ) sCurrent = &fss->aStatus[0];
898 assert( sCurrent );
899 @ <span class='forum-status-selection'>
 
 
 
 
 
 
 
 
900 if( forum_may_set_status(fp->fpid) ){
901 @ <form method="post" action='%R/forumpost_status'>
902 login_insert_csrf_secret();
903 @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
904 @ <select name='status' data-fpid='%s(fp->zUuid)'\
@@ -917,11 +963,11 @@
917 @ </form>
918 /* Form is activated in fossil.page.forumpost.js */
919 }else{
920 @ <button disabled>Status: %h(sCurrent->zLabel)</button>
921 }
922 @ </span>
923 fossil_free(zCurrent);
924 }
925 }
926
927 /*
@@ -1045,17 +1091,26 @@
1045 /*
1046 ** Renders the attachment list for the given forum post.
1047 ** Emits no output if there are no attachments.
1048 */
1049 static void forum_render_attachment_list(const char *zUuid){
1050 char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%s'>"
1051 "Attachments:</a>", zUuid);
1052 attachment_list(zUuid, zLbl,
1053 ATTACHLIST_HRULE_ABOVE
1054 | ATTACHLIST_SIZE
1055 | ATTACHLIST_HIDE_UNAPPROVED);
1056 fossil_free(zLbl);
 
 
 
 
 
 
 
 
 
1057 }
1058
1059 /*
1060 ** Renders the attachment list for p or (if not NULL) pEditHead.
1061 */
@@ -1098,24 +1153,33 @@
1098
1099 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
1100 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
1101 if( !pManifest ) return;
1102 iClosed = forumpost_is_closed(pThread, p, 1);
 
 
 
1103 /* When not in raw mode, create the border around the post. */
1104 if( !bRaw ){
1105 /* Open the <div> enclosing the post. Set the class string to mark the post
1106 ** as selected and/or obsolete. */
1107 iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1;
1108 @ <div id='forum%d(p->fpid)' class='forumTime\
1109 @ %s(bSelect ? " forumSel" : "")\
1110 @ %s(iClosed ? " forumClosed" : "")\
1111 @ %s(p->pEditTail ? " forumObs" : "")' \
1112 if( iIndent && iIndentScale ){
1113 @ style='margin-left:%d(iIndent*iIndentScale)ex;'>
1114 }else{
1115 @ >
 
 
 
 
 
1116 }
 
1117
1118 /* If this is the first post (or an edit thereof), emit the thread title. */
1119 if( pManifest->zThreadTitle ){
1120 @ <h1>%h(pManifest->zThreadTitle)</h1>
1121 }
@@ -1196,32 +1260,33 @@
1196 /* Provide a link to the raw source code. */
1197 if( !bUnf ){
1198 @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
1199 }
1200 @ </h3>
 
 
 
 
1201 }/*!bRaw*/
1202
1203 /* Check if this post is approved, also if it's by the current user. */
1204 bPrivate = content_is_private(p->fpid);
1205 bSameUser = login_is_individual()
1206 && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
1207
1208 /* Render the post if the user is able to see it. */
1209 if( bPrivate && !g.perm.ModForum && !bSameUser ){
1210 @ <p><span class="modpending">Awaiting Moderator Approval</span></p>
1211 }else{
1212 if( bRaw || bUnf || p->pEditTail ){
1213 zMimetype = "text/plain";
1214 }else{
1215 zMimetype = pManifest->zMimetype;
1216 }
1217 forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw);
1218 forum_render_attachment_list2(p);
1219 }
1220
1221 /* When not in raw mode, finish creating the border around the post. */
1222 if( !bRaw ){
 
 
1223 /* If the user is able to write to the forum and if this post has not been
1224 ** edited, create a form with various interaction buttons. */
1225 if( g.perm.WrForum && !p->pEditTail ){
1226 @ <div class="forumpost-single-controls">\
1227 @ <form action="%R/forumedit" method="POST">
@@ -1250,10 +1315,11 @@
1250 @ <br><label><input type="checkbox" name="trust">
1251 @ Trust user "%h(pManifest->zUser)" so that future posts by \
1252 @ "%h(pManifest->zUser)" do not require moderation.
1253 @ </label>
1254 @ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)">
 
1255 }
1256 }else if( bSameUser ){
1257 /* Allow users to delete (reject) their own pending posts. */
1258 @ <input type="submit" name="reject" value="Delete">
1259 }
@@ -1273,23 +1339,19 @@
1273 @ %s(iClosed ? "action-reopen" : "action-close")'/>
1274 /* ^^^ activated by fossil.page.forumpost.js */
1275 }
1276 @ </form>
1277 }
1278 if( g.perm.Admin ||
1279 (login_is_individual()
1280 && forumpost_is_owner(p/*not pHead*/->fpid, 0)) ){
1281 /* When an admin edits someone else's post, the admin
1282 ** effectively takes over ownership of it (and we currently
1283 ** have no way of passing it back). Because of this, we
1284 ** check the ownership of `p` instead of `pHead`. */
1285 @ <form method="post" action="%R/attachadd">\
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 }
1293 @ </div>
1294 }
1295 if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
@@ -1475,11 +1537,12 @@
1475 ** to all forum-related pages. It does not include page-specific
1476 ** code (e.g. "forum.js").
1477 */
1478 static void forum_emit_js(void){
1479 builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1480 NULL);
 
1481 builtin_request_js("fossil.page.forumpost.js");
1482 }
1483
1484 /*
1485 ** WEBPAGE: forumpost
@@ -1635,21 +1698,13 @@
1635 if( g.perm.WrTForum ) return 0;
1636 if( g.perm.ModForum ) return 0;
1637 return 1;
1638 }
1639
1640 /*
1641 ** Return true if the string is white-space only.
1642 */
1643 static int whitespace_only(const char *z){
1644 if( z==0 ) return 1;
1645 while( z[0] && fossil_isspace(z[0]) ){ z++; }
1646 return z[0]==0;
1647 }
1648
1649 /* Flags for use with forum_post() */
1650 #define FPOST_NO_ALERT 1 /* do not send any alerts */
 
1651
1652 /*
1653 ** Return a flags value for use with the final argument to
1654 ** forum_post(), extracted from the CGI environment.
1655 */
@@ -1656,10 +1711,13 @@
1656 static int forum_post_flags(void){
1657 int iPostFlags = 0;
1658 if( g.perm.Debug && P("fpsilent")!=0 ){
1659 iPostFlags |= FPOST_NO_ALERT;
1660 }
 
 
 
1661 return iPostFlags;
1662 }
1663
1664 /*
1665 ** Add a new Forum Post artifact to the repository.
@@ -1687,11 +1745,11 @@
1687 if( !g.perm.Admin && (iEdit || iInReplyTo)
1688 && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
1689 forumpost_error_closed();
1690 return 0;
1691 }
1692 if( iEdit==0 && whitespace_only(zContent) ){
1693 return 0;
1694 }
1695 if( iInReplyTo==0 && iEdit>0 ){
1696 iBasis = iEdit;
1697 iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
@@ -1712,17 +1770,19 @@
1712 fossil_free(zG);
1713 }
1714 if( zTitle ){
1715 blob_appendf(&x, "H %F\n", zTitle);
1716 }
1717 zI = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iInReplyTo);
1718 if( zI ){
1719 blob_appendf(&x, "I %s\n", zI);
1720 fossil_free(zI);
1721 }
1722 if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1723 blob_appendf(&x, "N %s\n", zMimetype);
 
 
1724 }
1725 if( iEdit>0 ){
1726 char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
1727 if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
1728 blob_appendf(&x, "P %s\n", zP);
@@ -1750,11 +1810,11 @@
1750 webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
1751 }
1752 webpage_assert( pPost->type==CFTYPE_FORUM );
1753 manifest_destroy(pPost);
1754
1755 if( P("dryrun") ){
1756 @ <div class='debug'>
1757 @ This is the artifact that would have been generated:
1758 @ <pre>%h(blob_str(&x))</pre>
1759 @ </div>
1760 blob_reset(&x);
@@ -1813,12 +1873,17 @@
1813 int addTag, int validFpid){
1814 if( !cgi_csrf_safe(2) ){
1815 webpage_error("CSRF validation failed");
1816 }else{
1817 const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1818 forumpost_tag(fpid, zTag, addTag, zVal);
1819 cgi_redirectf("%R/forumpost/%S",P("fpid"));
 
 
 
 
 
1820 }
1821 }
1822
1823 /*
1824 ** WEBPAGE: forumpost_close hidden
@@ -1965,11 +2030,11 @@
1965 @ it.</div>
1966 }
1967 }
1968
1969 /*
1970 ** WEBPAGE: forume1
1971 **
1972 ** Start a new forum thread.
1973 */
1974 void forumnew_page(void){
1975 const char *zTitle = PDT("title","");
@@ -1983,36 +2048,45 @@
1983 }
1984 if( P("submit") && cgi_csrf_safe(2) ){
1985 if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent,
1986 forum_post_flags()) ) return;
1987 }
1988 if( P("preview") && !whitespace_only(zContent) ){
1989 @ <h1>Preview:</h1>
1990 forum_render(zTitle, zMimetype, zContent, "forumEdit", 1);
1991 }
1992 style_set_current_feature("forum");
1993 style_header("New Forum Thread");
1994 @ <form action="%R/forume1" method="POST">
 
 
1995 @ <h1>New Thread:</h1>
1996 forum_from_line();
1997 forum_post_widget(zTitle, zMimetype, zContent);
1998 @ <input type="submit" name="preview" value="Preview">
1999 if( P("preview") && !whitespace_only(zContent) ){
2000 @ <input type="submit" name="submit" value="Submit">
2001 }else{
2002 @ <input type="submit" name="submit" value="Submit" disabled>
2003 }
2004 forum_render_debug_options();
2005 login_insert_csrf_secret();
2006 @ </form>
 
 
 
 
 
 
 
2007 forum_render_attachment_notice();
2008 forum_emit_js();
2009 style_finish_page();
2010 }
2011
2012 /*
2013 ** WEBPAGE: forume2
2014 **
2015 ** Edit an existing forum message.
2016 ** Query parameters:
2017 **
2018 ** fpid=X Hash of the post to be edited. REQUIRED
@@ -2097,11 +2171,11 @@
2097 style_set_current_feature("forum");
2098 isDelete = P("nullout")!=0;
2099 if( P("submit")
2100 && isCsrfSafe
2101 && (zContent = PDT("content",""))!=0
2102 && (!whitespace_only(zContent) || isDelete)
2103 ){
2104 int done = 1;
2105 const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE);
2106 if( bReply ){
2107 done = forum_post(0, fpid, 0, 0, zMimetype, zContent,
@@ -2175,11 +2249,11 @@
2175 zDisplayName = display_name_from_login(pPost->zUser);
2176 @ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3>
2177 fossil_free(zDisplayName);
2178 fossil_free(zDate);
2179 forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1);
2180 if( bPreview && !whitespace_only(zContent) ){
2181 @ <h2>Preview:</h2>
2182 forum_render(0, zMimetype,zContent, "forumEdit", 1);
2183 }
2184 @ <h2>Enter Reply:</h2>
2185 @ <form action="%R/forume2" method="POST">
@@ -2190,11 +2264,11 @@
2190 }
2191 if( !isDelete ){
2192 @ <input type="submit" name="preview" value="Preview">
2193 }
2194 @ <input type="submit" name="cancel" value="Cancel">
2195 if( (bPreview && !whitespace_only(zContent)) || isDelete ){
2196 if( !iClosed || g.perm.Admin ) {
2197 @ <input type="submit" name="submit" value="Submit">
2198 }
2199 }
2200 forum_render_debug_options();
@@ -2201,11 +2275,13 @@
2201 login_insert_csrf_secret();
2202 @ </form>
2203 if( !bReply ){
2204 forum_render_attachment_list(rid_to_uuid(fpid));
2205 }
2206 forum_render_attachment_notice();
 
 
2207 forum_emit_js();
2208 style_finish_page();
2209 }
2210
2211 /*
@@ -2214,21 +2290,21 @@
2214 ** to closed posts. If false, only administrators may do so. Note that
2215 ** this only affects the forum web UI, not post-closing tags which
2216 ** arrive via the command-line or from synchronization with a remote.
2217 ** This policy also determines whether moderators may delete forum
2218 ** attachments.
2219 */
2220 /*
2221 ** SETTING: forum-title width=20 default=Forum
2222 ** This is the name or "title" of the Forum for this repository. The
2223 ** default is just "Forum". But in some setups, admins might want to
2224 ** change it to "Developer Forum" or "User Forum" or whatever other name
2225 ** seems more appropriate for the particular usage.
2226 **
2227 ** SETTING: attachment-size-limit width=16
2228 ** The maximum number of bytes for an attachment. The default (or 0) is
2229 ** unlimited but a limit may be imposed by the web server or a proxy.
 
2230 **
2231 ** SETTING: forum-statuses width=40 block-text
2232 ** This JSON5-formatted value defines an array of objects describing
2233 ** the available statuses of forum posts. Each entry of the array must
2234 ** be an object in the form {label:"X",value:"Y"}.
@@ -2723,5 +2799,388 @@
2723 ** URL arg when the status selection list is activated. */
2724 forum_emit_js();
2725 }
2726 style_finish_page();
2727 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2728
2729 DDED src/fossil.attach.js
--- src/forum.c
+++ src/forum.c
@@ -96,11 +96,11 @@
96
97 /*
98 ** Returns a high-level representation of the forum-statuses setting.
99 ** This is a singleton, cached across calls.
100 */
101 const ForumStatusList * forum_statuses(void){
102 static ForumStatusList fses = {0,0};
103 static int once = 0;
104 while( !once ){
105 ++once;
106 /* Read `forum-statuses` setting and transform it into the
@@ -151,11 +151,13 @@
151 ** found, the corresponding object is returned. If no match is found
152 ** then (A) if bFirst is false then 0 is returned, else (B) the first
153 ** entry in the list is returned, noting that the list may be empty,
154 ** in which case 0 is returned.
155 */
156 static const ForumStatus * forum_status_by_value(
157 const char *z, int bFirst
158 ){
159 const ForumStatusList * const fses = forum_statuses();
160 const ForumStatus * fs0 = 0;
161 unsigned int i;
162 if( !fses->n ) return 0;
163 for( i = 0; i < fses->n; ++i ){
@@ -227,18 +229,18 @@
229 }
230
231 /*
232 ** Works like forumpost_head_rid() but expects zUuid to be an
233 ** unambiguous forum post name. It may be a hash prefix, so long as
234 ** it's unambiguous. Returns the rid of the head post, -1 if the name
235 ** is ambiguous, and 0 if the name cannot be resolved as a forum post.
236 */
237 int forumpost_head_rid2(const char *zUuid){
238 const int fpid = symbolic_name_to_rid(zUuid, "f");
239 return fpid>0
240 ? forumpost_head_rid(fpid)
241 : fpid;
242 }
243
244 /*
245 ** Given a forum post RID and user name, returns true if zUserName
246 ** matches the event.(euser,user) field for a formpost entry with the
@@ -408,18 +410,17 @@
410 ** no tag is added. Similarly, it will only remove a tag from a post
411 ** which has its own tag, and will not remove an inherited one from a
412 ** parent post.
413 **
414 ** If addTag is true and frid is already tagged, this is a
415 ** no-op. Likewise, if addTag is false and frid is not tagged (not
416 ** accounting for a tag inherited via an in-response-to post), this is
417 ** a no-op.
418 **
419 ** Returns a positive value (a new tag.tagid value) if it actually
420 ** creates a new tag, else 0. On error it returns a negative alue
421 ** and g.zErrMsg "should" contain details.
 
422 **
423 ** If it returns true then state from previously-loaded posts may be
424 ** invalidated if they refer to the amended post or a response to it.
425 ** e.g. if zTagName is "closed" then ForumPost::iClosed values may be
426 ** stale.
@@ -438,11 +439,12 @@
439 **
440 ** - The applied tag is propagating so so that "closed" tags can
441 ** account for how edits of posts are handled. This differs from
442 ** closure of a branch, where a non-propagating tag is used.
443 */
444 static int forumpost_tag(int frid, int addTag,
445 const char *zTagName,
446 const char *zValue){
447 Blob artifact = BLOB_INITIALIZER; /* Output artifact */
448 Blob cksum = BLOB_INITIALIZER; /* Z-card */
449 int iTagged; /* true if frid is already tagged */
450 int trid; /* RID of new control artifact */
@@ -461,11 +463,11 @@
463 zValue = 0;
464 }
465 if( addTag && iTagged ){
466 char *zOld = 0;
467 int cmp;
468 rid_has_tag2(frid, zTagName, &zOld);
469 cmp = fossil_strcmp(zOld, zValue);
470 fossil_free(zOld);
471 if( 0==cmp ){
472 /* Same value - leave it as is. */
473 db_end_transaction(0);
@@ -481,22 +483,58 @@
483 md5sum_blob(&artifact, &cksum);
484 blob_appendf(&artifact, "Z %b\n", &cksum);
485 blob_reset(&cksum);
486 trid = content_put_ex(&artifact, 0, 0, 0, 0);
487 if( trid==0 ){
488 return -1;
489 }
490 if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){
491 return -2;
492 }
493 assert( blob_is_reset(&artifact) );
494 db_add_unsent(trid);
495 admin_log("Tag forum post %S with %c%s",
496 zUuid, addTag ? '*' : '-', zTagName);
497 fossil_free(zUuid);
498 db_end_transaction(0);
499 return trid;
500 }
501
502 /*
503 ** COMMAND: test-forumpost-tag
504 **
505 ** Usage: %fossil test-forumpost-tag ?-cancel? THREADID TAGNAME TAGVAL
506 **
507 ** A tester for forumpost_tag(). It always rolls back changes.
508 */
509 void test_forumpost_tag_command(void){
510 int fpid;
511 int rc;
512 const char *zPost;
513 const char *zTag;
514 const char *zVal;
515 const int bAdd = find_option("cancel","",0)==0;
516
517 db_find_and_open_repository(0,0);
518 verify_all_options();
519 if( g.argc<5 ){
520 usage("forum-post-id tag-name value");
521 }
522 zPost = g.argv[2];
523 zTag = g.argv[3];
524 zVal = g.argv[4];
525
526 db_begin_transaction();
527 fpid = forumpost_head_rid2(zPost);
528 if( fpid<=0 ){
529 fossil_fatal("Cannot resolve post ID %s", zPost);
530 }
531 fossil_print("%s => %d => %z\n", zTag, fpid,
532 rid_to_uuid(fpid));
533 rc = forumpost_tag(fpid, bAdd, zTag, zVal);
534 fossil_print("tag fpid=%d taxgxref.tagid=%d\n", fpid, rc);
535 db_end_transaction(1);
536 }
537
538 /*
539 ** Returns true if the forum-close-policy setting is true, else false,
540 ** caching the result for subsequent calls.
@@ -894,11 +932,19 @@
932 break;
933 }
934 }
935 if( !sCurrent ) sCurrent = &fss->aStatus[0];
936 assert( sCurrent );
937 @ <fieldset class='forum-status-selection'>\
938 @ <legend>Status \
939 @ <span class='help-buttonlet initially-hidden'>\
940 @ Moderators and the post's owner may change \
941 @ the status of this thread unless it is still. \
942 @ pending moderation. See \
943 @ <a href='%R/help/forum-statuses' target='_new'>\
944 @ /help/forum-statuses</a></span>\
945 @ </legend>\
946 if( forum_may_set_status(fp->fpid) ){
947 @ <form method="post" action='%R/forumpost_status'>
948 login_insert_csrf_secret();
949 @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' />
950 @ <select name='status' data-fpid='%s(fp->zUuid)'\
@@ -917,11 +963,11 @@
963 @ </form>
964 /* Form is activated in fossil.page.forumpost.js */
965 }else{
966 @ <button disabled>Status: %h(sCurrent->zLabel)</button>
967 }
968 @ </fieldset>
969 fossil_free(zCurrent);
970 }
971 }
972
973 /*
@@ -1045,17 +1091,26 @@
1091 /*
1092 ** Renders the attachment list for the given forum post.
1093 ** Emits no output if there are no attachments.
1094 */
1095 static void forum_render_attachment_list(const char *zUuid){
1096 #if 1
1097 attachment_list(zUuid, "&#x1f4ce; Attachments", 0
1098 | ATTACHLIST_SIZE
1099 | ATTACHLIST_HIDE_UNAPPROVED
1100 | ATTACHLIST_DETAILS_CLOSED
1101 | ATTACHLIST_HIDE_EMPTY);
1102 #else
1103 char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>"
1104 "Attachments</a>:", zUuid);
1105 attachment_list(zUuid, zLbl,
1106 ATTACHLIST_HRULE_ABOVE
1107 | ATTACHLIST_SIZE
1108 | ATTACHLIST_HIDE_UNAPPROVED
1109 | ATTACHLIST_HIDE_EMPTY);
1110 fossil_free(zLbl);
1111 #endif
1112 }
1113
1114 /*
1115 ** Renders the attachment list for p or (if not NULL) pEditHead.
1116 */
@@ -1098,24 +1153,33 @@
1153
1154 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
1155 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
1156 if( !pManifest ) return;
1157 iClosed = forumpost_is_closed(pThread, p, 1);
1158 bPrivate = content_is_private(p->fpid);
1159 bSameUser = login_is_individual()
1160 && fossil_strcmp(pManifest->zUser, g.zLogin)==0;
1161 /* When not in raw mode, create the border around the post. */
1162 if( !bRaw ){
1163 /* Open the <div> enclosing the post. Set the class string to mark the post
1164 ** as selected and/or obsolete. */
1165 iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1;
1166 @ <div id='forum%d(p->fpid)' class='forumpost forumTime\
1167 @ %s(bSelect ? " forumSel" : "")\
1168 @ %s(iClosed ? " forumClosed" : "")\
1169 @ %s(p->pEditTail ? " forumObs" : "")' \
1170 if( iIndent && iIndentScale ){
1171 @ style='margin-left:%d(iIndent*iIndentScale)ex;' \
1172 }
1173 /* These data-X fields are used by the JS editor. */
1174 if( p->pIrt ){
1175 @ data-firt="%s(p->pIrt->zUuid)" \
1176 }
1177 if( p->pEditHead ){
1178 @ data-fedithead="%s(p->pEditHead->zUuid)" \
1179 }
1180 @ data-fpid="%s(p->zUuid)">\
1181
1182 /* If this is the first post (or an edit thereof), emit the thread title. */
1183 if( pManifest->zThreadTitle ){
1184 @ <h1>%h(pManifest->zThreadTitle)</h1>
1185 }
@@ -1196,32 +1260,33 @@
1260 /* Provide a link to the raw source code. */
1261 if( !bUnf ){
1262 @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a>
1263 }
1264 @ </h3>
1265
1266 if( bPrivate && (bSameUser || g.perm.Admin || g.perm.ModForum) ){
1267 moderation_pending_www(p->fpid);
1268 }
1269 }/*!bRaw*/
1270
1271 /* Check if this post is approved, also if it's by the current user.
1272 Render the post if the user is able to see it. */
 
 
 
 
1273 if( bPrivate && !g.perm.ModForum && !bSameUser ){
1274 @ <p><span class="modpending">Awaiting Moderator Approval</span></p>
1275 }else{
1276 if( bRaw || bUnf || p->pEditTail ){
1277 zMimetype = "text/plain";
1278 }else{
1279 zMimetype = pManifest->zMimetype;
1280 }
1281 forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw);
 
1282 }
1283
1284 /* When not in raw mode, finish creating the border around the post. */
1285 if( !bRaw ){
1286 int bBrBeforeAttach = 0; /* Layout kludge for Attach button */
1287 forum_render_attachment_list2(p);
1288 /* If the user is able to write to the forum and if this post has not been
1289 ** edited, create a form with various interaction buttons. */
1290 if( g.perm.WrForum && !p->pEditTail ){
1291 @ <div class="forumpost-single-controls">\
1292 @ <form action="%R/forumedit" method="POST">
@@ -1250,10 +1315,11 @@
1315 @ <br><label><input type="checkbox" name="trust">
1316 @ Trust user "%h(pManifest->zUser)" so that future posts by \
1317 @ "%h(pManifest->zUser)" do not require moderation.
1318 @ </label>
1319 @ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)">
1320 bBrBeforeAttach = 1 /* slightly unmangle the layout */;
1321 }
1322 }else if( bSameUser ){
1323 /* Allow users to delete (reject) their own pending posts. */
1324 @ <input type="submit" name="reject" value="Delete">
1325 }
@@ -1273,23 +1339,19 @@
1339 @ %s(iClosed ? "action-reopen" : "action-close")'/>
1340 /* ^^^ activated by fossil.page.forumpost.js */
1341 }
1342 @ </form>
1343 }
1344 if( attach_user_may(p/*not pHead*/->fpid, CFTYPE_FORUM) ){
 
 
1345 /* When an admin edits someone else's post, the admin
1346 ** effectively takes over ownership of it (and we currently
1347 ** have no way of passing it back). Because of this, we
1348 ** check the ownership of `p` instead of `pHead`. */
1349 if( bBrBeforeAttach ){
1350 @ <br>
1351 }
1352 attach_render_attachadd_button(pHead->zUuid);
 
 
1353 }
1354 }
1355 @ </div>
1356 }
1357 if( !p->pIrt && (flags & FDISPLAY_SELECTED)){
@@ -1475,11 +1537,12 @@
1537 ** to all forum-related pages. It does not include page-specific
1538 ** code (e.g. "forum.js").
1539 */
1540 static void forum_emit_js(void){
1541 builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer",
1542 "attach", "tabs", "storage",
1543 "popupwidget", NULL);
1544 builtin_request_js("fossil.page.forumpost.js");
1545 }
1546
1547 /*
1548 ** WEBPAGE: forumpost
@@ -1635,21 +1698,13 @@
1698 if( g.perm.WrTForum ) return 0;
1699 if( g.perm.ModForum ) return 0;
1700 return 1;
1701 }
1702
 
 
 
 
 
 
 
 
 
1703 /* Flags for use with forum_post() */
1704 #define FPOST_NO_ALERT 1 /* do not send any alerts */
1705 #define FPOST_DRYRUN 2 /* do not save the artifact */
1706
1707 /*
1708 ** Return a flags value for use with the final argument to
1709 ** forum_post(), extracted from the CGI environment.
1710 */
@@ -1656,10 +1711,13 @@
1711 static int forum_post_flags(void){
1712 int iPostFlags = 0;
1713 if( g.perm.Debug && P("fpsilent")!=0 ){
1714 iPostFlags |= FPOST_NO_ALERT;
1715 }
1716 if( P("dryrun")!=0 ){
1717 iPostFlags |= FPOST_DRYRUN;
1718 }
1719 return iPostFlags;
1720 }
1721
1722 /*
1723 ** Add a new Forum Post artifact to the repository.
@@ -1687,11 +1745,11 @@
1745 if( !g.perm.Admin && (iEdit || iInReplyTo)
1746 && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
1747 forumpost_error_closed();
1748 return 0;
1749 }
1750 if( iEdit==0 && fossil_all_whitespace(zContent) ){
1751 return 0;
1752 }
1753 if( iInReplyTo==0 && iEdit>0 ){
1754 iBasis = iEdit;
1755 iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
@@ -1712,17 +1770,19 @@
1770 fossil_free(zG);
1771 }
1772 if( zTitle ){
1773 blob_appendf(&x, "H %F\n", zTitle);
1774 }
1775 zI = rid_to_uuid(iInReplyTo);
1776 if( zI ){
1777 blob_appendf(&x, "I %s\n", zI);
1778 fossil_free(zI);
1779 }
1780 if( zMimetype!=0
1781 && zMimetype[0]!=0
1782 && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
1783 blob_appendf(&x, "N %F\n", zMimetype);
1784 }
1785 if( iEdit>0 ){
1786 char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
1787 if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
1788 blob_appendf(&x, "P %s\n", zP);
@@ -1750,11 +1810,11 @@
1810 webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
1811 }
1812 webpage_assert( pPost->type==CFTYPE_FORUM );
1813 manifest_destroy(pPost);
1814
1815 if( (iFlags & FPOST_DRYRUN)!=0 ){
1816 @ <div class='debug'>
1817 @ This is the artifact that would have been generated:
1818 @ <pre>%h(blob_str(&x))</pre>
1819 @ </div>
1820 blob_reset(&x);
@@ -1813,12 +1873,17 @@
1873 int addTag, int validFpid){
1874 if( !cgi_csrf_safe(2) ){
1875 webpage_error("CSRF validation failed");
1876 }else{
1877 const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param();
1878 if( fpid>0 ){
1879 if( forumpost_tag(fpid, addTag, zTag, zVal) < 0 ){
1880 webpage_error("Tagging artifact failed: %s", g.zErrMsg);
1881 }else{
1882 cgi_redirectf("%R/forumpost/%S",P("fpid"));
1883 }
1884 }
1885 }
1886 }
1887
1888 /*
1889 ** WEBPAGE: forumpost_close hidden
@@ -1965,11 +2030,11 @@
2030 @ it.</div>
2031 }
2032 }
2033
2034 /*
2035 ** WEBPAGE: forume1 hidden
2036 **
2037 ** Start a new forum thread.
2038 */
2039 void forumnew_page(void){
2040 const char *zTitle = PDT("title","");
@@ -1983,36 +2048,45 @@
2048 }
2049 if( P("submit") && cgi_csrf_safe(2) ){
2050 if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent,
2051 forum_post_flags()) ) return;
2052 }
2053 if( P("preview") && !fossil_all_whitespace(zContent) ){
2054 @ <h1>Preview:</h1>
2055 forum_render(zTitle, zMimetype, zContent, "forumEdit", 1);
2056 }
2057 style_set_current_feature("forum");
2058 style_header("New Forum Thread");
2059
2060 @ <form action="%R/forume1" method="POST" \
2061 @ class="remove-if-replaced">
2062 @ <h1>New Thread:</h1>
2063 forum_from_line();
2064 forum_post_widget(zTitle, zMimetype, zContent);
2065 @ <input type="submit" name="preview" value="Preview">
2066 if( P("preview") && !fossil_all_whitespace(zContent) ){
2067 @ <input type="submit" name="submit" value="Submit">
2068 }else{
2069 @ <input type="submit" name="submit" value="Submit" disabled>
2070 }
2071 forum_render_debug_options();
2072 login_insert_csrf_secret();
2073 @ </form>
2074 /* When JS is disabled the block above will work. When it's
2075 enabled, the above will be removed and JS will render the editor
2076 form in the next element. */
2077 @ <div hidden id='forumnew-placeholder'>
2078 @ <input type='hidden' name='title' value='%h(zTitle)'>
2079 login_insert_csrf_secret();
2080 @ </div>
2081 forum_render_attachment_notice();
2082 forum_emit_js();
2083 style_finish_page();
2084 }
2085
2086 /*
2087 ** WEBPAGE: forume2 hidden
2088 **
2089 ** Edit an existing forum message.
2090 ** Query parameters:
2091 **
2092 ** fpid=X Hash of the post to be edited. REQUIRED
@@ -2097,11 +2171,11 @@
2171 style_set_current_feature("forum");
2172 isDelete = P("nullout")!=0;
2173 if( P("submit")
2174 && isCsrfSafe
2175 && (zContent = PDT("content",""))!=0
2176 && (isDelete || !fossil_all_whitespace(zContent))
2177 ){
2178 int done = 1;
2179 const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE);
2180 if( bReply ){
2181 done = forum_post(0, fpid, 0, 0, zMimetype, zContent,
@@ -2175,11 +2249,11 @@
2249 zDisplayName = display_name_from_login(pPost->zUser);
2250 @ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3>
2251 fossil_free(zDisplayName);
2252 fossil_free(zDate);
2253 forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1);
2254 if( bPreview && !fossil_all_whitespace(zContent) ){
2255 @ <h2>Preview:</h2>
2256 forum_render(0, zMimetype,zContent, "forumEdit", 1);
2257 }
2258 @ <h2>Enter Reply:</h2>
2259 @ <form action="%R/forume2" method="POST">
@@ -2190,11 +2264,11 @@
2264 }
2265 if( !isDelete ){
2266 @ <input type="submit" name="preview" value="Preview">
2267 }
2268 @ <input type="submit" name="cancel" value="Cancel">
2269 if( isDelete || (bPreview && !fossil_all_whitespace(zContent)) ){
2270 if( !iClosed || g.perm.Admin ) {
2271 @ <input type="submit" name="submit" value="Submit">
2272 }
2273 }
2274 forum_render_debug_options();
@@ -2201,11 +2275,13 @@
2275 login_insert_csrf_secret();
2276 @ </form>
2277 if( !bReply ){
2278 forum_render_attachment_list(rid_to_uuid(fpid));
2279 }
2280 if( !isDelete ){
2281 forum_render_attachment_notice();
2282 }
2283 forum_emit_js();
2284 style_finish_page();
2285 }
2286
2287 /*
@@ -2214,21 +2290,21 @@
2290 ** to closed posts. If false, only administrators may do so. Note that
2291 ** this only affects the forum web UI, not post-closing tags which
2292 ** arrive via the command-line or from synchronization with a remote.
2293 ** This policy also determines whether moderators may delete forum
2294 ** attachments.
2295 **
 
2296 ** SETTING: forum-title width=20 default=Forum
2297 ** This is the name or "title" of the Forum for this repository. The
2298 ** default is just "Forum". But in some setups, admins might want to
2299 ** change it to "Developer Forum" or "User Forum" or whatever other name
2300 ** seems more appropriate for the particular usage.
2301 **
2302 ** SETTING: attachment-size-limit width=16
2303 ** The maximum number of bytes for an attachment to a wiki page,
2304 ** ticket, tech note, or forum post. The default (or 0) is unlimited
2305 ** but a limit may be imposed by the web server or a proxy.
2306 **
2307 ** SETTING: forum-statuses width=40 block-text
2308 ** This JSON5-formatted value defines an array of objects describing
2309 ** the available statuses of forum posts. Each entry of the array must
2310 ** be an object in the form {label:"X",value:"Y"}.
@@ -2723,5 +2799,388 @@
2799 ** URL arg when the status selection list is activated. */
2800 forum_emit_js();
2801 }
2802 style_finish_page();
2803 }
2804
2805 /*
2806 ** The AJAX counterpart of forum_post().
2807 **
2808 ** Returns the new artifact's RID on success, 0 if no changes were
2809 ** necessary (e.g. an empty new post or dry-run mode), and a negative
2810 ** value on error. If it returns a negative value then it will have
2811 ** populated the ajax response state with an error object.
2812 **
2813 ** zTitle must be NULL if iInReplyTo>0 and must be non-empty if
2814 ** iInReplyTo==0.
2815 **
2816 ** The caller must have started a transaction and must roll it back if
2817 ** this call returns <=0, noting that only the negative-value case is
2818 ** an error.
2819 **
2820 ** This function does some work to try to ensure that duplicate
2821 ** entries are not save (this can happen as a side effect of the forum
2822 ** post editor added in 2026-06). If the given post will not have been
2823 ** materially edited by these changes, they are not applied and the
2824 ** rid of the existing entry is used.
2825 **
2826 ** Maintenance reminders:
2827 **
2828 ** - iInReplyTo==0 && iEdit==0: new thread
2829 ** - iInReplyTo==0 && iEdit>0 : edit top post or response
2830 ** - iInReplyTo>0 && iEdit==0: new response
2831 ** - iInReplyTo>0 && iEdit>0 : edit response
2832 */
2833 static int forum_post_ajax(
2834 const char *zTitle, /* Title. NULL for replies */
2835 int iInReplyTo, /* Post replying to. 0 for new threads */
2836 int iEdit, /* Post being edited, or zero for a new post */
2837 const char *zUser, /* Username. NULL means use login name */
2838 const char *zMimetype, /* Mimetype of content. */
2839 const char *zContent, /* Content */
2840 int iFlags /* FPOST_xyz flag values */
2841 ){
2842 char *zI;
2843 char *zG;
2844 char *zP = 0;
2845 int iBasis;
2846 Blob x = BLOB_INITIALIZER,
2847 cksum = BLOB_INITIALIZER,
2848 formatCheck = BLOB_INITIALIZER,
2849 errMsg = BLOB_INITIALIZER;
2850 Manifest *pPost = 0;
2851 int nContent = zContent ? (int)strlen(zContent) : 0;
2852 int rc = 0;
2853
2854 assert( db_transaction_nesting_depth()>0 );
2855 schema_forum();
2856 if( iEdit==0 && fossil_all_whitespace(zContent) ){
2857 return 0;
2858 }
2859 if( !g.perm.Admin && (iEdit || iInReplyTo)
2860 && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){
2861 return -ajax_route_error(400, "Thread is closed.");
2862 }
2863 if( 0==iInReplyTo && fossil_all_whitespace(zTitle) ){
2864 return -ajax_route_error(400, "Empty title is not permitted.");
2865 }
2866
2867 if( zUser==0 ){
2868 if( login_is_nobody() ){
2869 zUser = "anonymous";
2870 }else{
2871 zUser = login_name();
2872 }
2873 }
2874 if( iEdit>0
2875 && !g.perm.Admin
2876 && !forumpost_is_owner(iEdit, zUser) ){
2877 return -ajax_route_error(
2878 403, "Only admins may edit other peoples' posts."
2879 );
2880 }
2881 if( iInReplyTo==0 && iEdit>0 ){
2882 iBasis = iEdit;
2883 iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d",
2884 iEdit);
2885 }else{
2886 iBasis = iInReplyTo;
2887 /* TODO (2026-06-008) If (iInReplyTo>0 && iEdit>0), validate that
2888 ** iInReplyTo is connected to iEdit properly, else we risk
2889 ** reparenting the new edit and having unrepredictable downstream
2890 ** side effects. */
2891 }
2892
2893 if( 0!=zMimetype && 0==zMimetype[0] ){
2894 zMimetype = 0;
2895 }
2896
2897 if( 0!=zTitle && 0==zTitle[0] ) zTitle = 0;
2898 webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 );
2899
2900 if( iEdit>0 ){
2901 int cmp;
2902 pPost = manifest_get(iEdit, CFTYPE_FORUM, 0);
2903 if( pPost==0 ){
2904 rc = -ajax_route_error(404, "Missing edit artifact %d", iEdit);
2905 goto post_ajax_end;
2906 }
2907 /*
2908 ** If the old content matches the new then do not save a new copy.
2909 ** It's easy to get re-posts of unedited content via the forum
2910 ** editor, especially since the one added in 2026-06, where a
2911 ** post's status and attachments may be amended from the editor
2912 ** without modifying any of the post's content. In the legacy
2913 ** editor such "out-of-band" changes weren't possible and users
2914 ** have never made a practice of re-posting unedited content.
2915 **
2916 ** We compare the following fields to the original: user, mimetype,
2917 ** content, and (for root posts only) the title.
2918 */
2919 cmp = (0==pPost->zInReplyTo)
2920 ? fossil_strcmp(pPost->zThreadTitle, zTitle)
2921 : 0;
2922 if( 0==cmp ){
2923 cmp=fossil_strcmp(pPost->zWiki, zContent);
2924 if( 0==cmp ){
2925 cmp = fossil_strcmp(pPost->zUser, zUser);
2926 }
2927 if( 0==cmp
2928 && 0!=(cmp=fossil_strcmp(pPost->zMimetype, zMimetype)) ){
2929 /* Extra mimetype checks for a common condition seen elsewhere */
2930 if( (0==zMimetype
2931 && 0==fossil_strcmp(pPost->zMimetype, "text/x-fossil-wiki"))
2932 || (0==pPost->zMimetype
2933 && 0==fossil_strcmp(zMimetype, "text/x-fossil-wiki")) ){
2934 cmp = 0;
2935 }
2936 }
2937 if( 0==cmp ){
2938 rc = iEdit;
2939 goto post_ajax_end;
2940 }
2941 }
2942 zP = rid_to_uuid(iEdit);
2943 }
2944
2945 /* Write the new artifact */
2946 blob_init(&x, 0, 0);
2947 blob_appendf(&x, "D %z\n", date_in_standard_format("now"));
2948 zG = db_text(
2949 0,
2950 "SELECT uuid FROM blob, forumpost"
2951 " WHERE blob.rid==forumpost.froot"
2952 " AND forumpost.fpid=%d",
2953 iBasis
2954 );
2955 if( zG ){
2956 blob_appendf(&x, "G %z\n", zG);
2957 }
2958 if( zTitle ){
2959 blob_appendf(&x, "H %F\n", zTitle);
2960 }
2961 if( iInReplyTo>0 ){
2962 zI = rid_to_uuid(iInReplyTo);
2963 if( 0==zI ){
2964 rc = -ajax_route_error(404, "Missing in-reply-to artifact %d",
2965 iInReplyTo);
2966 goto post_ajax_end;
2967 }
2968 blob_appendf(&x, "I %z\n", zI);
2969 }
2970 if( zMimetype!=0
2971 && fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
2972 blob_appendf(&x, "N %F\n", zMimetype);
2973 }
2974 if( zP ){
2975 blob_appendf(&x, "P %s\n", zP);
2976 }
2977
2978 blob_appendf(&x, "U %F\n", zUser);
2979 blob_appendf(&x, "W %d\n%s\n", nContent, zContent);
2980 md5sum_blob(&x, &cksum);
2981 blob_appendf(&x, "Z %b\n", &cksum);
2982 blob_reset(&cksum);
2983
2984 /* Verify that the artifact we are creating is well-formed */
2985 blob_init(&formatCheck, 0, 0);
2986 blob_init(&errMsg, 0, 0);
2987 blob_copy(&formatCheck, &x);
2988 pPost = manifest_parse(&formatCheck, 0, &errMsg);
2989 if( pPost==0 ){
2990 ajax_route_error(500, "Malformed forum post artifact: %b", &errMsg);
2991 rc = -500;
2992 goto post_ajax_end;
2993 }
2994 webpage_assert( pPost->type==CFTYPE_FORUM );
2995
2996 if( (iFlags & FPOST_DRYRUN)!=0 ){
2997 rc = 0;
2998 }else{
2999 int nrid;
3000 db_begin_transaction();
3001 nrid = wiki_put(&x, iEdit>0 ? iEdit : 0, forum_need_moderation());
3002 blob_reset(&x);
3003 if( (iFlags & FPOST_NO_ALERT)!=0 ){
3004 alert_unqueue('f', nrid);
3005 }
3006 rc = nrid;
3007 db_end_transaction(0);
3008 }
3009 post_ajax_end:
3010 manifest_destroy(pPost);
3011 fossil_free(zP);
3012 blob_reset(&x);
3013 blob_reset(&cksum);
3014 blob_reset(&formatCheck);
3015 return rc;
3016 }
3017 /*
3018 ** WEBPAGE: forumajax_save hidden
3019 **
3020 ** WIP
3021 **
3022 ** Response JSON:
3023 **
3024 ** { uuid: hash, ...tbd }
3025 */
3026 void forum_ajax_save_page(void){
3027 const char *zFpid;
3028 const char *zTitle;
3029 const char *zIrt;
3030 const char *zMimetype;
3031 const char *zContent;
3032 const char *zStatus;
3033 const int bHasAttachment = P("file1")!=0;
3034 Manifest *pPost = 0;
3035 char *zNewUuid = 0;
3036 int firt = 0; /* In-reply-to rid or 0 */
3037 int fpid = 0; /* Post rid being edited or 0 */
3038 int rc = 0; /* Result code. */
3039 int nrid = 0; /* New artifact rid. */
3040 int iPostFlags; /* forum_post_flags() (after perms check) */
3041 int bRollback; /* True = roll back. */
3042 int nAttach = 0; /* Number of attachments added */
3043 int bStatusSet = 0; /* True if status tag set. */
3044
3045 if( !ajax_route_bootstrap(0, 1) ){
3046 return;
3047 }else if( !g.perm.WrForum
3048 || (bHasAttachment && !g.perm.AttachForum) ){
3049 ajax_route_error_forbidden();
3050 return;
3051 }else if( !ajax_check_csrf(2) ){
3052 ajax_route_error_csrf();
3053 return;
3054 }
3055
3056 iPostFlags = forum_post_flags(/*must come after permissions init*/);
3057 bRollback = (FPOST_DRYRUN & iPostFlags);
3058 zFpid = P("fpid");
3059 zIrt = P("firt");
3060 zMimetype = P("mimetype");
3061 zContent = P("content");
3062 zStatus = P("status");
3063 db_begin_transaction();
3064 if( zFpid && zFpid[0] ){
3065 fpid = symbolic_name_to_rid(zFpid, "f");
3066 if( fpid<0 ){
3067 rc = -ajax_route_error(400, "Ambiguous forum ID.");
3068 goto ajax_save_end;
3069 }else if( 0==fpid
3070 || 0==(pPost = manifest_get(fpid, CFTYPE_FORUM, 0)) ){
3071 rc = -ajax_route_error(404, "Cannot resolve forum post ID.");
3072 goto ajax_save_end;
3073 }
3074 }
3075 /*
3076 ** Problem: if we derive firt from fpid/pPost then there's a race
3077 ** condition where the IRT post is edited between the time that this
3078 ** edit was initiated and when it is posted: the new edit's IRT will
3079 ** point to the edit which was made in the meantime, not the one the
3080 ** user intended to respond to. However, if we accept firt from the
3081 ** enviornment, we "really should" validate that it's actually in
3082 ** the current chain, to prohibit that malicious posts could move
3083 ** posts around.
3084 **
3085 ** forum_post_ajax() will, if fpid>0 && !firt, select fpid's current
3086 ** firt.
3087 */
3088 if( zIrt && zIrt[0] ){
3089 firt = symbolic_name_to_rid(zIrt, "f");
3090 if( firt<0 ){
3091 rc = -ajax_route_error(400, "Ambiguous in-reply-do ID.");
3092 goto ajax_save_end;
3093 }else if( 0==firt ){
3094 rc = -ajax_route_error(404, "Cannot resolve in-reply-do ID.");
3095 goto ajax_save_end;
3096 }
3097 }
3098
3099 if( 0 ){
3100 rc = -ajax_route_error(400, "Save is TODO. "
3101 "iPostFlags=%d debug=%d",
3102 iPostFlags, g.perm.Debug);
3103 goto ajax_save_end;
3104 }
3105
3106 zTitle = firt ? 0 : P("title");
3107 nrid = forum_post_ajax(zTitle, firt, fpid, 0, zMimetype,
3108 zContent, iPostFlags);
3109 if( nrid<0 ){
3110 rc = nrid;
3111 goto ajax_save_end;
3112 }else if( nrid==0 ){
3113 if( 0==(FPOST_DRYRUN & iPostFlags) ){
3114 bRollback = 1;
3115 CX("{\"message\": \"No saving needed.\"}\n");
3116 }else{
3117 CX("{\"message\": \"Rolled back for dry-run.\","
3118 "\"iPostFlags\":%d}\n", iPostFlags);
3119 }
3120 goto ajax_save_end;
3121 }else{
3122 const int bNeedsModeration = forum_need_moderation();
3123 const int fpHead = forumpost_head_rid(nrid);
3124 assert( nrid>0 );
3125 assert( fpHead>0 );
3126 zNewUuid = rid_to_uuid(nrid);
3127 if( 0!=P("file1") ){
3128 /* Attachments */
3129 if( !g.perm.Admin && !g.perm.AttachForum ){
3130 rc = -ajax_route_error(403, "No permission no attach files.");
3131 goto ajax_save_end;
3132 }else{
3133 char *zRoot = (nrid==fpHead) ? 0 : rid_to_uuid(fpHead);
3134 nAttach = attachments_ajax_from_POST(zRoot ? zRoot : zNewUuid,
3135 bNeedsModeration);
3136 fossil_free(zRoot);
3137 if( nAttach<0 ){
3138 rc = nAttach;
3139 goto ajax_save_end;
3140 }
3141 if( nAttach>0
3142 && (iPostFlags & FPOST_NO_ALERT)!=0
3143 && db_table_exists("repository","pending_alert") ){
3144 /* Unqueue any alerts for these attachments. Recall that
3145 ** they're attached to the first version of the post, which
3146 ** means we actually risk cancelling _other_ pending
3147 ** notifications for attachments on this same post. C'est la
3148 ** vie.*/
3149 db_multi_exec(
3150 "WITH x(id) AS (\n"
3151 " SELECT 'f%d'\n"
3152 " UNION ALL\n"
3153 " SELECT 'f'||a.attachid FROM blob b, attachment a\n"
3154 " WHERE b.rid=%d\n"
3155 " AND b.uuid=a.target\n"
3156 ") DELETE FROM pending_alert WHERE eventid IN x",
3157 fpHead, fpHead
3158 );
3159 }
3160 }
3161 }
3162 if( 0==bNeedsModeration
3163 /* ^^^ Do not allow a status tag on a pending-moderation post
3164 ** because it will introduce a reference to an artifact which
3165 ** will become a phantom if it is rejected by a moderator. */
3166 && zStatus!=0 && zStatus[0]!=0
3167 && forum_may_set_status(nrid)
3168 && (bStatusSet=forumpost_tag(nrid, 1, "status", zStatus))<0 ){
3169 rc = -ajax_route_error(500, "Tagging failed: %s", g.zErrMsg);
3170 goto ajax_save_end;
3171 }
3172 }
3173
3174 assert( 0==rc );
3175 assert( zNewUuid );
3176 CX("{\"uuid\": %!j, \"attachedCount\": %d, "
3177 "\"statusModified\": %d, "
3178 "\"dryrun\": %s, \"iPostFlags\":%d}\n",
3179 zNewUuid, nAttach,
3180 bStatusSet, bRollback ? "true" : "false", iPostFlags);
3181
3182 ajax_save_end:
3183 manifest_destroy(pPost);
3184 fossil_free(zNewUuid);
3185 db_end_transaction(rc || bRollback);
3186 }
3187
3188 DDED src/fossil.attach.js
--- a/src/fossil.attach.js
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
1
+"use strict";
2
+/**
3
+ Utility for interactive file attachment. Supports attachment
4
+ selection from a file dialog, from the clipboard, or drag/drop.
5
+
6
+ Requires that window.fossil has already been set up.
7
+ Depends on fossil.dom.
8
+*/
9
+(function(namespace){
10
+ "use strict";
11
+ const F = namespace, D = F.dom;
12
+
13
+ let idCounter = 0;
14
+ /**
15
+ Implements a multi-file selector widget. Intended to be plugged
16
+ in to places in Fossil's UI where attachments can be assigned to
17
+ an artifact.
18
+ */
19
+ class Attacher {
20
+ /* Options. */
21
+ #opt;
22
+ /* List of objects representing each row. */
23
+ #rows = [];
24
+ /* DOM elements */
25
+ #e = Object.create(null);
26
+ /* Proxy for various events this object fires. */
27
+ #events = new EventTarget();
28
+
29
+ /**
30
+ Options:
31
+
32
+ opt.container: Optional DOM element to append the resulting
33
+ widget to. If not set, the client can get access to the widget
34
+ element using this.body.
35
+
36
+ opt.addButtonLabel: optional label for the "add attachment"
37
+ button, defaulting to something generic.
38
+
39
+ opt.limit: optional max number of attachments to allow. This
40
+ defaults to "some sensible value".
41
+
42
+ opt.startWith[=0]: if >0 then that many file selection widgets
43
+ are automatically activated, as if the user had tapped the Add
44
+ button that many times.
45
+
46
+ opt.description[=true]: if true then show the file description
47
+ field, otherwise elide it.
48
+
49
+ opt.reverse[=false]: reverses the flow of the widget such that
50
+ the Add button stays on the top and rows are ordered
51
+ most-recently-added.
52
+
53
+ opt.controls = [array of DOM elements]. Optional DOM elements
54
+ to inject into the UI element which wraps the "Add" button.
55
+ See this.controlsElement.
56
+
57
+ opt.listener = function or object: {add: func, remove: func,
58
+ populate: func}: if these are functions they are registered as
59
+ listeners for 'entry-added', 'entry-removed', and/or
60
+ 'entry-populated' events, described below. opt.listener.all, if
61
+ set, is used as a fallback for any of 'add', 'remove', or
62
+ 'populate' which are not set. If opt.listener is a function
63
+ then it behaves as if listener={all: thatFunction}.
64
+
65
+ Events:
66
+
67
+ This class fires CustomEvents for certain changes:
68
+
69
+ 'entry-added' and 'entry-removed' trigger when an attachment
70
+ entry row is added/removed. Its event.detail is:
71
+
72
+ {attacher: this, row: object, type: 'same as event type'}.
73
+
74
+ 'entry-populated' is triggered when a visible entry gets
75
+ content attached to it, with the same detail structure as
76
+ described above.
77
+
78
+ The public structure of the row object passed to each is
79
+ currently TBD.
80
+ */
81
+ constructor(opt){
82
+ this.#opt = opt = F.nu({
83
+ addButtonLabel: false,
84
+ startWith: 0,
85
+ limit: 0,
86
+ dryRun: undefined,
87
+ description: true,
88
+ reverse: false
89
+ }, opt);
90
+ this.#e.body = D.addClass(D.div(), 'Attacher');
91
+ if( opt.reverse ) this.#e.body.classList.add('reverse');
92
+ const eBtnAdd = this.#e.btnAdd = D.addClass(
93
+ D.button(this.#opt.addButtonLabel || 'Add attachment',
94
+ ()=>this.#addRow()),
95
+ 'attach-add-button'
96
+ );
97
+ eBtnAdd.type = 'button';
98
+ opt.ownsAddButton = true;
99
+ this.#e.err = D.addClass(D.div(), 'error', 'hidden');
100
+ this.#e.body.append(this.#e.err);
101
+ this.#e.err.addEventListener('dblclick',()=>this.reportError());
102
+
103
+ const eControls = this.#e.controls =
104
+ D.addClass(D.div(), 'attach-controls');
105
+ eControls.append(eBtnAdd);
106
+ if( opt.container ){
107
+ opt.container.appendChild(this.#e.body);
108
+ }
109
+ this.#e.body.appendChild(eControls);
110
+ if( opt.listener ){
111
+ const doCb = (eventType, key)=>{
112
+ const f = (opt.listener instanceof Function)
113
+ ? opt.listener
114
+ : (opt.listener[key] || opt.listener.all);
115
+ if( f instanceof Function ){
116
+ this.addEventListener(eventType, f);
117
+ }
118
+ };
119
+ doCb('entry-added', 'add');
120
+ doCb('entry-removed', 'remove');
121
+ doCb('entry-populated', 'populate');
122
+ }
123
+ if( opt.dryRun ){
124
+ /* Add dry-run toggle for testing. */
125
+ const eLbl = D.label(false, "Dry-run?");
126
+ const eCb = D.checkbox(true);
127
+ eLbl.append(eCb);
128
+ eControls.append(eLbl);
129
+ eCb.checked = opt.dryRun = true;
130
+ eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
131
+ }
132
+ if( Array.isArray(opt.controls) ){
133
+ eControls.append(...opt.controls);
134
+ }
135
+ if( opt.startWith > 0 ){
136
+ for(let i = 0; i < opt.startWith; ++i ){
137
+ this.#addRow();
138
+ }
139
+ }else{
140
+ this.#updateControls();
141
+ }
142
+ }
143
+
144
+
145
+ get widget(){
146
+ return this.#e.body;
147
+ }
148
+
149
+ addEventListener(...args){
150
+ return this.#events.addEventListener(...args);
151
+ }
152
+
153
+ removeEventListener(...args){
154
+ return this.#events.removeEventListener(...args);
155
+ }
156
+
157
+ /** Returns true if any visible input widgets have content
158
+ selected. */
159
+ get isPopulated(){
160
+ for(let r of this.#rows){
161
+ if( r.file ) return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ get isDryRun(){
167
+ return !!this.#opt.dryRun;
168
+ }
169
+ /**
170
+ Returns the DOM element (div.attach-controls) which wraps the
171
+ "Add" button. Clients may add buttons to it.
172
+ */
173
+ get controlsElement(){
174
+ return this.#e.controls;
175
+ }
176
+
177
+ /**
178
+ Reports an error by appending each argument to the error widget
179
+ and unhiding it. If passed no arugments, it clears and hides
180
+ the error widget.
181
+ */
182
+ reportError(...msg){
183
+ const e = this.#e.err;
184
+ D.clearElement(e);
185
+ if( msg.length ){
186
+ e.classList.remove('hidden');
187
+ e.append(...msg);
188
+ }else{
189
+ e.classList.add('hidden');
190
+ }
191
+ }
192
+
193
+ #removeRow(rowObj){
194
+ const er = rowObj.e.row;
195
+ if( er.parentNode ){
196
+ this.#rows = this.#rows.filter(v=>v!==rowObj);
197
+ this.#updateControls();
198
+ er.classList.add('animate-exit');
199
+ er.addEventListener('animationend', ()=>er.remove(), {once: true});
200
+ this.#events.dispatchEvent(
201
+ new CustomEvent('entry-removed',{
202
+ detail: F.nu({
203
+ type: 'entry-removed',
204
+ row: rowObj,
205
+ attacher: this
206
+ })
207
+ })
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ Removes all attachments and clears the error state.
214
+ */
215
+ clear(){
216
+ for(const r of [...this.#rows/*clone because this updates #rows*/]){
217
+ this.#removeRow(r);
218
+ }
219
+ this.reportError();
220
+ }
221
+
222
+ /**
223
+ Hides or shows the Add button, as appropriate.
224
+ */
225
+ #updateControls(){
226
+ const b = this.#e.btnAdd;
227
+ if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
228
+ b.classList.add('hidden');
229
+ D.disable(b);
230
+ //F.toast.warning("Attachment form limit reached.");
231
+ }else{
232
+ b.classList.remove('hidden');
233
+ D.enable(b);
234
+ if( this.#opt.ownsAddButton ){
235
+ this.#e.body.append(this.#e.controls/*move to the end*/);
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ Returns the "Add" button widget, Passing control of it to the
242
+ caller so that they can place it in another location. This
243
+ object will still manage its enabled/disabled/hidden state but
244
+ will no longer move it when adding a row.
245
+ */
246
+ takeAddButton(){
247
+ if( this.#opt.ownsAddButton ){
248
+ this.#opt.ownsAddButton;
249
+ }
250
+ return this.#e.btnAdd;
251
+ }
252
+ /**
253
+ Sets rowObj.e.err up with an error message, or clears it if
254
+ passed only 1 argument.
255
+ */
256
+ #rowError(rowObj,...msg){
257
+ let e = rowObj.e.err;
258
+ if( e ){
259
+ D.clearElement(e);
260
+ }else{
261
+ if( !msg.length ) return;
262
+ e = rowObj.e.err = D.addClass(D.span(), 'error');
263
+ rowObj.e.info.append(e);
264
+ }
265
+ if( msg.length ){
266
+ e.append(...msg);
267
+ e.classList.remove('hidden');
268
+ }else{
269
+ e.classList.add('hidden');
270
+ }
271
+ }
272
+
273
+ #addRow(){
274
+ const id = ++idCounter;
275
+ const rowObj = F.nu({
276
+ id, file: null, mimeType: ''
277
+ });
278
+ const eRow = D.addClass(D.div(), 'attach-row');
279
+ const eDropzone = D.addClass(D.div(), 'attach-dropzone');
280
+ const eFile = D.addClass(
281
+ D.input('file'), 'attach-file-input', 'hidden'
282
+ );
283
+ const eInfo = D.addClass(D.span(), 'attach-row-info');
284
+ const eFilename = D.append(
285
+ D.addClass(D.span(), 'attach-filename'),
286
+ "Select/drop file or click the outer border and tap your "+
287
+ "platform's conventional <paste> keyboard shortcut."
288
+ );
289
+ const eSize = D.addClass(D.span(), 'attach-size');
290
+ eInfo.append(eFilename, eSize);
291
+ const eDesc = this.#opt.description
292
+ ? D.addClass(
293
+ D.attr(D.textarea(), 'placeholder',
294
+ 'Optional description...'),
295
+ 'attach-desc'
296
+ )
297
+ : undefined;
298
+ const eRemove = D.addClass(
299
+ D.button('X', (ev)=>{
300
+ ev.stopPropagation();
301
+ this.#removeRow(rowObj);
302
+ }),
303
+ 'attach-row-remove'
304
+ );
305
+ eRemove.setAttribute('title', 'Remove this attachment.');
306
+ eRemove.type = 'button';
307
+
308
+ D.append(eDropzone, eInfo, eFile, eRemove);
309
+ eDropzone.addEventListener('click', ()=>eFile.click());
310
+ eFile.addEventListener('change', (ev)=>{
311
+ if( ev.target.files.length ){
312
+ this.#injestBlob(rowObj, ev.target.files[0]);
313
+ }
314
+ });
315
+
316
+ eDropzone.addEventListener('dragover', (ev)=>{
317
+ ev.preventDefault();
318
+ eDropzone.classList.add('dragover');
319
+ });
320
+ eDropzone.addEventListener('dragleave', (ev)=>{
321
+ eDropzone.classList.remove('dragover');
322
+ });
323
+ const handleDrop = (ev, theRealRowObj)=>{
324
+ ev.preventDefault();
325
+ eDropzone.classList.remove('dragover');
326
+ if( ev.dataTransfer.files.length ){
327
+ const r = theRealRowObj || rowObj;
328
+ this.#injestBlob(r, ev.dataTransfer.files[0]);
329
+ }
330
+ };
331
+ /* Isn't working? eBtnAdd.addEventListener('drop', (ev)=>{
332
+ this.#addRow();
333
+ handleDrop(ev, this.#rows[this.#rows.length-1]);
334
+ });*/
335
+ eDropzone.addEventListener('drop', handleDrop);
336
+ const pasteImage = (event, item)=>{
337
+ if( item.type.indexOf('image') === 0 ) {
338
+ event.preventDefault();
339
+ const blob = item.getAsFile();
340
+ if( blob.name === 'image.png' ){
341
+ /* Workaround to attempt to avoid name collisions when pasting
342
+ multiple images. We cannot, at this level, unambiguously
343
+ distinguish a ctrl-v of bitmap data vs a ctrl-v of an image
344
+ file copied via a desktop file manager. */
345
+ rowObj.overrideName = `pasted-image-${Date.now()}.png`;
346
+ }
347
+ this.#injestBlob(rowObj, blob);
348
+ return true;
349
+ }
350
+ return false;
351
+ };
352
+ const pasteThing = (event, thing)=>{
353
+ if( pasteImage(event, thing) ) return true;
354
+ if( 'file' === thing.kind ){
355
+ event.preventDefault();
356
+ const blob = thing.getAsFile();
357
+ if( blob ){
358
+ this.#injestBlob(rowObj, blob);
359
+ return true;
360
+ }
361
+ }
362
+ return false;
363
+ };
364
+ eDesc?.addEventListener?.('paste', (e) => {
365
+ e.stopPropagation();
366
+ const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
367
+ if( !items ) return;
368
+ for( let i = 0; i < items.length; ++i ){
369
+ const item = items[i];
370
+ if( pasteThing(e, item) ){
371
+ break;
372
+ }
373
+ }
374
+ });
375
+ eRow.addEventListener('paste', (e) => {
376
+ const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
377
+ if( !items ) return;
378
+ for( let i = 0; i < items.length; ++i ){
379
+ const item = items[i];
380
+ if( item.type === 'text/plain' ){
381
+ e.preventDefault();
382
+ item.getAsString((text) => {
383
+ rowObj.overrideName = `pasted-text-${Date.now()}.txt`;
384
+ const blob = new File([text], rowObj.overrideName,
385
+ {type: 'text/plain'});
386
+ this.#injestBlob(rowObj, blob);
387
+ });
388
+ break;
389
+ }else if( pasteThing(e, item) ){
390
+ break;
391
+ }
392
+ }
393
+ });
394
+ eRow.append(eDropzone);
395
+ if( eDesc ) eRow.append(eDesc);
396
+ rowObj.e = F.nu({
397
+ dropzone: eDropzone,
398
+ info: eInfo,
399
+ filename: eFilename,
400
+ size: eSize,
401
+ desc: eDesc,
402
+ row: eRow,
403
+ remove: eRemove
404
+ });
405
+ this.#e.body.append(eRow);
406
+ eRow.classList.add('animate-entrance');
407
+ requestAnimationFrame(() => {
408
+ eRow.scrollIntoView({
409
+ behavior: 'smooth',
410
+ block: 'nearest',
411
+ inline: 'nearest'
412
+ });
413
+ });
414
+
415
+ this.#rows.push( rowObj );
416
+ this.#updateControls();
417
+ this.#events.dispatchEvent(
418
+ new CustomEvent('entry-added',{
419
+ detail: F.nu({
420
+ type: 'entry-added',
421
+ row: rowObj,
422
+ attacher: this
423
+ })
424
+ })
425
+ );
426
+ if( 0 ){
427
+ /* To allow immediate ctrl-v, we need a trick...
428
+ But don't do this because it will interfere with, e.g.,
429
+ the forum editor. */
430
+ D.attr(eRow, 'tabindex', '-1');
431
+ eRow.focus();
432
+ }
433
+ }
434
+
435
+ #rowMatchingName(name){
436
+ for(let r of this.#rows){
437
+ if( r.file?.name===name ) return r;
438
+ }
439
+ }
440
+
441
+ /**
442
+ Injects the given File object as the attached content for the
443
+ given row. If the object's name collides with another row,
444
+ rowObj is removed from this widget and the old row is instead
445
+ re-populated with the new file.
446
+
447
+ If rowObj.overrideName is set then the given file gets wrapped
448
+ with that name before attaching it, and that property is
449
+ removed from rowObj. This is intended only for communicating
450
+ auto-generated names for pasted data.
451
+ */
452
+ #injestBlob(rowObj, file){
453
+ if( !file ) return;
454
+ const old = this.#rowMatchingName(file.name);
455
+ if( rowObj.overrideName ){
456
+ if( rowObj.overrideName !== file.name ){
457
+ file = new File([file], rowObj.overrideName, {type: file.type});
458
+ }
459
+ rowObj.overrideName = undefined;
460
+ }
461
+ if( old && rowObj !== old ){
462
+ /*
463
+ Fossil attachments treat the name as a unique-per-target
464
+ key, with the newest one being the primary. If a name is
465
+ given twice, remove the new entry and reuse the older
466
+ one. There are conceivable, but also unlikely, cases where
467
+ this will have unintended side-effects, e.g. attaching both
468
+ /foo/bar and /baz/bar, but that seems like a lesser evil
469
+ than attaching the same file N times, leading to N
470
+ attachment artifacts.
471
+ */
472
+ /* recycle `old` instead to avoid UI flicker. */
473
+ this.#rowError(old);
474
+ this.#removeRow(rowObj);
475
+ rowObj = old;
476
+ }
477
+
478
+ let szLbl;
479
+ if( file.size < 500000 ){
480
+ szLbl = file.size + ' bytes';
481
+ }else if( file.size < 1000000 ){
482
+ szLbl = (file.size / 1024).toFixed(2)+' KB';
483
+ }else{
484
+ szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
485
+ }
486
+ this.#rowError(rowObj);
487
+ rowObj.file = file;
488
+ rowObj.mimeType = file.type || 'application/octet-stream';
489
+ D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content');
490
+ D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || '');
491
+ rowObj.e.dropzone.classList.add('populated');
492
+ if( rowObj.e.desc ){
493
+ rowObj.e.desc.classList.remove('hidden');
494
+ }
495
+ if( rowObj.e.thumbnail ){
496
+ rowObj.e.thumbnail.remove();
497
+ rowObj.e.thumbnail = undefined;
498
+ }
499
+ if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
500
+ /* Add a thumbnail */
501
+ const img = rowObj.e.thumbnail = D.img();
502
+ rowObj.e.dropzone.insertBefore(img, rowObj.e.remove);
503
+ img.classList.add('thumbnail');
504
+ const reader = new FileReader();
505
+ reader.onload = (e)=>img.setAttribute('src', e.target.result);
506
+ reader.readAsDataURL(file);
507
+ }
508
+ if( F.config.attachmentSizeLimit>0
509
+ && file.size>F.config.attachmentSizeLimit ){
510
+ /* Problem: tapping this link propagates its click event through
511
+ to eDropzone. Thus... */
512
+ const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit');
513
+ eLink.addEventListener('click', ev=>ev.stopPropagation());
514
+ this.#rowError(rowObj, "Too large: ", eLink,
515
+ " is ",F.config.attachmentSizeLimit," bytes");
516
+ rowObj.ok = false;
517
+ }else if( !file.size ){
518
+ this.#rowError(rowObj, "Cannot attach zero-byte files.");
519
+ rowObj.ok = false;
520
+ }else{
521
+ rowObj.ok = true;
522
+ }
523
+ this.#events.dispatchEvent(
524
+ new CustomEvent('entry-populated',{
525
+ detail: F.nu({
526
+ type: 'entry-populated',
527
+ row: rowObj,
528
+ attacher: this
529
+ })
530
+ })
531
+ );
532
+ }
533
+
534
+ /**
535
+ Returns an array of objects describing the currently-selected
536
+ attachments.
537
+ */
538
+ collectState(){
539
+ const rv = [];
540
+ for(let r of this.#rows){
541
+ if( !r.e.dropzone?.classList?.contains?.('populated') ){
542
+ continue;
543
+ }
544
+ rv.push(F.nu({
545
+ name: r.name || r.file.name,
546
+ content: r.file,
547
+ description: r.e.desc?.value ?? '',
548
+ mimeType: r.mimeType
549
+ }));
550
+ }
551
+ return rv;
552
+ }
553
+
554
+ /**
555
+ Populates the given FormData object with entries named
556
+ ${namePrefix}${N}, each representing a selected file and N
557
+ being a 1-based incremental counter. For entries which have a
558
+ description, it also sets ${namePrefix}${N}_desc.
559
+ */
560
+ populateFormData(fd, namePrefix='file'){
561
+ const st = this.collectState();
562
+ let i = 0;
563
+ for( ; i < st.length; ++i){
564
+ const s = st[i];
565
+ const suffix = i+1;
566
+ fd.append(`${namePrefix}${suffix}`, s.content, s.name);
567
+ const d = s.description?.trim?.();
568
+ if( d ){
569
+ fd.append(`${namePrefix}${suffix}_desc`, d);
570
+ }
571
+ }
572
+ return i;
573
+ }
574
+ }/*Attacher*/;
575
+ F.Attacher = Attacher;
576
+
577
+ F.onPageLoad(function(){
578
+ const eAttachWrapper = document.querySelector('#attachadd-form-wrapper');
579
+ if( eAttachWrapper ){
580
+ /* This page is /attachadd v2. eAttachWrapper holds
581
+ input[type=hidden] fields for use in attaching files and is
582
+ where we inject a file attachment widget. */
583
+ document.body.querySelectorAll('#attachadd-legacy-form').forEach(e=>e.remove());
584
+
585
+ eAttachWrapper.classList.remove('hidden');
586
+ const urlArgs = new URLSearchParams(window.location.search);
587
+ let zTarget = urlArgs.get('target');
588
+ let zTo = urlArgs.get('to') || urlArgs.get('from');
589
+ const eBtnSubmit = D.button("Submit");
590
+ eBtnSubmit.type = 'button';
591
+ const updateBtnSubmit = (attacher)=>{
592
+ if( attacher.isPopulated ){
593
+ eBtnSubmit.removeAttribute('disabled');
594
+ }else{
595
+ eBtnSubmit.setAttribute('disabled', '');
596
+ }
597
+ };
598
+ const cbAttacherChange = (ev)=>{
599
+ const a = ev.detail.attacher;
600
+ updateBtnSubmit(a);
601
+ };
602
+ const att = new Attacher({
603
+ container: eAttachWrapper,
604
+ startWith: 1,
605
+ listener: cbAttacherChange,
606
+ controls: [eBtnSubmit],
607
+ description: true
608
+ });
609
+ eBtnSubmit.addEventListener('click', async (ev)=>{
610
+ att.reportError();
611
+ const li = att.collectState();
612
+ if( !li.length ) return;
613
+ if( eBtnSubmit.dataset.submitted ) return;
614
+ eBtnSubmit.dataset.submitted = 1;
615
+ D.disable(eBtnSubmit);
616
+ const fd = new FormData();
617
+ att.populateFormData(fd);
618
+ for( const eIn of eAttachWrapper.querySelectorAll(
619
+ 'input[type="hidden"]'
620
+ ) ){
621
+ /* Copy over hidden input fields emitted by the server. */
622
+ if( eIn.name==='target' ){
623
+ zTarget = eIn.value;
624
+ }else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){
625
+ zTo = eIn.value;
626
+ }
627
+ fd.append(eIn.name, eIn.value)
628
+ }
629
+ if( att.isDryRun ){
630
+ fd.append('dryrun', '1');
631
+ }
632
+ let err;
633
+ const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), {
634
+ method: 'POST',
635
+ body: fd
636
+ }).catch((e)=>{
637
+ err = e;
638
+ });
639
+ D.enable(eBtnSubmit);
640
+ delete eBtnSubmit.dataset.submitted;
641
+ const jr = err ? undefined : await resp.json().catch(()=>{});
642
+ if( err || jr?.error || !resp.ok ){
643
+ const msg = err ? err.message : (jr?.error || resp.statusText);
644
+ att.reportError("Attaching failed: ", msg);
645
+ }else{
646
+ att.clear();
647
+ let to = zTo || jr?.redirect;
648
+ if( to ){
649
+ if( '/'!==to[0] ){
650
+ to = F.repoUrl(to);
651
+ }
652
+ window.location = to;
653
+ }else if( zTarget ){
654
+ window.location = '?target='+zTarget+'&'+Date.now();
655
+ }
656
+ }
657
+ })/*submit handler*/;
658
+ updateBtnSubmit(att);
659
+ F.page.attacher = att /* only for testing via dev console */;
660
+ }/* /attachadd */
661
+ })/*onPageLoad()*/;
662
+
663
+})(window.fossil);
--- a/src/fossil.attach.js
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/src/fossil.attach.js
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
1 "use strict";
2 /**
3 Utility for interactive file attachment. Supports attachment
4 selection from a file dialog, from the clipboard, or drag/drop.
5
6 Requires that window.fossil has already been set up.
7 Depends on fossil.dom.
8 */
9 (function(namespace){
10 "use strict";
11 const F = namespace, D = F.dom;
12
13 let idCounter = 0;
14 /**
15 Implements a multi-file selector widget. Intended to be plugged
16 in to places in Fossil's UI where attachments can be assigned to
17 an artifact.
18 */
19 class Attacher {
20 /* Options. */
21 #opt;
22 /* List of objects representing each row. */
23 #rows = [];
24 /* DOM elements */
25 #e = Object.create(null);
26 /* Proxy for various events this object fires. */
27 #events = new EventTarget();
28
29 /**
30 Options:
31
32 opt.container: Optional DOM element to append the resulting
33 widget to. If not set, the client can get access to the widget
34 element using this.body.
35
36 opt.addButtonLabel: optional label for the "add attachment"
37 button, defaulting to something generic.
38
39 opt.limit: optional max number of attachments to allow. This
40 defaults to "some sensible value".
41
42 opt.startWith[=0]: if >0 then that many file selection widgets
43 are automatically activated, as if the user had tapped the Add
44 button that many times.
45
46 opt.description[=true]: if true then show the file description
47 field, otherwise elide it.
48
49 opt.reverse[=false]: reverses the flow of the widget such that
50 the Add button stays on the top and rows are ordered
51 most-recently-added.
52
53 opt.controls = [array of DOM elements]. Optional DOM elements
54 to inject into the UI element which wraps the "Add" button.
55 See this.controlsElement.
56
57 opt.listener = function or object: {add: func, remove: func,
58 populate: func}: if these are functions they are registered as
59 listeners for 'entry-added', 'entry-removed', and/or
60 'entry-populated' events, described below. opt.listener.all, if
61 set, is used as a fallback for any of 'add', 'remove', or
62 'populate' which are not set. If opt.listener is a function
63 then it behaves as if listener={all: thatFunction}.
64
65 Events:
66
67 This class fires CustomEvents for certain changes:
68
69 'entry-added' and 'entry-removed' trigger when an attachment
70 entry row is added/removed. Its event.detail is:
71
72 {attacher: this, row: object, type: 'same as event type'}.
73
74 'entry-populated' is triggered when a visible entry gets
75 content attached to it, with the same detail structure as
76 described above.
77
78 The public structure of the row object passed to each is
79 currently TBD.
80 */
81 constructor(opt){
82 this.#opt = opt = F.nu({
83 addButtonLabel: false,
84 startWith: 0,
85 limit: 0,
86 dryRun: undefined,
87 description: true,
88 reverse: false
89 }, opt);
90 this.#e.body = D.addClass(D.div(), 'Attacher');
91 if( opt.reverse ) this.#e.body.classList.add('reverse');
92 const eBtnAdd = this.#e.btnAdd = D.addClass(
93 D.button(this.#opt.addButtonLabel || 'Add attachment',
94 ()=>this.#addRow()),
95 'attach-add-button'
96 );
97 eBtnAdd.type = 'button';
98 opt.ownsAddButton = true;
99 this.#e.err = D.addClass(D.div(), 'error', 'hidden');
100 this.#e.body.append(this.#e.err);
101 this.#e.err.addEventListener('dblclick',()=>this.reportError());
102
103 const eControls = this.#e.controls =
104 D.addClass(D.div(), 'attach-controls');
105 eControls.append(eBtnAdd);
106 if( opt.container ){
107 opt.container.appendChild(this.#e.body);
108 }
109 this.#e.body.appendChild(eControls);
110 if( opt.listener ){
111 const doCb = (eventType, key)=>{
112 const f = (opt.listener instanceof Function)
113 ? opt.listener
114 : (opt.listener[key] || opt.listener.all);
115 if( f instanceof Function ){
116 this.addEventListener(eventType, f);
117 }
118 };
119 doCb('entry-added', 'add');
120 doCb('entry-removed', 'remove');
121 doCb('entry-populated', 'populate');
122 }
123 if( opt.dryRun ){
124 /* Add dry-run toggle for testing. */
125 const eLbl = D.label(false, "Dry-run?");
126 const eCb = D.checkbox(true);
127 eLbl.append(eCb);
128 eControls.append(eLbl);
129 eCb.checked = opt.dryRun = true;
130 eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
131 }
132 if( Array.isArray(opt.controls) ){
133 eControls.append(...opt.controls);
134 }
135 if( opt.startWith > 0 ){
136 for(let i = 0; i < opt.startWith; ++i ){
137 this.#addRow();
138 }
139 }else{
140 this.#updateControls();
141 }
142 }
143
144
145 get widget(){
146 return this.#e.body;
147 }
148
149 addEventListener(...args){
150 return this.#events.addEventListener(...args);
151 }
152
153 removeEventListener(...args){
154 return this.#events.removeEventListener(...args);
155 }
156
157 /** Returns true if any visible input widgets have content
158 selected. */
159 get isPopulated(){
160 for(let r of this.#rows){
161 if( r.file ) return true;
162 }
163 return false;
164 }
165
166 get isDryRun(){
167 return !!this.#opt.dryRun;
168 }
169 /**
170 Returns the DOM element (div.attach-controls) which wraps the
171 "Add" button. Clients may add buttons to it.
172 */
173 get controlsElement(){
174 return this.#e.controls;
175 }
176
177 /**
178 Reports an error by appending each argument to the error widget
179 and unhiding it. If passed no arugments, it clears and hides
180 the error widget.
181 */
182 reportError(...msg){
183 const e = this.#e.err;
184 D.clearElement(e);
185 if( msg.length ){
186 e.classList.remove('hidden');
187 e.append(...msg);
188 }else{
189 e.classList.add('hidden');
190 }
191 }
192
193 #removeRow(rowObj){
194 const er = rowObj.e.row;
195 if( er.parentNode ){
196 this.#rows = this.#rows.filter(v=>v!==rowObj);
197 this.#updateControls();
198 er.classList.add('animate-exit');
199 er.addEventListener('animationend', ()=>er.remove(), {once: true});
200 this.#events.dispatchEvent(
201 new CustomEvent('entry-removed',{
202 detail: F.nu({
203 type: 'entry-removed',
204 row: rowObj,
205 attacher: this
206 })
207 })
208 );
209 }
210 }
211
212 /**
213 Removes all attachments and clears the error state.
214 */
215 clear(){
216 for(const r of [...this.#rows/*clone because this updates #rows*/]){
217 this.#removeRow(r);
218 }
219 this.reportError();
220 }
221
222 /**
223 Hides or shows the Add button, as appropriate.
224 */
225 #updateControls(){
226 const b = this.#e.btnAdd;
227 if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
228 b.classList.add('hidden');
229 D.disable(b);
230 //F.toast.warning("Attachment form limit reached.");
231 }else{
232 b.classList.remove('hidden');
233 D.enable(b);
234 if( this.#opt.ownsAddButton ){
235 this.#e.body.append(this.#e.controls/*move to the end*/);
236 }
237 }
238 }
239
240 /**
241 Returns the "Add" button widget, Passing control of it to the
242 caller so that they can place it in another location. This
243 object will still manage its enabled/disabled/hidden state but
244 will no longer move it when adding a row.
245 */
246 takeAddButton(){
247 if( this.#opt.ownsAddButton ){
248 this.#opt.ownsAddButton;
249 }
250 return this.#e.btnAdd;
251 }
252 /**
253 Sets rowObj.e.err up with an error message, or clears it if
254 passed only 1 argument.
255 */
256 #rowError(rowObj,...msg){
257 let e = rowObj.e.err;
258 if( e ){
259 D.clearElement(e);
260 }else{
261 if( !msg.length ) return;
262 e = rowObj.e.err = D.addClass(D.span(), 'error');
263 rowObj.e.info.append(e);
264 }
265 if( msg.length ){
266 e.append(...msg);
267 e.classList.remove('hidden');
268 }else{
269 e.classList.add('hidden');
270 }
271 }
272
273 #addRow(){
274 const id = ++idCounter;
275 const rowObj = F.nu({
276 id, file: null, mimeType: ''
277 });
278 const eRow = D.addClass(D.div(), 'attach-row');
279 const eDropzone = D.addClass(D.div(), 'attach-dropzone');
280 const eFile = D.addClass(
281 D.input('file'), 'attach-file-input', 'hidden'
282 );
283 const eInfo = D.addClass(D.span(), 'attach-row-info');
284 const eFilename = D.append(
285 D.addClass(D.span(), 'attach-filename'),
286 "Select/drop file or click the outer border and tap your "+
287 "platform's conventional <paste> keyboard shortcut."
288 );
289 const eSize = D.addClass(D.span(), 'attach-size');
290 eInfo.append(eFilename, eSize);
291 const eDesc = this.#opt.description
292 ? D.addClass(
293 D.attr(D.textarea(), 'placeholder',
294 'Optional description...'),
295 'attach-desc'
296 )
297 : undefined;
298 const eRemove = D.addClass(
299 D.button('X', (ev)=>{
300 ev.stopPropagation();
301 this.#removeRow(rowObj);
302 }),
303 'attach-row-remove'
304 );
305 eRemove.setAttribute('title', 'Remove this attachment.');
306 eRemove.type = 'button';
307
308 D.append(eDropzone, eInfo, eFile, eRemove);
309 eDropzone.addEventListener('click', ()=>eFile.click());
310 eFile.addEventListener('change', (ev)=>{
311 if( ev.target.files.length ){
312 this.#injestBlob(rowObj, ev.target.files[0]);
313 }
314 });
315
316 eDropzone.addEventListener('dragover', (ev)=>{
317 ev.preventDefault();
318 eDropzone.classList.add('dragover');
319 });
320 eDropzone.addEventListener('dragleave', (ev)=>{
321 eDropzone.classList.remove('dragover');
322 });
323 const handleDrop = (ev, theRealRowObj)=>{
324 ev.preventDefault();
325 eDropzone.classList.remove('dragover');
326 if( ev.dataTransfer.files.length ){
327 const r = theRealRowObj || rowObj;
328 this.#injestBlob(r, ev.dataTransfer.files[0]);
329 }
330 };
331 /* Isn't working? eBtnAdd.addEventListener('drop', (ev)=>{
332 this.#addRow();
333 handleDrop(ev, this.#rows[this.#rows.length-1]);
334 });*/
335 eDropzone.addEventListener('drop', handleDrop);
336 const pasteImage = (event, item)=>{
337 if( item.type.indexOf('image') === 0 ) {
338 event.preventDefault();
339 const blob = item.getAsFile();
340 if( blob.name === 'image.png' ){
341 /* Workaround to attempt to avoid name collisions when pasting
342 multiple images. We cannot, at this level, unambiguously
343 distinguish a ctrl-v of bitmap data vs a ctrl-v of an image
344 file copied via a desktop file manager. */
345 rowObj.overrideName = `pasted-image-${Date.now()}.png`;
346 }
347 this.#injestBlob(rowObj, blob);
348 return true;
349 }
350 return false;
351 };
352 const pasteThing = (event, thing)=>{
353 if( pasteImage(event, thing) ) return true;
354 if( 'file' === thing.kind ){
355 event.preventDefault();
356 const blob = thing.getAsFile();
357 if( blob ){
358 this.#injestBlob(rowObj, blob);
359 return true;
360 }
361 }
362 return false;
363 };
364 eDesc?.addEventListener?.('paste', (e) => {
365 e.stopPropagation();
366 const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
367 if( !items ) return;
368 for( let i = 0; i < items.length; ++i ){
369 const item = items[i];
370 if( pasteThing(e, item) ){
371 break;
372 }
373 }
374 });
375 eRow.addEventListener('paste', (e) => {
376 const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
377 if( !items ) return;
378 for( let i = 0; i < items.length; ++i ){
379 const item = items[i];
380 if( item.type === 'text/plain' ){
381 e.preventDefault();
382 item.getAsString((text) => {
383 rowObj.overrideName = `pasted-text-${Date.now()}.txt`;
384 const blob = new File([text], rowObj.overrideName,
385 {type: 'text/plain'});
386 this.#injestBlob(rowObj, blob);
387 });
388 break;
389 }else if( pasteThing(e, item) ){
390 break;
391 }
392 }
393 });
394 eRow.append(eDropzone);
395 if( eDesc ) eRow.append(eDesc);
396 rowObj.e = F.nu({
397 dropzone: eDropzone,
398 info: eInfo,
399 filename: eFilename,
400 size: eSize,
401 desc: eDesc,
402 row: eRow,
403 remove: eRemove
404 });
405 this.#e.body.append(eRow);
406 eRow.classList.add('animate-entrance');
407 requestAnimationFrame(() => {
408 eRow.scrollIntoView({
409 behavior: 'smooth',
410 block: 'nearest',
411 inline: 'nearest'
412 });
413 });
414
415 this.#rows.push( rowObj );
416 this.#updateControls();
417 this.#events.dispatchEvent(
418 new CustomEvent('entry-added',{
419 detail: F.nu({
420 type: 'entry-added',
421 row: rowObj,
422 attacher: this
423 })
424 })
425 );
426 if( 0 ){
427 /* To allow immediate ctrl-v, we need a trick...
428 But don't do this because it will interfere with, e.g.,
429 the forum editor. */
430 D.attr(eRow, 'tabindex', '-1');
431 eRow.focus();
432 }
433 }
434
435 #rowMatchingName(name){
436 for(let r of this.#rows){
437 if( r.file?.name===name ) return r;
438 }
439 }
440
441 /**
442 Injects the given File object as the attached content for the
443 given row. If the object's name collides with another row,
444 rowObj is removed from this widget and the old row is instead
445 re-populated with the new file.
446
447 If rowObj.overrideName is set then the given file gets wrapped
448 with that name before attaching it, and that property is
449 removed from rowObj. This is intended only for communicating
450 auto-generated names for pasted data.
451 */
452 #injestBlob(rowObj, file){
453 if( !file ) return;
454 const old = this.#rowMatchingName(file.name);
455 if( rowObj.overrideName ){
456 if( rowObj.overrideName !== file.name ){
457 file = new File([file], rowObj.overrideName, {type: file.type});
458 }
459 rowObj.overrideName = undefined;
460 }
461 if( old && rowObj !== old ){
462 /*
463 Fossil attachments treat the name as a unique-per-target
464 key, with the newest one being the primary. If a name is
465 given twice, remove the new entry and reuse the older
466 one. There are conceivable, but also unlikely, cases where
467 this will have unintended side-effects, e.g. attaching both
468 /foo/bar and /baz/bar, but that seems like a lesser evil
469 than attaching the same file N times, leading to N
470 attachment artifacts.
471 */
472 /* recycle `old` instead to avoid UI flicker. */
473 this.#rowError(old);
474 this.#removeRow(rowObj);
475 rowObj = old;
476 }
477
478 let szLbl;
479 if( file.size < 500000 ){
480 szLbl = file.size + ' bytes';
481 }else if( file.size < 1000000 ){
482 szLbl = (file.size / 1024).toFixed(2)+' KB';
483 }else{
484 szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
485 }
486 this.#rowError(rowObj);
487 rowObj.file = file;
488 rowObj.mimeType = file.type || 'application/octet-stream';
489 D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content');
490 D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || '');
491 rowObj.e.dropzone.classList.add('populated');
492 if( rowObj.e.desc ){
493 rowObj.e.desc.classList.remove('hidden');
494 }
495 if( rowObj.e.thumbnail ){
496 rowObj.e.thumbnail.remove();
497 rowObj.e.thumbnail = undefined;
498 }
499 if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
500 /* Add a thumbnail */
501 const img = rowObj.e.thumbnail = D.img();
502 rowObj.e.dropzone.insertBefore(img, rowObj.e.remove);
503 img.classList.add('thumbnail');
504 const reader = new FileReader();
505 reader.onload = (e)=>img.setAttribute('src', e.target.result);
506 reader.readAsDataURL(file);
507 }
508 if( F.config.attachmentSizeLimit>0
509 && file.size>F.config.attachmentSizeLimit ){
510 /* Problem: tapping this link propagates its click event through
511 to eDropzone. Thus... */
512 const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit');
513 eLink.addEventListener('click', ev=>ev.stopPropagation());
514 this.#rowError(rowObj, "Too large: ", eLink,
515 " is ",F.config.attachmentSizeLimit," bytes");
516 rowObj.ok = false;
517 }else if( !file.size ){
518 this.#rowError(rowObj, "Cannot attach zero-byte files.");
519 rowObj.ok = false;
520 }else{
521 rowObj.ok = true;
522 }
523 this.#events.dispatchEvent(
524 new CustomEvent('entry-populated',{
525 detail: F.nu({
526 type: 'entry-populated',
527 row: rowObj,
528 attacher: this
529 })
530 })
531 );
532 }
533
534 /**
535 Returns an array of objects describing the currently-selected
536 attachments.
537 */
538 collectState(){
539 const rv = [];
540 for(let r of this.#rows){
541 if( !r.e.dropzone?.classList?.contains?.('populated') ){
542 continue;
543 }
544 rv.push(F.nu({
545 name: r.name || r.file.name,
546 content: r.file,
547 description: r.e.desc?.value ?? '',
548 mimeType: r.mimeType
549 }));
550 }
551 return rv;
552 }
553
554 /**
555 Populates the given FormData object with entries named
556 ${namePrefix}${N}, each representing a selected file and N
557 being a 1-based incremental counter. For entries which have a
558 description, it also sets ${namePrefix}${N}_desc.
559 */
560 populateFormData(fd, namePrefix='file'){
561 const st = this.collectState();
562 let i = 0;
563 for( ; i < st.length; ++i){
564 const s = st[i];
565 const suffix = i+1;
566 fd.append(`${namePrefix}${suffix}`, s.content, s.name);
567 const d = s.description?.trim?.();
568 if( d ){
569 fd.append(`${namePrefix}${suffix}_desc`, d);
570 }
571 }
572 return i;
573 }
574 }/*Attacher*/;
575 F.Attacher = Attacher;
576
577 F.onPageLoad(function(){
578 const eAttachWrapper = document.querySelector('#attachadd-form-wrapper');
579 if( eAttachWrapper ){
580 /* This page is /attachadd v2. eAttachWrapper holds
581 input[type=hidden] fields for use in attaching files and is
582 where we inject a file attachment widget. */
583 document.body.querySelectorAll('#attachadd-legacy-form').forEach(e=>e.remove());
584
585 eAttachWrapper.classList.remove('hidden');
586 const urlArgs = new URLSearchParams(window.location.search);
587 let zTarget = urlArgs.get('target');
588 let zTo = urlArgs.get('to') || urlArgs.get('from');
589 const eBtnSubmit = D.button("Submit");
590 eBtnSubmit.type = 'button';
591 const updateBtnSubmit = (attacher)=>{
592 if( attacher.isPopulated ){
593 eBtnSubmit.removeAttribute('disabled');
594 }else{
595 eBtnSubmit.setAttribute('disabled', '');
596 }
597 };
598 const cbAttacherChange = (ev)=>{
599 const a = ev.detail.attacher;
600 updateBtnSubmit(a);
601 };
602 const att = new Attacher({
603 container: eAttachWrapper,
604 startWith: 1,
605 listener: cbAttacherChange,
606 controls: [eBtnSubmit],
607 description: true
608 });
609 eBtnSubmit.addEventListener('click', async (ev)=>{
610 att.reportError();
611 const li = att.collectState();
612 if( !li.length ) return;
613 if( eBtnSubmit.dataset.submitted ) return;
614 eBtnSubmit.dataset.submitted = 1;
615 D.disable(eBtnSubmit);
616 const fd = new FormData();
617 att.populateFormData(fd);
618 for( const eIn of eAttachWrapper.querySelectorAll(
619 'input[type="hidden"]'
620 ) ){
621 /* Copy over hidden input fields emitted by the server. */
622 if( eIn.name==='target' ){
623 zTarget = eIn.value;
624 }else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){
625 zTo = eIn.value;
626 }
627 fd.append(eIn.name, eIn.value)
628 }
629 if( att.isDryRun ){
630 fd.append('dryrun', '1');
631 }
632 let err;
633 const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), {
634 method: 'POST',
635 body: fd
636 }).catch((e)=>{
637 err = e;
638 });
639 D.enable(eBtnSubmit);
640 delete eBtnSubmit.dataset.submitted;
641 const jr = err ? undefined : await resp.json().catch(()=>{});
642 if( err || jr?.error || !resp.ok ){
643 const msg = err ? err.message : (jr?.error || resp.statusText);
644 att.reportError("Attaching failed: ", msg);
645 }else{
646 att.clear();
647 let to = zTo || jr?.redirect;
648 if( to ){
649 if( '/'!==to[0] ){
650 to = F.repoUrl(to);
651 }
652 window.location = to;
653 }else if( zTarget ){
654 window.location = '?target='+zTarget+'&'+Date.now();
655 }
656 }
657 })/*submit handler*/;
658 updateBtnSubmit(att);
659 F.page.attacher = att /* only for testing via dev console */;
660 }/* /attachadd */
661 })/*onPageLoad()*/;
662
663 })(window.fossil);
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -17,10 +17,14 @@
1717
initialized that object.
1818
*/
1919
2020
const F = global.fossil;
2121
22
+ /** Creates a prototype-less plain object with properties derived
23
+ from all of its object-type arguments. */
24
+ F.nu = (...obj)=>Object.assign(Object.create(null),...obj);
25
+
2226
/**
2327
Returns the current time in something approximating
2428
ISO-8601 format.
2529
*/
2630
const timestring = function f(){
@@ -54,11 +58,11 @@
5458
** removed from the object. Pass it a falsy value to clear the target
5559
** element.
5660
**
5761
** Returns this object.
5862
*/
59
- F.message = function f(msg){
63
+ F.message = function f(){
6064
const args = Array.prototype.slice.call(arguments,0);
6165
const tgt = f.targetElement;
6266
if(args.length) args.unshift(
6367
localTimeString()+':'
6468
//timestring(),'UTC:'
@@ -84,26 +88,25 @@
8488
F.message.targetElement.addEventListener(
8589
'dblclick', ()=>F.message(), false
8690
);
8791
}
8892
/*
89
- ** By default fossil.error() sends its first argument to
93
+ ** By default fossil.error() sends all arguments to
9094
** console.error(). If fossil.message.targetElement (yes,
9195
** fossil.message) is set, it adds the 'error' CSS class to
9296
** that element and sets its content as defined for message().
9397
**
9498
** Returns this object.
9599
*/
96
- F.error = function f(msg){
100
+ F.error = function f(){
97101
const args = Array.prototype.slice.call(arguments,0);
98102
const tgt = F.message.targetElement;
99103
args.unshift(timestring(),'UTC:');
100104
if(tgt){
101105
tgt.classList.add('error');
102106
tgt.innerText = args.join(' ');
103
- }
104
- else{
107
+ }else{
105108
args.unshift('Fossil error:');
106109
console.error.apply(console,args);
107110
}
108111
return this;
109112
};
110113
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -17,10 +17,14 @@
17 initialized that object.
18 */
19
20 const F = global.fossil;
21
 
 
 
 
22 /**
23 Returns the current time in something approximating
24 ISO-8601 format.
25 */
26 const timestring = function f(){
@@ -54,11 +58,11 @@
54 ** removed from the object. Pass it a falsy value to clear the target
55 ** element.
56 **
57 ** Returns this object.
58 */
59 F.message = function f(msg){
60 const args = Array.prototype.slice.call(arguments,0);
61 const tgt = f.targetElement;
62 if(args.length) args.unshift(
63 localTimeString()+':'
64 //timestring(),'UTC:'
@@ -84,26 +88,25 @@
84 F.message.targetElement.addEventListener(
85 'dblclick', ()=>F.message(), false
86 );
87 }
88 /*
89 ** By default fossil.error() sends its first argument to
90 ** console.error(). If fossil.message.targetElement (yes,
91 ** fossil.message) is set, it adds the 'error' CSS class to
92 ** that element and sets its content as defined for message().
93 **
94 ** Returns this object.
95 */
96 F.error = function f(msg){
97 const args = Array.prototype.slice.call(arguments,0);
98 const tgt = F.message.targetElement;
99 args.unshift(timestring(),'UTC:');
100 if(tgt){
101 tgt.classList.add('error');
102 tgt.innerText = args.join(' ');
103 }
104 else{
105 args.unshift('Fossil error:');
106 console.error.apply(console,args);
107 }
108 return this;
109 };
110
--- src/fossil.bootstrap.js
+++ src/fossil.bootstrap.js
@@ -17,10 +17,14 @@
17 initialized that object.
18 */
19
20 const F = global.fossil;
21
22 /** Creates a prototype-less plain object with properties derived
23 from all of its object-type arguments. */
24 F.nu = (...obj)=>Object.assign(Object.create(null),...obj);
25
26 /**
27 Returns the current time in something approximating
28 ISO-8601 format.
29 */
30 const timestring = function f(){
@@ -54,11 +58,11 @@
58 ** removed from the object. Pass it a falsy value to clear the target
59 ** element.
60 **
61 ** Returns this object.
62 */
63 F.message = function f(){
64 const args = Array.prototype.slice.call(arguments,0);
65 const tgt = f.targetElement;
66 if(args.length) args.unshift(
67 localTimeString()+':'
68 //timestring(),'UTC:'
@@ -84,26 +88,25 @@
88 F.message.targetElement.addEventListener(
89 'dblclick', ()=>F.message(), false
90 );
91 }
92 /*
93 ** By default fossil.error() sends all arguments to
94 ** console.error(). If fossil.message.targetElement (yes,
95 ** fossil.message) is set, it adds the 'error' CSS class to
96 ** that element and sets its content as defined for message().
97 **
98 ** Returns this object.
99 */
100 F.error = function f(){
101 const args = Array.prototype.slice.call(arguments,0);
102 const tgt = F.message.targetElement;
103 args.unshift(timestring(),'UTC:');
104 if(tgt){
105 tgt.classList.add('error');
106 tgt.innerText = args.join(' ');
107 }else{
 
108 args.unshift('Fossil error:');
109 console.error.apply(console,args);
110 }
111 return this;
112 };
113
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -80,10 +80,14 @@
8080
Returns a LABEL element. If passed an argument,
8181
it must be an id or an HTMLElement with an id,
8282
and that id is set as the 'for' attribute of the
8383
label. If passed 2 arguments, the 2nd is text or
8484
a DOM element to append to the label.
85
+
86
+ 2026-06: this is a goofy interface. Generally simpler that
87
+ dealing with IDs is to embed the target control within the label
88
+ element.
8589
*/
8690
dom.label = function(forElem, text){
8791
const rc = document.createElement('label');
8892
if(forElem){
8993
if(forElem instanceof HTMLElement){
9094
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -80,10 +80,14 @@
80 Returns a LABEL element. If passed an argument,
81 it must be an id or an HTMLElement with an id,
82 and that id is set as the 'for' attribute of the
83 label. If passed 2 arguments, the 2nd is text or
84 a DOM element to append to the label.
 
 
 
 
85 */
86 dom.label = function(forElem, text){
87 const rc = document.createElement('label');
88 if(forElem){
89 if(forElem instanceof HTMLElement){
90
--- src/fossil.dom.js
+++ src/fossil.dom.js
@@ -80,10 +80,14 @@
80 Returns a LABEL element. If passed an argument,
81 it must be an id or an HTMLElement with an id,
82 and that id is set as the 'for' attribute of the
83 label. If passed 2 arguments, the 2nd is text or
84 a DOM element to append to the label.
85
86 2026-06: this is a goofy interface. Generally simpler that
87 dealing with IDs is to embed the target control within the label
88 element.
89 */
90 dom.label = function(forElem, text){
91 const rc = document.createElement('label');
92 if(forElem){
93 if(forElem instanceof HTMLElement){
94
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,24 +1,852 @@
1
+/**
2
+ Code for the forum family of pages. Requires fossil.X where X is
3
+ (copybutton, pikchr, confirmer, attach, tabs, storage).
4
+*/
15
(function(F/*the fossil object*/){
26
"use strict";
37
/* JS code for /forumpost and friends. Requires fossil.dom
48
and can optionally use fossil.pikchr. */
59
const P = F.page, D = F.dom;
610
11
+ let idCounter = 0;
12
+
13
+ /*
14
+ The problem: when previewing the bottom-most post of a thread, the
15
+ preview widget's size changes cause the page to scroll
16
+ unpredictably as the bottom boundary of the page moves. A weird
17
+ workaround (not invented here) is to add dummy blank padding to
18
+ the page to allow the preview widget to grow and shrink without
19
+ (usually) scrolling, but whether it does so really depends on its
20
+ size.
21
+
22
+ We could maybe get the same effect by adding this size as
23
+ padding-bottom to document.body instead of as a new element.
24
+ */
25
+ const dummyPadding = D.div();
26
+ dummyPadding.style.height = '75em';
27
+ /* Keep track of ForumPostEditor instances so we can remove this
28
+ padding when none are active. */
29
+ dummyPadding.refs = new Set();
30
+ F.dummyPadding = dummyPadding /* only for debugging */;
31
+
32
+ /**
33
+ A forum post editor widget for new posts and responses.
34
+ */
35
+ class ForumPostEditor {
36
+ /* Options */
37
+ #opt;
38
+ /* Dom elements */
39
+ #e;
40
+ /* F.Attacher instance */
41
+ #att;
42
+ /* Is waiting on a pending remote response. */
43
+ #isWaiting = false;
44
+ /* F.TabManager */
45
+ #tabs;
46
+ /* Elements to disable while an XHR is pending. */
47
+ #toDisable = [];
48
+ /* DOM element of the current active tab. */
49
+ #activeTab;
50
+ /* Extra input[type=hidden] fields imported from fossil's
51
+ static page generation. */
52
+ #extraFields;
53
+ /* Persistent draft message object. */
54
+ #draft;
55
+
56
+ /**
57
+ Options:
58
+
59
+ opt.draftKey[string=undefined]: if set then this object's state
60
+ will be stored in fossil.storage when the relevant input fields
61
+ lose focus. If old state is found, the form is pre-populated
62
+ from it. The state is cleared on a discard() or successful
63
+ submit.
64
+
65
+ opt.ondiscard[=function]: if set, a Discard button is added
66
+ which, when activated, clears the current draft and removes
67
+ this object's widget from the DOM. After doing so,
68
+ opt.ondiscard() is called and passed this object. Exceptions
69
+ thrown by ondiscard() are ignored but may be logged.
70
+
71
+ opt.onsubmit[=function]: if set, this function is called
72
+ immediately after the post has been successfully saved, and
73
+ passed this object and a JSON-format response object from the
74
+ save request. It is generally then up to the caller to close()
75
+ this object and/or redirect to /forumpost/${arguments[1].uuid}.
76
+
77
+ opt.onclose[=function]: like opt.onsubmit, this function is
78
+ called when this.close() is called, and passed no arguments.
79
+ onclose() is called before the widget is removed from the dom
80
+ and _does not_ fire if it is not in the DOM.
81
+
82
+ opt.hiddenFields: an optional list of input elements to
83
+ incorporate into the form for requests which request the
84
+ preview or save the post.
85
+
86
+ opt.inReplyTo=uuid: if this is a response to a post, this
87
+ is the full forum post uuid of the being-replied-to post.
88
+
89
+ opt.edit=artifactObject: if this is an edit of an existing
90
+ post, this is the full JSON-format artifact of the forum post
91
+ the being-edited post, as returned by /ajax/artifact.json.
92
+
93
+ opt.status: optional current status tag value for opt.edit,
94
+ if known. This is used for pre-selecting a status value.
95
+
96
+ opt.hideStash[bool=false]: if true, the "Stash" button does not
97
+ get added. Intended for use with /forumnew.
98
+ */
99
+ constructor(opt){
100
+ opt = this.#opt = F.nu({
101
+ draftKey: undefined,
102
+ hideStash: false
103
+ }, opt);
104
+ opt.isNewThread = !opt.inReplyTo && !opt.edit;
105
+ if( opt.draftKey ){
106
+ this.#draft = F.nu(F.storage.getJSON(opt.draftKey, {}));
107
+ }
108
+ const e = this.#e = F.nu({
109
+ mimetype: F.nu(),
110
+ button: F.nu()
111
+ });
112
+ //console.debug("Setting up FPE opt =",opt);
113
+ const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
114
+ D.clearElement(wrapper);
115
+
116
+ if( !opt.inReplyTo ){
117
+ /* Title... */
118
+ e.titleBar = D.addClass(D.div(),'titlebar');
119
+ e.title = D.attr(
120
+ D.addClass(D.input('text'), 'title'),
121
+ 'placeholder',
122
+ 'Thread title (required)'
123
+ );
124
+ e.title.setAttribute('maxlength', 125);
125
+ e.titleBar.append(
126
+ D.append(D.span(), "Title:"),
127
+ e.title
128
+ );
129
+ if( this.#draft ){
130
+ e.title.addEventListener('blur', ()=>{
131
+ this.#draft.title = e.title.value;
132
+ this.#storeDraft();
133
+ });
134
+ e.title.value = this.#draft.title || opt.edit?.H || '';
135
+ }else if( opt.edit?.H ){
136
+ e.title.value = opt.edit.H;
137
+ }
138
+ wrapper.append(e.titleBar);
139
+ }
140
+
141
+ { /* Mimetype... */
142
+ e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
143
+ const sel = e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
144
+ sel.setAttribute('title', 'Markup format for this post.');
145
+ this.#toDisable.push(sel);
146
+ let i = 0;
147
+ D.option(sel, '', '- Markup format -').disabled = true;
148
+ for(const [k,v] of Object.entries({
149
+ 'text/x-markdown': 'Markdown',
150
+ 'text/x-fossil-wiki': 'Fossil Wiki',
151
+ 'text/plain': 'Plain text'
152
+ })) {
153
+ D.option(sel, k, v);
154
+ }
155
+ sel.value = opt.mimetype
156
+ || this.#draft?.mimetype
157
+ || F.storage.get('forum-mimetype', sel.options[1].value);
158
+ sel.addEventListener('change',ev=>{
159
+ if( this.#draft && this.#draft.mimetype!==ev.target.value ){
160
+ this.#draft.mimetype = ev.target.value;
161
+ this.#storeDraft();
162
+ }
163
+ F.storage.set('forum-mimetype', ev.target.value);
164
+ });
165
+ e.mimetype.wrapper.append(sel);
166
+ }
167
+
168
+ e.buttons = D.addClass(D.div(), 'buttons');
169
+ { /* Preview/submit buttons... */
170
+ e.button.preview = D.attr(
171
+ D.button("Preview", e=>this.#preview()),
172
+ 'title',
173
+ 'Preview your edits.'
174
+ );
175
+ e.button.submit = D.attr(
176
+ D.button("Submit"),
177
+ 'title',
178
+ 'Save any edits to the server. Not permitted until Preview has been used.'
179
+ );
180
+ if( this.#draft && !opt.hideStash ){
181
+ e.button.stash = D.attr(
182
+ D.button(
183
+ "Stash", e=>this.close()
184
+ /* This could be called Close, but that would semantically
185
+ collide with the Close [this post] button. All "Stash"
186
+ does is close the widget. */
187
+ ),
188
+ 'title', "Close this editor and stash any edits locally."
189
+ );
190
+ }
191
+ if( opt.ondiscard instanceof Function ){
192
+ e.button.discard = D.attr(
193
+ D.button('Discard'),
194
+ 'title',
195
+ 'Close the editor and discard all local edits.'
196
+ );
197
+ }
198
+ if( 1 ){
199
+ F.confirmer(e.button.submit, {
200
+ confirmText: "Confirm submit...",
201
+ onconfirm: ()=>this.#submit()
202
+ });
203
+ if( e.button.discard ){
204
+ F.confirmer(e.button.discard, {
205
+ confirmText: "Really discard?",
206
+ onconfirm: ()=>this.discard()
207
+ });
208
+ }
209
+ }else{
210
+ e.button.submit.addEventListener('click', ()=>this.#submit());
211
+ if( e.button.discard ){
212
+ e.button.submit.addEventListener('click', ()=>this.discard());
213
+ }
214
+ }
215
+ e.button.submit.setAttribute('disabled', '');
216
+ wrapper.append(e.buttons);
217
+
218
+ e.error = D.addClass(D.div(), 'error', 'hidden');
219
+ wrapper.append(e.error);
220
+ e.error.addEventListener('dblclick',()=>this.reportError());
221
+ }
222
+
223
+ const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
224
+ { /* Main tabs... */
225
+ e.tabs = D.attr(
226
+ D.addClass(D.div(), 'tab-container'),
227
+ 'id', idPrefix+'-tabs'
228
+ );
229
+ this.#tabs = new F.TabManager(e.tabs);
230
+ this.#tabs.addEventListener('before-switch-to', (ev)=>{
231
+ //console.debug("Switching to tab",ev.detail);
232
+ switch( (this.#activeTab = ev.detail) ){
233
+ case e.preview:
234
+ this.#e.button.preview.click();
235
+ break;
236
+ case e.help:
237
+ if( e.help.$needsInit ){
238
+ delete e.help.$needsInit;
239
+ this.#initHelpTab();
240
+ }
241
+ break;
242
+ case e.tabAttach:
243
+ if( !this.#att ) this.#initAttacherTab();
244
+ break;
245
+ }
246
+ });
247
+ wrapper.append( e.tabs );
248
+
249
+ e.tabEdit = D.div();
250
+ e.tabEdit.classList.add('editor-wrapper');
251
+ e.editor = D.attr(
252
+ D.addClass(D.textarea(), 'editor'),
253
+ 'placeholder',
254
+ 'Your message to other forum-goers...'
255
+ );
256
+ e.tabEdit.append(e.editor);
257
+ e.tabEdit.dataset.tabLabel = (opt.edit || !opt.inReplyTo)
258
+ ? 'Edit' : 'Reply';
259
+ this.#tabs.addTab( e.tabEdit );
260
+ this.#tabs.switchToTab( e.tabEdit );
261
+ if( this.#draft ){
262
+ this.editorContent = this.#draft.content || opt.edit?.W || '';
263
+ e.editor.addEventListener(
264
+ 'blur', ()=>{
265
+ this.#draft.content = this.editorContent;
266
+ this.#storeDraft();
267
+ }
268
+ );
269
+ }else if( opt.edit?.W ){
270
+ this.editorContent = opt.artifact.W;
271
+ }
272
+ e.preview = D.addClass(D.div(), 'preview');
273
+ e.preview.dataset.tabLabel = 'Preview';
274
+ this.#toDisable.push(e.button.preview);
275
+ this.#tabs.addTab( e.preview );
276
+ }
277
+
278
+ if( F.user.enableDebug ){
279
+ e.debug = D.addClass(D.div(), 'debug');
280
+ e.debug.dataset.tabLabel = 'Debug';
281
+ e.debug.setAttribute('id', idPrefix+'-debug');
282
+ for(const [k,v] of Object.entries({
283
+ dryrun: 'Dry run',
284
+ domod: 'Require moderation approval',
285
+ //showqp: 'Show query parameters',
286
+ fpsilent: 'Do not send notification emails'
287
+ })){
288
+ const lbl = D.label(false, v);
289
+ lbl.prepend(D.checkbox(k));
290
+ e.debug.append(lbl);
291
+ }
292
+ this.#tabs.addTab(e.debug);
293
+ }
294
+ e.buttons.append(e.mimetype.wrapper);
295
+
296
+ if( opt.edit
297
+ && !opt.inReplyTo
298
+ && F.config.forumStatuses?.length>0 ){
299
+ const sel = e.status = D.select();
300
+ sel.setAttribute('title', 'The status tag value for this post.');
301
+ D.option(sel, "", "- Status -").disabled = true;
302
+ for( const status of F.config.forumStatuses ){
303
+ D.option(sel, status.value, status.label);
304
+ }
305
+ e.buttons.append(sel);
306
+ if( opt.status ){
307
+ sel.value = opt.status;
308
+ }else if( this.#draft ){
309
+ if( this.#draft.status ){
310
+ sel.value = this.#draft.status;
311
+ }else{
312
+ this.#draft.status = sel.value = F.config.forumStatuses[0].value;
313
+ }
314
+ sel.addEventListener('change',ev=>{
315
+ const v = sel.value;
316
+ if( this.#draft.status !== v ){
317
+ this.#draft.status = v;
318
+ this.#storeDraft();
319
+ }
320
+ });
321
+ }
322
+ }/*e.status*/
323
+
324
+ if( F.user.mayAttachForum ){
325
+ //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
326
+ e.tabAttach = D.div();
327
+ e.tabAttach.setAttribute('id', idPrefix+'-attach');
328
+ e.tabAttach.dataset.tabLabel = 'Attachments';
329
+ this.#tabs.addTab(e.tabAttach);
330
+ /* Reminder: we don't currently have a way to disable/enable
331
+ an Attacher's controls during ajax traffic. */
332
+ }
333
+ e.buttons.append(e.button.preview, e.button.submit);
334
+ if( e.button.stash ){
335
+ e.buttons.append(e.button.stash);
336
+ this.#toDisable.push(e.button.stash);
337
+ }
338
+ if( e.button.discard ){
339
+ e.buttons.append(e.button.discard);
340
+ this.#toDisable.push(e.button.discard);
341
+ }
342
+
343
+ e.help = D.attr(D.div(), 'id', idPrefix+'-help');
344
+ e.help.$needsInit = true;
345
+ e.help.dataset.tabLabel = 'Help';
346
+ this.#tabs.addTab(e.help);
347
+
348
+ if( opt.hiddenFields ){
349
+ this.addHiddenFields( opt.hiddenFields );
350
+ delete opt.hiddenFields;
351
+ }
352
+
353
+ { /* Shift-enter pieces... */
354
+ const eCb = D.checkbox(1);
355
+ const eLbl = D.label();
356
+ const eHelp = D.append(
357
+ D.span(), [
358
+ 'When checked, shift-enter will toggle between preview ',
359
+ 'and edit modes, which is generally useful but some ',
360
+ 'software keyboards misinteract with it. If the preview ',
361
+ 'starts when tapping Enter, turn this setting off.'
362
+ ].join('')
363
+ );
364
+ eCb.checked = F.storage.getBool(
365
+ 'edit-shift-enter-preview',
366
+ true
367
+ /* Maintenance reminder: this setting is shared across
368
+ several apps, like /chat, /wikiedit, and /fileedit. */
369
+ );
370
+ eCb.addEventListener('change', (ev)=>{
371
+ F.storage.set('edit-shift-enter-preview', eCb.checked);
372
+ });
373
+ F.helpButtonlets.setup(eHelp);
374
+ eLbl.append("Shift-enter toggles preview?", eCb, eHelp);
375
+ e.tabEdit.append(eLbl);
376
+ const isShiftEnter = (ev)=>eCb.checked && ev.shiftKey && 13===ev.keyCode;
377
+ e.editor.addEventListener('keydown',(ev)=>{
378
+ /**
379
+ If eCb.checked is true, a keyboard combo of shift-enter
380
+ (from the editor) toggles between preview and edit modes.
381
+ This is normally desired but at least one software
382
+ keyboard is known to misinteract with this, treating an
383
+ Enter after automatically-capitalized letters as a
384
+ shift-enter:
385
+
386
+ https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8
387
+ */
388
+ if(!isShiftEnter(ev)) return;
389
+ ev.preventDefault();
390
+ ev.stopPropagation();
391
+ e.editor.blur(/*force draft update if needed*/);
392
+ this.#tabs.switchToTab(e.preview);
393
+ }, false);
394
+ // If we're in the preview tab, have ctrl-enter switch back to the editor.
395
+ document.body.addEventListener('keydown',(ev)=>{
396
+ if(!isShiftEnter(ev)) return;
397
+ if(this.#activeTab !== e.tabEdit){
398
+ ev.preventDefault();
399
+ ev.stopPropagation();
400
+ this.#tabs.switchToTab(e.tabEdit);
401
+ e.editor.focus(/*slow as molasses for long docs, as focus()
402
+ forces a document reflow. */);
403
+ return false;
404
+ }
405
+ }, true);
406
+ }/*shift-enter preview bits*/
407
+
408
+ if(0){ /* Needs to be optional */
409
+ const elemsToToggle = document.body.querySelectorAll(
410
+ ':scope > header, :scope > nav'
411
+ );
412
+ e.button.toggleHeader =
413
+ D.button('Toggle header', e=>{
414
+ for(const et of elemsToToggle){
415
+ et.classList.toggle('hidden');
416
+ }
417
+ });
418
+ e.buttons.append(e.button.toggleHeader);
419
+ }
420
+
421
+ {
422
+ const eLbl = D.label(false, "Posting as "+F.user.name)
423
+ eLbl.classList.add('logged-in-as');
424
+ e.buttons.append(eLbl);
425
+ }
426
+
427
+ }/*constructor*/
428
+
429
+ /*
430
+ ** Removes this object from the DOM. It has no side effects if
431
+ ** it's not in the DOM.
432
+ */
433
+ close(){
434
+ const e = this.#e.widget;
435
+ if( e?.parentNode ){
436
+ if( this.#opt.onclose instanceof Function ){
437
+ try{this.#opt.onclose();}
438
+ catch(e){
439
+ console.error("ForumPostEditor.onclose() threw:",e);
440
+ }
441
+ }
442
+ //console.debug("FPE discarding", this);
443
+ e.classList.add('animate-exit');
444
+ e.addEventListener('animationend', ()=>e.remove(), {once: true});
445
+ dummyPadding.refs.delete(this);
446
+ if( 0===dummyPadding.refs.size ){
447
+ dummyPadding.remove();
448
+ }
449
+ }
450
+ }
451
+
452
+ /*
453
+ ** Discards any draft edits then calls close(). If an ondiscard
454
+ ** callback was provided to the constructor then it is called
455
+ ** before the drafts are cleared and any exceptions it throws are
456
+ ** ignored (but may be logged).
457
+ */
458
+ discard(){
459
+ if( this.#opt.ondiscard instanceof Function ){
460
+ try{this.#opt.ondiscard(this);}
461
+ catch(e){
462
+ console.error("ForumPostEditor.ondiscard() threw:",e);
463
+ }
464
+ }
465
+ this.#clearDraft();
466
+ this.close();
467
+ }
468
+
469
+ /** This widget's top-most DOM element. */
470
+ get widget(){
471
+ if( !dummyPadding.parentElement ){
472
+ document.body.append(dummyPadding);
473
+ }
474
+ dummyPadding.refs.add(this);
475
+ return this.#e.widget;
476
+ }
477
+
478
+ get editorContent(){
479
+ /* We wrap access to the editor's contents in a getter/setter so
480
+ that we can eventually add optional use of a contenteditable
481
+ edit field, as those are generally more comfortable. The code
482
+ for that is in fossil.page.chat.js. */
483
+ return this.#e.editor.value;
484
+ }
485
+
486
+ set editorContent(v){
487
+ this.#e.editor.value = v;
488
+ }
489
+
490
+ /**
491
+ Reports an error by appending each argument to the error widget
492
+ and unhiding it. If passed no arugments, it clears and hides
493
+ the error widget.
494
+ */
495
+ reportError(...msg){
496
+ const e = this.#e.error;
497
+ D.clearElement(e);
498
+ if( msg.length ){
499
+ console.error('ForumPostEditor:',...msg);
500
+ e.classList.remove('hidden');
501
+ e.append(
502
+ ...msg, D.br(),
503
+ D.button("Clear", ()=>this.reportError())
504
+ /* Looks horrid in the Blitz skin */
505
+ );
506
+ }else{
507
+ e.classList.add('hidden');
508
+ }
509
+ }
510
+
511
+ /**
512
+ Adds a list of input[type=hidden] form fields to this object,
513
+ imported from the server-generated HTML. This is used for
514
+ collecting, e.g., the CSRF token and an initial page title.
515
+ */
516
+ addHiddenFields(list){
517
+ this.#extraFields ??= [];
518
+ for( const f of list ){
519
+ if( !f ) continue;
520
+ if( 'title'===f.name && this.#e.title ){
521
+ if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
522
+ this.#e.title.value = f.value;
523
+ }
524
+ }else{
525
+ this.#extraFields.push(f);
526
+ }
527
+ }
528
+ }
529
+
530
+ get mimetype(){
531
+ return this.#e.mimetype.select.value;
532
+ }
533
+
534
+ get title(){
535
+ return this.#e.title?.value || this.#opt.edit?.H;
536
+ }
537
+
538
+ #initHelpTab(){
539
+ const eh = this.#e.help;
540
+ const list = D.ul();
541
+ D.append(
542
+ D.li(list),
543
+ D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
544
+ 'target', '_new')
545
+ );
546
+ D.append(
547
+ D.li(list),
548
+ "WARNING: draft edits are keyed on the ID of the message they ",
549
+ "are editing or responding to. Attempting to edit or reply to ",
550
+ "the same post from multiple tabs will cause the most-recently-edited ",
551
+ "one to overwrite the draft slot for that post. In browsers which support ",
552
+ "Web Locks, a second attempt to edit or reply to a post will be blocked ",
553
+ "and an error will be shown explaining the problem."
554
+ );
555
+ if( this.#e.status ){
556
+ D.append(
557
+ D.li(list),
558
+ "Tip: changing just the status in the editor will change only that, ",
559
+ "not a whole new (but unedited) copy of the post."
560
+ );
561
+ }
562
+ eh.append(list);
563
+ }
564
+
565
+ #initAttacherTab(){
566
+ this.#att = new F.Attacher({
567
+ reverse: true
568
+ });
569
+ if( this.#opt.edit ){
570
+ const eNote = D.append(
571
+ D.div(),
572
+ "Tip: attachments can be added to posts without editing them ",
573
+ "by visiting ",
574
+ D.attr(
575
+ D.a(F.repoUrl('attachadd?target='+this.#opt.edit.uuid), '/attachadd'),
576
+ 'target',
577
+ '_new'
578
+ ),
579
+ ".",
580
+ );
581
+ this.#e.tabAttach.append(eNote);
582
+ }
583
+ this.#e.tabAttach.append(this.#att.widget);
584
+ }
585
+
586
+ #newFormData(addThisContent){
587
+ const fd = new FormData;
588
+ for(const f of this.#extraFields){
589
+ fd.append(f.name, f.value);
590
+ }
591
+ let v;
592
+ if( this.#opt.inReplyTo ){
593
+ fd.append( 'firt', this.#opt.inReplyTo );
594
+ }else if( (v = (this.#e.title?.value?.trim?.() || this.#opt.edit?.H)) ){
595
+ fd.append('title', v);
596
+ }
597
+ fd.append('mimetype', this.mimetype);
598
+ fd.append('content', addThisContent || this.editorContent.trim());
599
+ return fd;
600
+ }
601
+
602
+ async #fetchPreview(content){
603
+ /* TODO: fetch preview */
604
+ const e = this.#e;
605
+ const fd = /*no: this.#newFormData(content); */
606
+ new FormData;
607
+ let ext;
608
+ switch(this.mimetype){
609
+ case 'text/x-markdown': ext = 'md'; break;
610
+ case 'text/x-fossil-wiki': ext = 'wiki'; break;
611
+ default: ext = 'txt'; break;
612
+ }
613
+ fd.append('filename', 'x.'+ext/*for mimetype determination*/);
614
+ fd.append('content', this.editorContent.trim());
615
+ return window
616
+ .fetch(F.repoUrl('ajax/preview-text'),{
617
+ method: 'POST',
618
+ body: fd
619
+ })
620
+ .then(r=>r.text())
621
+ .then(t=>{
622
+ if( /^\{.*}$/.test(t) ){
623
+ const o = JSON.parse(t);
624
+ throw new Error(o.error);
625
+ }
626
+ return t;
627
+ });
628
+ }
629
+
630
+ #setPreviewContent(rawHtml){
631
+ /**
632
+ Append the new content then remove the old, to help reduce
633
+ jumping-around of the UI if the preview is cleared then
634
+ repopulated.
635
+ */
636
+ const preview = this.#e.preview;
637
+ const childs = [...preview.childNodes];
638
+ D.parseHtml(preview, rawHtml);
639
+ D.remove(childs);
640
+ //preview.style.removeProperty('height');
641
+ if(F.pikchr && 'text/x-markdown'===this.mimetype){
642
+ F.pikchr.addSrcView(
643
+ preview.querySelectorAll('svg.pikchr')
644
+ );
645
+ }
646
+ }
647
+
648
+ async #preview(){
649
+ if( this.#isWaiting ) return;
650
+ const e = this.#e;
651
+ if( e.preview !== this.#activeTab ){
652
+ this.#tabs.switchToTab(e.preview);
653
+ /* Will recurse into here */
654
+ return;
655
+ }
656
+ const content = this.editorContent.trim();
657
+ //console.debug("content to preview", content);
658
+ if( !content ){
659
+ return;
660
+ }
661
+ if( 0
662
+ && !e.preview.firstElementChild ){
663
+ /* On an initial first preview, inherit the editor's height to
664
+ reduce jumping-around of the UI. */
665
+ if( 0 /* does not work: height of the editor is "auto" */ ){
666
+ const c = window.getComputedStyle(e.editor/*tabEdit*/);
667
+ e.preview.style.height = c.height;
668
+ }else{
669
+ e.preview.style.height = '20em';
670
+ }
671
+ }
672
+ this.#isWaiting = true;
673
+ D.disable(this.#toDisable, e.button.submit);
674
+ this.#fetchPreview(content)
675
+ .then((c)=>{
676
+ this.#setPreviewContent(c);
677
+ D.enable(e.button.submit);
678
+ })
679
+ .catch(err=>{
680
+ e.preview.textContent = "Error fetching preview: "+err.message;
681
+ console.error("Error fetching preview:",err);
682
+ this.reportError(err.message);
683
+ })
684
+ .finally(()=>{
685
+ this.#isWaiting = false;
686
+ D.enable(this.#toDisable);
687
+ });
688
+ }
689
+
690
+ #validate(tgt){
691
+ if( this.#e.title ){
692
+ const v = this.#e.title.value.trim();
693
+ if( !v ){
694
+ this.reportError("A non-empty title is required.");
695
+ return;
696
+ }
697
+ }
698
+ return true;
699
+ }
700
+
701
+ #submit(){
702
+ if( this.#isWaiting ) return;
703
+ if( !this.#validate() ) return;
704
+ this.#isWaiting = true;
705
+ const e = this.#e;
706
+ D.disable(e.button.submit);
707
+ const fd = this.#newFormData();
708
+ if( this.#e.status ){
709
+ /* Send the status only if it was modified, otherwise we may
710
+ add a superfluous tag. */
711
+ const v = this.#e.status.value;
712
+ if( this.#e.status.dataset.originalValue !== v ){
713
+ fd.append("status", v);
714
+ }
715
+ }
716
+ if( e.debug ){
717
+ e.debug.querySelectorAll('input[type=checkbox]').forEach(cb=>{
718
+ if( cb.checked ){
719
+ fd.append(cb.value, 1);
720
+ //console.debug("Forum post debug option:",cb);
721
+ }
722
+ });
723
+ }
724
+ if( this.#att ){
725
+ this.#att.populateFormData(fd);
726
+ }
727
+ //console.warn("Ready to submit",fd);
728
+ if( 0 ){
729
+ this.#isWaiting = false;
730
+ return;
731
+ }
732
+ const resp = window.fetch(F.repoUrl('forumajax_save'), {
733
+ method: 'POST',
734
+ body: fd
735
+ }).then(r=>r.json())
736
+ .then(j=>{
737
+ j = F.nu(j);
738
+ console.debug("forum post editor response:",j);
739
+ if( j.error ){
740
+ throw new Error(j.error);
741
+ }else if( j.message ){
742
+ /* This is only for use in debugging during
743
+ * development. */
744
+ this.reportError(j.message);
745
+ return;
746
+ }
747
+ if( 1 ){
748
+ this.#clearDraft();
749
+ if( this.#opt.onsubmit instanceof Function ){
750
+ try{this.#opt.onsubmit(this, j);}
751
+ catch(e){
752
+ console.error("ForumPostEditor.onsubmit() threw: ", e);
753
+ }
754
+ }
755
+ /*
756
+ if( this.#opt.edit?.uuid === j.uuid ) then we know the
757
+ content did not change, but it's possible that attachments
758
+ and/or a status tag did. Ergo, we need to unconditionally
759
+ reload to render those changes (if any). The other option
760
+ is to tell the user "nothing changed" and leave them in
761
+ the editor, but that could be a lie because we don't know
762
+ if any attachments or tags were changed.
763
+ */
764
+ else if( 0 ){
765
+ if( this.#opt.edit.uuid === j.uuid
766
+ && !j.statusModified && 0===j.attachedCount ){
767
+ this.reportError("No changes made.");
768
+ }else{
769
+ window.location = F.repoUrl('forumpost/'+j.uuid);
770
+ setTimeout(()=>this.close(), 500/*just in case not redirected*/);
771
+ }
772
+ }
773
+ }else{
774
+ this.reportError(
775
+ "Saving worked but we're ignoring it and staying here."
776
+ );
777
+ }
778
+ })
779
+ .catch((e)=>this.reportError(e.message))
780
+ .finally(()=>this.#isWaiting = false);
781
+ }
782
+
783
+ #storeDraft(){
784
+ if( this.#draft ){
785
+ this.#draft.mtime = Date.now();
786
+ F.storage.setJSON(this.#opt.draftKey, this.#draft);
787
+ }
788
+ }
789
+
790
+ /** Clears any persistent draft state. Does not clear the UI
791
+ widgets. */
792
+ #clearDraft(){
793
+ if( this.#draft ){
794
+ F.storage.remove(this.#opt.draftKey);
795
+ this.#draft = F.nu();
796
+ }
797
+ }
798
+
799
+ /**
800
+ Looks for editing draft keys matching either a fixed key or a
801
+ regex, and removes each matching one which is older than the
802
+ given number of days. Pass days=0 to purge all entries
803
+ immediately.
804
+ */
805
+ static purgeOldDrafts(key, days=10){
806
+ const age = (3600 * 24 * days) * 1000/*ms*/;
807
+ const now = Date.now();
808
+ const check = (k)=>{
809
+ const o = F.storage.getJSON(k);
810
+ if( o && o.mtime && (!days || (o.mtime+age <= now)) ){
811
+ F.storage.remove(k);
812
+ }
813
+ };
814
+ if( key instanceof RegExp ){
815
+ for(const k of F.storage.keys(false).filter(v=>key.test(v))){
816
+ check(k);
817
+ }
818
+ }else{
819
+ check(key);
820
+ }
821
+ }
822
+
823
+ async #fetchPost(){
824
+ /*
825
+ TODO: when editing an existing post, fetch the raw body of the
826
+ post and populate this.e.
827
+ */
828
+ }
829
+ }/*ForumPostEditor*/;
830
+ F.ForumPostEditor = ForumPostEditor;
831
+
7832
/**
8833
When the page is loaded, this handler does the following:
9834
10
- - Installs expand/collapse UI elements on "long" posts and collapses
835
+ 1. Installs expand/collapse UI elements on "long" posts and collapses
11836
them.
12837
13
- - Any pikchr-generated SVGs get a source-toggle button added to them
838
+ 2. Any pikchr-generated SVGs get a source-toggle button added to them
14839
which activates when the mouse is over the image or it is tapped.
840
+
841
+ 3. Plugs in a new edit/reply widget to forum posts.
15842
16843
This is a harmless no-op if the current page has neither forum
17
- post constructs for (1) nor any pikchr images for (2), nor will
18
- NOT running this code cause any breakage for clients with no JS
19
- support: this is all "nice-to-have", not required functionality.
844
+ post constructs for (1) and (3) nor any pikchr images for (2),
845
+ nor will NOT running this code cause any breakage for clients
846
+ with no JS support: this is all "nice-to-have", not required
847
+ functionality.
20848
*/
21849
F.onPageLoad(function(){
22850
const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight;
23851
/* Returns an event handler which implements the post expand/collapse toggle
24852
on contentElem when the given widget is activated. */
@@ -105,15 +933,20 @@
105933
const eStatus = document.querySelector(
106934
'form div.submenu select.submenuctrl[name="status"]'
107935
);
108936
if( eStatus ){
109937
/* Main /forum list. Remove the 'x' form element when eStatus
110
- ** changes, to avoid propagating x when changing the filter. */
938
+ changes, to avoid propagating x when changing the filter.
939
+ The problem this solves: we're browsed to page 3 of status X.
940
+ We change the status filter selection to Y. We're redirected
941
+ to page x, but Y only has 2 posts with that status, so we see
942
+ an empty list. When changing the filter, we need to ensure
943
+ that we start back and that beginning. */
111944
const pForm = eStatus.parentElement?.parentElement;
112945
if( pForm ){
113946
eStatus.addEventListener('change', ()=>{
114
- pForm.querySelector('input[type="hidden"][name="x"]')?.remove();
947
+ pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.();
115948
}, true);
116949
}
117950
}else{
118951
/* One of the single-post edit/view pages. Handle various UI
119952
controls and attempt to keep stray double-clicks from
@@ -126,16 +959,21 @@
126959
return;
127960
}
128961
form.dataset.submitted = '1';
129962
/** If the user is left waiting "a long time," disable the
130963
resubmit protection. If we don't do this and they tap the
131
- browser's cancel button while waiting, they'll be stuck with
132
- an unsubmittable form. */
964
+ browser's cancel button while waiting, they'll be stuck
965
+ with an unsubmittable form. It can apparently also happen,
966
+ via browser-back, that the form gets left in a submitted
967
+ state. */
133968
setTimeout(()=>{delete form.dataset.submitted}, 7000);
134969
return;
135970
};
971
+
136972
document.querySelectorAll("form").forEach(function(form){
973
+ /* Set up controls for closing posts and setting thread
974
+ status. */
137975
form.addEventListener('submit', formSubmitted);
138976
form
139977
.querySelectorAll("input.action-close, input.action-reopen")
140978
.forEach(function(e){
141979
e.classList.remove('hidden');
@@ -149,11 +987,13 @@
149987
form
150988
.querySelectorAll("input[type='button'].action-status")
151989
.forEach(function(btn){
152990
btn.classList.remove('hidden');
153991
const sel = btn.previousElementSibling;
154
- const updateAble = ()=>{
992
+ const updateButton = ()=>{
993
+ /* Enable btn only when the status has been locally
994
+ modified. */
155995
if( sel.dataset.initialValue ){
156996
if( sel.dataset.initialValue===sel.value ){
157997
btn.setAttribute('disabled','');
158998
}else{
159999
btn.removeAttribute('disabled');
@@ -164,17 +1004,392 @@
1641004
}else{
1651005
btn.removeAttribute('disabled');
1661006
}
1671007
}
1681008
};
169
- sel.addEventListener('change', updateAble, true);
170
- updateAble();
1009
+ sel.addEventListener('change', updateButton, true);
1010
+ updateButton();
1711011
F.confirmer(btn, {
1721012
confirmText: "Confirm status change",
1731013
onconfirm: ()=>form.submit()
1741014
});
1751015
});
1761016
});
1771017
}
1781018
1019
+ /* Apply page-specific tweaks for ForumPostEditor instance fpe
1020
+ then plug it into the UI at the end of ePost. */
1021
+ const initFPEWidget = (fpe, ePost)=>{
1022
+ ePost.eUnhideThenWhenDone = [
1023
+ /* List of elements to hide while editing/replying and reveal
1024
+ when discarding or saving. */
1025
+ ];
1026
+ for( const ee of ePost.querySelectorAll(
1027
+ '.forumpost-single-controls, fieldset.forum-status-selection'
1028
+ ) ){
1029
+ ee.hidden = true;
1030
+ ePost.eUnhideThenWhenDone.push(ee);
1031
+ }
1032
+ const w = fpe.widget;
1033
+ w.classList.add('animate-entrance');
1034
+ ePost.append(w);
1035
+ requestAnimationFrame(() => {
1036
+ w.scrollIntoView({
1037
+ behavior: 'smooth',
1038
+ block: 'nearest',
1039
+ inline: 'nearest'
1040
+ });
1041
+ });
1042
+ };
1043
+
1044
+ const plugInEditor =
1045
+ (new URL(window.location).searchParams).get('nojs')===null;
1046
+
1047
+ const eForumNew = (
1048
+ plugInEditor
1049
+ && (
1050
+ document.body.classList.contains('cpage-forumnew')
1051
+ || document.body.classList.contains('cpage-forume1')
1052
+ ))
1053
+ ? document.body.querySelector('#forumnew-placeholder')
1054
+ : null;
1055
+ if( plugInEditor && eForumNew ){
1056
+ /* /forumnew and /forume1 */
1057
+ const fpe = new F.ForumPostEditor({
1058
+ draftKey: 'draft-forumnew',
1059
+ hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
1060
+ hideStash: true,
1061
+ ondiscard: ()=>{
1062
+ window.location = F.repoUrl('forum');
1063
+ },
1064
+ onsubmit: (fpe, response)=>{
1065
+ window.location = F.repoUrl('forumpost/'+response.uuid);
1066
+ }
1067
+ });
1068
+ eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
1069
+ eForumNew.remove();
1070
+ fossil.page.fpe = fpe /* for testing via the console */;
1071
+ }/*eForumNew*/
1072
+ else if( plugInEditor
1073
+ && (document.body.classList.contains('cpage-forumpost')
1074
+ || document.body.classList.contains('cpage-forumthread')) ){
1075
+ /* /forumpost and /forumthread. Take over the Edit/Reply buttons
1076
+ to use a ForumPostEditor. */
1077
+
1078
+ const fetchPost = async (fpid)=>{
1079
+ return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
1080
+ .then(r=>r.json())
1081
+ .then(j=>{
1082
+ j = F.nu(j);
1083
+ if( j.error ) throw new Error(j.error);
1084
+ return j;
1085
+ });
1086
+ };
1087
+
1088
+ const makeDraftKey = (prefix,uuid)=>{
1089
+ return prefix+'-'+uuid.substr(0,12);
1090
+ };
1091
+
1092
+ /**
1093
+ Perform some init common to both Reply and Edit. ePost = the
1094
+ forum post DOM element. eButton = the Reply or Edit
1095
+ button.
1096
+ */
1097
+ const setupEditReplyElement = (ePost, eButton)=>{
1098
+ /* Forum posts are indented in the main forum view to
1099
+ represent their place in the hierarchy. In order to gain
1100
+ some screen space, we shift the post to the left margin and
1101
+ arrange to shift it back when the editor is closed. We also
1102
+ record the original button label so that it can be
1103
+ restored on close. */
1104
+ ePost.dataset.originalMarginLeft = ePost.style.marginLeft;
1105
+ ePost.style.marginLeft = 'initial';
1106
+ eButton.dataset.originalLabel = eButton.innerText;
1107
+ };
1108
+
1109
+ /** Undoes the damage done by setupEditReplyElement(). */
1110
+ const restoreEditReplyElement = (ePost, eButton)=>{
1111
+ if( ePost.dataset.originalMarginLeft ){
1112
+ ePost.style.marginLeft = ePost.dataset.originalMarginLeft;
1113
+ delete ePost.dataset.originalMarginLeft;
1114
+ }
1115
+ if( eButton.dataset.originalLabel ){
1116
+ eButton.innerText = eButton.dataset.originalLabel;
1117
+ delete eButton.dataset.originalLabel;
1118
+ }
1119
+ for(const ee of (ePost.eUnhideThenWhenDone || [])){
1120
+ ee.removeAttribute('hidden');
1121
+ }
1122
+ ePost.eUnhideThenWhenDone = undefined;
1123
+ };
1124
+
1125
+ /**
1126
+ Reports an error regarding the forum post element
1127
+ ePost, appending each entry in msg to a wrapper
1128
+ element with the class
1129
+ */
1130
+ const reportFPEError = (ePost,...msg)=>{
1131
+ const e = D.addClass(D.p(), 'error');
1132
+ e.append(
1133
+ ...msg,
1134
+ D.br(),
1135
+ D.button("Clear error", ()=>e.remove())
1136
+ );
1137
+ ePost.append(e);
1138
+ };
1139
+
1140
+ /**
1141
+ Plug in an editor widget representing a reply to a post.
1142
+ form = a (.forum-post-single-controls > form) element. The
1143
+ final 3 arguments are as documented for
1144
+ setupEditReplyElement().
1145
+ */
1146
+ const replyClicked = async (form, ePost, eBtnReply)=>{
1147
+ const fpid = ePost.dataset.fpid;
1148
+ const fEditHead = ePost.dataset.fedithead;
1149
+ const draftKey = makeDraftKey(
1150
+ 'draft-reply', fEditHead
1151
+ /* The problem with firt as a key is that firt is not
1152
+ necessarily the root edit of that post, which is what we
1153
+ really want as a draft key so that the draft does not
1154
+ disappear if firt is later edited (giving us a new firt
1155
+ value here). */
1156
+ || fpid
1157
+ );
1158
+ let releaseLock;
1159
+ if( window.navigator.locks ){
1160
+ releaseLock = await new Promise((resolve)=>{
1161
+ window.navigator.locks.request(
1162
+ 'fossil-'+draftKey,
1163
+ {ifAvailable: true},
1164
+ async (lock) => {
1165
+ if( !lock ){
1166
+ /*lock contention*/
1167
+ resolve(null);
1168
+ return;
1169
+ }
1170
+ let release;
1171
+ const lockReleased = new Promise(res=>release=res);
1172
+ resolve(release);
1173
+ await lockReleased/*hold the lock open*/;
1174
+ });
1175
+ });
1176
+ if( !releaseLock ){
1177
+ reportFPEError(
1178
+ ePost,
1179
+ "This post is actively being replied to ",
1180
+ "in another tab. To avoid losing edits, ",
1181
+ "it cannot be opened here until the locking ",
1182
+ "tab is closed."
1183
+ );
1184
+ return;
1185
+ }
1186
+ }
1187
+
1188
+ setupEditReplyElement(ePost, eBtnReply);
1189
+ eBtnReply.innerText = "Replying...";
1190
+ const ondone = (fpe, response)=>{
1191
+ /* onsubmit() and ondiscard() callback */
1192
+ restoreEditReplyElement(ePost, eBtnReply);
1193
+ //console.debug("ondiscard/onsubmit", fpe, artifact);
1194
+ if( response/*onsubmit()*/ ){
1195
+ window.location = F.repoUrl('forumpost/'+response.uuid);
1196
+ setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1197
+ }else{/*ondiscard() or onclose()*/
1198
+ }
1199
+ };
1200
+ const fpe = new F.ForumPostEditor(F.nu({
1201
+ hiddenFields: form.querySelectorAll(
1202
+ 'input[type=hidden][name=csrf]'
1203
+ /* Do not inherit the fpid field, else this will become
1204
+ an edit to that post rather than a response. */
1205
+ ),
1206
+ ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1207
+ onsubmit: ondone,
1208
+ onclose: ()=>{
1209
+ if( releaseLock ){
1210
+ releaseLock();
1211
+ releaseLock = null;
1212
+ }
1213
+ ondone();
1214
+ },
1215
+ inReplyTo: fpid,
1216
+ draftKey
1217
+ }));
1218
+ initFPEWidget(fpe, ePost);
1219
+ }/*replyClicked()*/;
1220
+
1221
+ /**
1222
+ Plug in an editor widget representing an edit to a post.
1223
+ form = a (.forum-post-single-controls > form) element. The
1224
+ final 3 arguments are as documented for
1225
+ setupEditReplyElement().
1226
+ */
1227
+ const editClicked = async (form, ePost, eBtnEdit)=>{
1228
+ const fpid = ePost.dataset.fpid;
1229
+ const firt = ePost.dataset.firt;
1230
+ const fEditHead = ePost.dataset.fedithead;
1231
+ const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid);
1232
+ let releaseLock;
1233
+ if( navigator.locks ){
1234
+ releaseLock = await new Promise((resolve) => {
1235
+ navigator.locks.request(
1236
+ 'fossil-'+draftKey,
1237
+ {ifAvailable: true},
1238
+ async (lock)=>{
1239
+ if( !lock ){
1240
+ resolve(null);
1241
+ return;
1242
+ }
1243
+ let release;
1244
+ const lockReleased = new Promise(res=>release=res);
1245
+ resolve(release);
1246
+ await lockReleased;
1247
+ });
1248
+ });
1249
+
1250
+ if( !releaseLock ){
1251
+ reportFPEError(
1252
+ ePost,
1253
+ "This post is actively being edited ",
1254
+ "in another tab. To avoid losing edits, ",
1255
+ "it cannot be opened here until the locking ",
1256
+ "tab is closed."
1257
+ );
1258
+ return;
1259
+ }
1260
+ }
1261
+ setupEditReplyElement(ePost, eBtnEdit);
1262
+ eBtnEdit.innerText = "Editing...";
1263
+ fetchPost(fpid)
1264
+ .then(artifact=>{
1265
+ const ondone = (fpe, response)=>{
1266
+ /* onsubmit() and ondiscard() callback */
1267
+ if( response/*onsubmit()*/ ){
1268
+ if( fpid === response.uuid
1269
+ && !response.statusModified
1270
+ && 0===response.attachedCount ){
1271
+ fpe.reportError("No changes made.");
1272
+ }else{
1273
+ restoreEditReplyElement(ePost, eBtnEdit);
1274
+ window.location = F.repoUrl('forumpost/'+response.uuid);
1275
+ setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1276
+ }
1277
+ }else{
1278
+ /*ondiscard() or onclose()*/
1279
+ restoreEditReplyElement(ePost, eBtnEdit);
1280
+ }
1281
+ };
1282
+ const eStatusSelect = ePost.querySelector(
1283
+ ':scope > fieldset.forum-status-selection select[name=status]'
1284
+ );
1285
+
1286
+ const fpe = new F.ForumPostEditor(F.nu({
1287
+ hiddenFields: form.querySelectorAll('input[type=hidden]'),
1288
+ ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1289
+ onsubmit: ondone,
1290
+ onclose: ()=>{
1291
+ if(releaseLock){
1292
+ releaseLock();
1293
+ releaseLock = null;
1294
+ }
1295
+ ondone();
1296
+ },
1297
+ draftKey,
1298
+ edit: artifact,
1299
+ status: eStatusSelect?.value,
1300
+ inReplyTo: firt
1301
+ }));
1302
+ initFPEWidget(fpe, ePost);
1303
+ })
1304
+ .catch(err=>{
1305
+ if( releaseLock ){
1306
+ releaseLock();
1307
+ releaseLock = null;
1308
+ }
1309
+ restoreEditReplyElement(ePost, eBtnEdit);
1310
+ console.error("Error fetching post:", err);
1311
+ reportFPEError(ePost, "Error fetching post: ", err.message);
1312
+ });
1313
+ }/*editClicked()*/;
1314
+
1315
+ document.body.querySelectorAll(
1316
+ '.forumpost-single-controls > form'
1317
+ ).forEach(form=>{
1318
+ /* For each forum post... */
1319
+ const eThePost = form.parentElement.parentElement/*main post DOM element*/;
1320
+ if( !eThePost?.dataset?.fpid ){
1321
+ /* The server injects these dataset values. */
1322
+ console.warn("Unexpected missing fpid", eThePost);
1323
+ return;
1324
+ }
1325
+ const checkButtonForDraft = (draftKeyPrefix, eBtn)=>{
1326
+ /* If a draft is found associated with eThePost, mark eBtn
1327
+ as a draft and set up storage event listeners to update
1328
+ the button as new drafts come and go. */
1329
+ const fpid = eThePost.dataset.fpid;
1330
+ const fEditHead = eThePost.dataset.fedithead;
1331
+ const draftKey = makeDraftKey(draftKeyPrefix, fEditHead || fpid);
1332
+ if( F.storage.contains(draftKey) ){
1333
+ eBtn.classList.add('draft');
1334
+ }
1335
+ F.storage.addEventListener('set', ({detail})=>{
1336
+ if( draftKey === detail.key ){
1337
+ eBtn.classList.add('draft');
1338
+ }
1339
+ });
1340
+ F.storage.addEventListener('remove', ({detail})=>{
1341
+ if( draftKey === detail.key ){
1342
+ eBtn.classList.remove('draft');
1343
+ }
1344
+ });
1345
+ };
1346
+ /* Replace the Reply and Edit buttons with ones which will activate
1347
+ a ForumPostEditor. */
1348
+ const btnReply = form.querySelector('input[type=submit][name=reply]');
1349
+ if( btnReply ){
1350
+ const b = D.button("Reply", ()=>replyClicked(form, eThePost, b));
1351
+ b.type = 'button'/*keep container form from submitting*/;
1352
+ checkButtonForDraft('draft-reply',b);
1353
+ btnReply.parentElement.insertBefore(b, btnReply);
1354
+ btnReply.remove();
1355
+ }
1356
+ const btnEdit = form.querySelector('input[type=submit][name=edit]');
1357
+ if( btnEdit ){
1358
+ const b = D.button("Edit", ()=>editClicked(form, eThePost, b));
1359
+ b.type = 'button'/*keep container form from submitting*/;
1360
+ checkButtonForDraft('draft-forumedit',b);
1361
+ btnEdit.parentElement.insertBefore(b, btnEdit);
1362
+ btnEdit.remove();
1363
+ }
1364
+ })/*for-each form*/;
1365
+
1366
+ }/* /forumpost and /forumthread */
1367
+
1368
+ document.body.querySelectorAll('.remove-on-load').forEach(e=>e.remove());
1369
+ document.body.querySelectorAll('.initially-hidden').forEach(e=>{
1370
+ /* This is a workaround for a span.help-buttonlet which we need
1371
+ to start hidden so that it does not show up for no-JS
1372
+ clients. */
1373
+ e.classList.remove('initially-hidden');
1374
+ });
1375
+
1376
+ if( plugInEditor ){
1377
+ document.body.querySelectorAll('.remove-if-replaced').forEach(
1378
+ /* Remove remaining legacy UI elements. */ e=>e.remove()
1379
+ );
1380
+ /* Purge old drafts only every now and then. */
1381
+ const now = Date.now();
1382
+ const lastPurge = +F.storage.get('forum-drafts-last-purge', 0);
1383
+ if( now - lastPurge > (24 * 60 * 60 * 1000 /*1 day ms*/) ){
1384
+ F.storage.set('forum-drafts-last-purge', now);
1385
+ setTimeout(()=>{
1386
+ /* Don't block the UI while we're doing I/O */
1387
+ F.ForumPostEditor.purgeOldDrafts(
1388
+ /^draft-(reply|forumedit)-.*/
1389
+ /* Intentionally leaving draft-forumnew in place. */
1390
+ );
1391
+ }, 50);
1392
+ }
1393
+ }
1791394
})/*F.onPageLoad callback*/;
1801395
})(window.fossil);
1811396
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,24 +1,852 @@
 
 
 
 
1 (function(F/*the fossil object*/){
2 "use strict";
3 /* JS code for /forumpost and friends. Requires fossil.dom
4 and can optionally use fossil.pikchr. */
5 const P = F.page, D = F.dom;
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7 /**
8 When the page is loaded, this handler does the following:
9
10 - Installs expand/collapse UI elements on "long" posts and collapses
11 them.
12
13 - Any pikchr-generated SVGs get a source-toggle button added to them
14 which activates when the mouse is over the image or it is tapped.
 
 
15
16 This is a harmless no-op if the current page has neither forum
17 post constructs for (1) nor any pikchr images for (2), nor will
18 NOT running this code cause any breakage for clients with no JS
19 support: this is all "nice-to-have", not required functionality.
 
20 */
21 F.onPageLoad(function(){
22 const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight;
23 /* Returns an event handler which implements the post expand/collapse toggle
24 on contentElem when the given widget is activated. */
@@ -105,15 +933,20 @@
105 const eStatus = document.querySelector(
106 'form div.submenu select.submenuctrl[name="status"]'
107 );
108 if( eStatus ){
109 /* Main /forum list. Remove the 'x' form element when eStatus
110 ** changes, to avoid propagating x when changing the filter. */
 
 
 
 
 
111 const pForm = eStatus.parentElement?.parentElement;
112 if( pForm ){
113 eStatus.addEventListener('change', ()=>{
114 pForm.querySelector('input[type="hidden"][name="x"]')?.remove();
115 }, true);
116 }
117 }else{
118 /* One of the single-post edit/view pages. Handle various UI
119 controls and attempt to keep stray double-clicks from
@@ -126,16 +959,21 @@
126 return;
127 }
128 form.dataset.submitted = '1';
129 /** If the user is left waiting "a long time," disable the
130 resubmit protection. If we don't do this and they tap the
131 browser's cancel button while waiting, they'll be stuck with
132 an unsubmittable form. */
 
 
133 setTimeout(()=>{delete form.dataset.submitted}, 7000);
134 return;
135 };
 
136 document.querySelectorAll("form").forEach(function(form){
 
 
137 form.addEventListener('submit', formSubmitted);
138 form
139 .querySelectorAll("input.action-close, input.action-reopen")
140 .forEach(function(e){
141 e.classList.remove('hidden');
@@ -149,11 +987,13 @@
149 form
150 .querySelectorAll("input[type='button'].action-status")
151 .forEach(function(btn){
152 btn.classList.remove('hidden');
153 const sel = btn.previousElementSibling;
154 const updateAble = ()=>{
 
 
155 if( sel.dataset.initialValue ){
156 if( sel.dataset.initialValue===sel.value ){
157 btn.setAttribute('disabled','');
158 }else{
159 btn.removeAttribute('disabled');
@@ -164,17 +1004,392 @@
164 }else{
165 btn.removeAttribute('disabled');
166 }
167 }
168 };
169 sel.addEventListener('change', updateAble, true);
170 updateAble();
171 F.confirmer(btn, {
172 confirmText: "Confirm status change",
173 onconfirm: ()=>form.submit()
174 });
175 });
176 });
177 }
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179 })/*F.onPageLoad callback*/;
180 })(window.fossil);
181
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -1,24 +1,852 @@
1 /**
2 Code for the forum family of pages. Requires fossil.X where X is
3 (copybutton, pikchr, confirmer, attach, tabs, storage).
4 */
5 (function(F/*the fossil object*/){
6 "use strict";
7 /* JS code for /forumpost and friends. Requires fossil.dom
8 and can optionally use fossil.pikchr. */
9 const P = F.page, D = F.dom;
10
11 let idCounter = 0;
12
13 /*
14 The problem: when previewing the bottom-most post of a thread, the
15 preview widget's size changes cause the page to scroll
16 unpredictably as the bottom boundary of the page moves. A weird
17 workaround (not invented here) is to add dummy blank padding to
18 the page to allow the preview widget to grow and shrink without
19 (usually) scrolling, but whether it does so really depends on its
20 size.
21
22 We could maybe get the same effect by adding this size as
23 padding-bottom to document.body instead of as a new element.
24 */
25 const dummyPadding = D.div();
26 dummyPadding.style.height = '75em';
27 /* Keep track of ForumPostEditor instances so we can remove this
28 padding when none are active. */
29 dummyPadding.refs = new Set();
30 F.dummyPadding = dummyPadding /* only for debugging */;
31
32 /**
33 A forum post editor widget for new posts and responses.
34 */
35 class ForumPostEditor {
36 /* Options */
37 #opt;
38 /* Dom elements */
39 #e;
40 /* F.Attacher instance */
41 #att;
42 /* Is waiting on a pending remote response. */
43 #isWaiting = false;
44 /* F.TabManager */
45 #tabs;
46 /* Elements to disable while an XHR is pending. */
47 #toDisable = [];
48 /* DOM element of the current active tab. */
49 #activeTab;
50 /* Extra input[type=hidden] fields imported from fossil's
51 static page generation. */
52 #extraFields;
53 /* Persistent draft message object. */
54 #draft;
55
56 /**
57 Options:
58
59 opt.draftKey[string=undefined]: if set then this object's state
60 will be stored in fossil.storage when the relevant input fields
61 lose focus. If old state is found, the form is pre-populated
62 from it. The state is cleared on a discard() or successful
63 submit.
64
65 opt.ondiscard[=function]: if set, a Discard button is added
66 which, when activated, clears the current draft and removes
67 this object's widget from the DOM. After doing so,
68 opt.ondiscard() is called and passed this object. Exceptions
69 thrown by ondiscard() are ignored but may be logged.
70
71 opt.onsubmit[=function]: if set, this function is called
72 immediately after the post has been successfully saved, and
73 passed this object and a JSON-format response object from the
74 save request. It is generally then up to the caller to close()
75 this object and/or redirect to /forumpost/${arguments[1].uuid}.
76
77 opt.onclose[=function]: like opt.onsubmit, this function is
78 called when this.close() is called, and passed no arguments.
79 onclose() is called before the widget is removed from the dom
80 and _does not_ fire if it is not in the DOM.
81
82 opt.hiddenFields: an optional list of input elements to
83 incorporate into the form for requests which request the
84 preview or save the post.
85
86 opt.inReplyTo=uuid: if this is a response to a post, this
87 is the full forum post uuid of the being-replied-to post.
88
89 opt.edit=artifactObject: if this is an edit of an existing
90 post, this is the full JSON-format artifact of the forum post
91 the being-edited post, as returned by /ajax/artifact.json.
92
93 opt.status: optional current status tag value for opt.edit,
94 if known. This is used for pre-selecting a status value.
95
96 opt.hideStash[bool=false]: if true, the "Stash" button does not
97 get added. Intended for use with /forumnew.
98 */
99 constructor(opt){
100 opt = this.#opt = F.nu({
101 draftKey: undefined,
102 hideStash: false
103 }, opt);
104 opt.isNewThread = !opt.inReplyTo && !opt.edit;
105 if( opt.draftKey ){
106 this.#draft = F.nu(F.storage.getJSON(opt.draftKey, {}));
107 }
108 const e = this.#e = F.nu({
109 mimetype: F.nu(),
110 button: F.nu()
111 });
112 //console.debug("Setting up FPE opt =",opt);
113 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
114 D.clearElement(wrapper);
115
116 if( !opt.inReplyTo ){
117 /* Title... */
118 e.titleBar = D.addClass(D.div(),'titlebar');
119 e.title = D.attr(
120 D.addClass(D.input('text'), 'title'),
121 'placeholder',
122 'Thread title (required)'
123 );
124 e.title.setAttribute('maxlength', 125);
125 e.titleBar.append(
126 D.append(D.span(), "Title:"),
127 e.title
128 );
129 if( this.#draft ){
130 e.title.addEventListener('blur', ()=>{
131 this.#draft.title = e.title.value;
132 this.#storeDraft();
133 });
134 e.title.value = this.#draft.title || opt.edit?.H || '';
135 }else if( opt.edit?.H ){
136 e.title.value = opt.edit.H;
137 }
138 wrapper.append(e.titleBar);
139 }
140
141 { /* Mimetype... */
142 e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper');
143 const sel = e.mimetype.select = D.addClass(D.select(), 'mimetype-select');
144 sel.setAttribute('title', 'Markup format for this post.');
145 this.#toDisable.push(sel);
146 let i = 0;
147 D.option(sel, '', '- Markup format -').disabled = true;
148 for(const [k,v] of Object.entries({
149 'text/x-markdown': 'Markdown',
150 'text/x-fossil-wiki': 'Fossil Wiki',
151 'text/plain': 'Plain text'
152 })) {
153 D.option(sel, k, v);
154 }
155 sel.value = opt.mimetype
156 || this.#draft?.mimetype
157 || F.storage.get('forum-mimetype', sel.options[1].value);
158 sel.addEventListener('change',ev=>{
159 if( this.#draft && this.#draft.mimetype!==ev.target.value ){
160 this.#draft.mimetype = ev.target.value;
161 this.#storeDraft();
162 }
163 F.storage.set('forum-mimetype', ev.target.value);
164 });
165 e.mimetype.wrapper.append(sel);
166 }
167
168 e.buttons = D.addClass(D.div(), 'buttons');
169 { /* Preview/submit buttons... */
170 e.button.preview = D.attr(
171 D.button("Preview", e=>this.#preview()),
172 'title',
173 'Preview your edits.'
174 );
175 e.button.submit = D.attr(
176 D.button("Submit"),
177 'title',
178 'Save any edits to the server. Not permitted until Preview has been used.'
179 );
180 if( this.#draft && !opt.hideStash ){
181 e.button.stash = D.attr(
182 D.button(
183 "Stash", e=>this.close()
184 /* This could be called Close, but that would semantically
185 collide with the Close [this post] button. All "Stash"
186 does is close the widget. */
187 ),
188 'title', "Close this editor and stash any edits locally."
189 );
190 }
191 if( opt.ondiscard instanceof Function ){
192 e.button.discard = D.attr(
193 D.button('Discard'),
194 'title',
195 'Close the editor and discard all local edits.'
196 );
197 }
198 if( 1 ){
199 F.confirmer(e.button.submit, {
200 confirmText: "Confirm submit...",
201 onconfirm: ()=>this.#submit()
202 });
203 if( e.button.discard ){
204 F.confirmer(e.button.discard, {
205 confirmText: "Really discard?",
206 onconfirm: ()=>this.discard()
207 });
208 }
209 }else{
210 e.button.submit.addEventListener('click', ()=>this.#submit());
211 if( e.button.discard ){
212 e.button.submit.addEventListener('click', ()=>this.discard());
213 }
214 }
215 e.button.submit.setAttribute('disabled', '');
216 wrapper.append(e.buttons);
217
218 e.error = D.addClass(D.div(), 'error', 'hidden');
219 wrapper.append(e.error);
220 e.error.addEventListener('dblclick',()=>this.reportError());
221 }
222
223 const idPrefix = 'FormPostEditor'+(++idCounter)/* TabManager requires IDs */;
224 { /* Main tabs... */
225 e.tabs = D.attr(
226 D.addClass(D.div(), 'tab-container'),
227 'id', idPrefix+'-tabs'
228 );
229 this.#tabs = new F.TabManager(e.tabs);
230 this.#tabs.addEventListener('before-switch-to', (ev)=>{
231 //console.debug("Switching to tab",ev.detail);
232 switch( (this.#activeTab = ev.detail) ){
233 case e.preview:
234 this.#e.button.preview.click();
235 break;
236 case e.help:
237 if( e.help.$needsInit ){
238 delete e.help.$needsInit;
239 this.#initHelpTab();
240 }
241 break;
242 case e.tabAttach:
243 if( !this.#att ) this.#initAttacherTab();
244 break;
245 }
246 });
247 wrapper.append( e.tabs );
248
249 e.tabEdit = D.div();
250 e.tabEdit.classList.add('editor-wrapper');
251 e.editor = D.attr(
252 D.addClass(D.textarea(), 'editor'),
253 'placeholder',
254 'Your message to other forum-goers...'
255 );
256 e.tabEdit.append(e.editor);
257 e.tabEdit.dataset.tabLabel = (opt.edit || !opt.inReplyTo)
258 ? 'Edit' : 'Reply';
259 this.#tabs.addTab( e.tabEdit );
260 this.#tabs.switchToTab( e.tabEdit );
261 if( this.#draft ){
262 this.editorContent = this.#draft.content || opt.edit?.W || '';
263 e.editor.addEventListener(
264 'blur', ()=>{
265 this.#draft.content = this.editorContent;
266 this.#storeDraft();
267 }
268 );
269 }else if( opt.edit?.W ){
270 this.editorContent = opt.artifact.W;
271 }
272 e.preview = D.addClass(D.div(), 'preview');
273 e.preview.dataset.tabLabel = 'Preview';
274 this.#toDisable.push(e.button.preview);
275 this.#tabs.addTab( e.preview );
276 }
277
278 if( F.user.enableDebug ){
279 e.debug = D.addClass(D.div(), 'debug');
280 e.debug.dataset.tabLabel = 'Debug';
281 e.debug.setAttribute('id', idPrefix+'-debug');
282 for(const [k,v] of Object.entries({
283 dryrun: 'Dry run',
284 domod: 'Require moderation approval',
285 //showqp: 'Show query parameters',
286 fpsilent: 'Do not send notification emails'
287 })){
288 const lbl = D.label(false, v);
289 lbl.prepend(D.checkbox(k));
290 e.debug.append(lbl);
291 }
292 this.#tabs.addTab(e.debug);
293 }
294 e.buttons.append(e.mimetype.wrapper);
295
296 if( opt.edit
297 && !opt.inReplyTo
298 && F.config.forumStatuses?.length>0 ){
299 const sel = e.status = D.select();
300 sel.setAttribute('title', 'The status tag value for this post.');
301 D.option(sel, "", "- Status -").disabled = true;
302 for( const status of F.config.forumStatuses ){
303 D.option(sel, status.value, status.label);
304 }
305 e.buttons.append(sel);
306 if( opt.status ){
307 sel.value = opt.status;
308 }else if( this.#draft ){
309 if( this.#draft.status ){
310 sel.value = this.#draft.status;
311 }else{
312 this.#draft.status = sel.value = F.config.forumStatuses[0].value;
313 }
314 sel.addEventListener('change',ev=>{
315 const v = sel.value;
316 if( this.#draft.status !== v ){
317 this.#draft.status = v;
318 this.#storeDraft();
319 }
320 });
321 }
322 }/*e.status*/
323
324 if( F.user.mayAttachForum ){
325 //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
326 e.tabAttach = D.div();
327 e.tabAttach.setAttribute('id', idPrefix+'-attach');
328 e.tabAttach.dataset.tabLabel = 'Attachments';
329 this.#tabs.addTab(e.tabAttach);
330 /* Reminder: we don't currently have a way to disable/enable
331 an Attacher's controls during ajax traffic. */
332 }
333 e.buttons.append(e.button.preview, e.button.submit);
334 if( e.button.stash ){
335 e.buttons.append(e.button.stash);
336 this.#toDisable.push(e.button.stash);
337 }
338 if( e.button.discard ){
339 e.buttons.append(e.button.discard);
340 this.#toDisable.push(e.button.discard);
341 }
342
343 e.help = D.attr(D.div(), 'id', idPrefix+'-help');
344 e.help.$needsInit = true;
345 e.help.dataset.tabLabel = 'Help';
346 this.#tabs.addTab(e.help);
347
348 if( opt.hiddenFields ){
349 this.addHiddenFields( opt.hiddenFields );
350 delete opt.hiddenFields;
351 }
352
353 { /* Shift-enter pieces... */
354 const eCb = D.checkbox(1);
355 const eLbl = D.label();
356 const eHelp = D.append(
357 D.span(), [
358 'When checked, shift-enter will toggle between preview ',
359 'and edit modes, which is generally useful but some ',
360 'software keyboards misinteract with it. If the preview ',
361 'starts when tapping Enter, turn this setting off.'
362 ].join('')
363 );
364 eCb.checked = F.storage.getBool(
365 'edit-shift-enter-preview',
366 true
367 /* Maintenance reminder: this setting is shared across
368 several apps, like /chat, /wikiedit, and /fileedit. */
369 );
370 eCb.addEventListener('change', (ev)=>{
371 F.storage.set('edit-shift-enter-preview', eCb.checked);
372 });
373 F.helpButtonlets.setup(eHelp);
374 eLbl.append("Shift-enter toggles preview?", eCb, eHelp);
375 e.tabEdit.append(eLbl);
376 const isShiftEnter = (ev)=>eCb.checked && ev.shiftKey && 13===ev.keyCode;
377 e.editor.addEventListener('keydown',(ev)=>{
378 /**
379 If eCb.checked is true, a keyboard combo of shift-enter
380 (from the editor) toggles between preview and edit modes.
381 This is normally desired but at least one software
382 keyboard is known to misinteract with this, treating an
383 Enter after automatically-capitalized letters as a
384 shift-enter:
385
386 https://fossil-scm.org/forum/forumpost/dbd5b68366147ce8
387 */
388 if(!isShiftEnter(ev)) return;
389 ev.preventDefault();
390 ev.stopPropagation();
391 e.editor.blur(/*force draft update if needed*/);
392 this.#tabs.switchToTab(e.preview);
393 }, false);
394 // If we're in the preview tab, have ctrl-enter switch back to the editor.
395 document.body.addEventListener('keydown',(ev)=>{
396 if(!isShiftEnter(ev)) return;
397 if(this.#activeTab !== e.tabEdit){
398 ev.preventDefault();
399 ev.stopPropagation();
400 this.#tabs.switchToTab(e.tabEdit);
401 e.editor.focus(/*slow as molasses for long docs, as focus()
402 forces a document reflow. */);
403 return false;
404 }
405 }, true);
406 }/*shift-enter preview bits*/
407
408 if(0){ /* Needs to be optional */
409 const elemsToToggle = document.body.querySelectorAll(
410 ':scope > header, :scope > nav'
411 );
412 e.button.toggleHeader =
413 D.button('Toggle header', e=>{
414 for(const et of elemsToToggle){
415 et.classList.toggle('hidden');
416 }
417 });
418 e.buttons.append(e.button.toggleHeader);
419 }
420
421 {
422 const eLbl = D.label(false, "Posting as "+F.user.name)
423 eLbl.classList.add('logged-in-as');
424 e.buttons.append(eLbl);
425 }
426
427 }/*constructor*/
428
429 /*
430 ** Removes this object from the DOM. It has no side effects if
431 ** it's not in the DOM.
432 */
433 close(){
434 const e = this.#e.widget;
435 if( e?.parentNode ){
436 if( this.#opt.onclose instanceof Function ){
437 try{this.#opt.onclose();}
438 catch(e){
439 console.error("ForumPostEditor.onclose() threw:",e);
440 }
441 }
442 //console.debug("FPE discarding", this);
443 e.classList.add('animate-exit');
444 e.addEventListener('animationend', ()=>e.remove(), {once: true});
445 dummyPadding.refs.delete(this);
446 if( 0===dummyPadding.refs.size ){
447 dummyPadding.remove();
448 }
449 }
450 }
451
452 /*
453 ** Discards any draft edits then calls close(). If an ondiscard
454 ** callback was provided to the constructor then it is called
455 ** before the drafts are cleared and any exceptions it throws are
456 ** ignored (but may be logged).
457 */
458 discard(){
459 if( this.#opt.ondiscard instanceof Function ){
460 try{this.#opt.ondiscard(this);}
461 catch(e){
462 console.error("ForumPostEditor.ondiscard() threw:",e);
463 }
464 }
465 this.#clearDraft();
466 this.close();
467 }
468
469 /** This widget's top-most DOM element. */
470 get widget(){
471 if( !dummyPadding.parentElement ){
472 document.body.append(dummyPadding);
473 }
474 dummyPadding.refs.add(this);
475 return this.#e.widget;
476 }
477
478 get editorContent(){
479 /* We wrap access to the editor's contents in a getter/setter so
480 that we can eventually add optional use of a contenteditable
481 edit field, as those are generally more comfortable. The code
482 for that is in fossil.page.chat.js. */
483 return this.#e.editor.value;
484 }
485
486 set editorContent(v){
487 this.#e.editor.value = v;
488 }
489
490 /**
491 Reports an error by appending each argument to the error widget
492 and unhiding it. If passed no arugments, it clears and hides
493 the error widget.
494 */
495 reportError(...msg){
496 const e = this.#e.error;
497 D.clearElement(e);
498 if( msg.length ){
499 console.error('ForumPostEditor:',...msg);
500 e.classList.remove('hidden');
501 e.append(
502 ...msg, D.br(),
503 D.button("Clear", ()=>this.reportError())
504 /* Looks horrid in the Blitz skin */
505 );
506 }else{
507 e.classList.add('hidden');
508 }
509 }
510
511 /**
512 Adds a list of input[type=hidden] form fields to this object,
513 imported from the server-generated HTML. This is used for
514 collecting, e.g., the CSRF token and an initial page title.
515 */
516 addHiddenFields(list){
517 this.#extraFields ??= [];
518 for( const f of list ){
519 if( !f ) continue;
520 if( 'title'===f.name && this.#e.title ){
521 if( f.value && this.#opt.isNewThread && !this.#e.title.value ){
522 this.#e.title.value = f.value;
523 }
524 }else{
525 this.#extraFields.push(f);
526 }
527 }
528 }
529
530 get mimetype(){
531 return this.#e.mimetype.select.value;
532 }
533
534 get title(){
535 return this.#e.title?.value || this.#opt.edit?.H;
536 }
537
538 #initHelpTab(){
539 const eh = this.#e.help;
540 const list = D.ul();
541 D.append(
542 D.li(list),
543 D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
544 'target', '_new')
545 );
546 D.append(
547 D.li(list),
548 "WARNING: draft edits are keyed on the ID of the message they ",
549 "are editing or responding to. Attempting to edit or reply to ",
550 "the same post from multiple tabs will cause the most-recently-edited ",
551 "one to overwrite the draft slot for that post. In browsers which support ",
552 "Web Locks, a second attempt to edit or reply to a post will be blocked ",
553 "and an error will be shown explaining the problem."
554 );
555 if( this.#e.status ){
556 D.append(
557 D.li(list),
558 "Tip: changing just the status in the editor will change only that, ",
559 "not a whole new (but unedited) copy of the post."
560 );
561 }
562 eh.append(list);
563 }
564
565 #initAttacherTab(){
566 this.#att = new F.Attacher({
567 reverse: true
568 });
569 if( this.#opt.edit ){
570 const eNote = D.append(
571 D.div(),
572 "Tip: attachments can be added to posts without editing them ",
573 "by visiting ",
574 D.attr(
575 D.a(F.repoUrl('attachadd?target='+this.#opt.edit.uuid), '/attachadd'),
576 'target',
577 '_new'
578 ),
579 ".",
580 );
581 this.#e.tabAttach.append(eNote);
582 }
583 this.#e.tabAttach.append(this.#att.widget);
584 }
585
586 #newFormData(addThisContent){
587 const fd = new FormData;
588 for(const f of this.#extraFields){
589 fd.append(f.name, f.value);
590 }
591 let v;
592 if( this.#opt.inReplyTo ){
593 fd.append( 'firt', this.#opt.inReplyTo );
594 }else if( (v = (this.#e.title?.value?.trim?.() || this.#opt.edit?.H)) ){
595 fd.append('title', v);
596 }
597 fd.append('mimetype', this.mimetype);
598 fd.append('content', addThisContent || this.editorContent.trim());
599 return fd;
600 }
601
602 async #fetchPreview(content){
603 /* TODO: fetch preview */
604 const e = this.#e;
605 const fd = /*no: this.#newFormData(content); */
606 new FormData;
607 let ext;
608 switch(this.mimetype){
609 case 'text/x-markdown': ext = 'md'; break;
610 case 'text/x-fossil-wiki': ext = 'wiki'; break;
611 default: ext = 'txt'; break;
612 }
613 fd.append('filename', 'x.'+ext/*for mimetype determination*/);
614 fd.append('content', this.editorContent.trim());
615 return window
616 .fetch(F.repoUrl('ajax/preview-text'),{
617 method: 'POST',
618 body: fd
619 })
620 .then(r=>r.text())
621 .then(t=>{
622 if( /^\{.*}$/.test(t) ){
623 const o = JSON.parse(t);
624 throw new Error(o.error);
625 }
626 return t;
627 });
628 }
629
630 #setPreviewContent(rawHtml){
631 /**
632 Append the new content then remove the old, to help reduce
633 jumping-around of the UI if the preview is cleared then
634 repopulated.
635 */
636 const preview = this.#e.preview;
637 const childs = [...preview.childNodes];
638 D.parseHtml(preview, rawHtml);
639 D.remove(childs);
640 //preview.style.removeProperty('height');
641 if(F.pikchr && 'text/x-markdown'===this.mimetype){
642 F.pikchr.addSrcView(
643 preview.querySelectorAll('svg.pikchr')
644 );
645 }
646 }
647
648 async #preview(){
649 if( this.#isWaiting ) return;
650 const e = this.#e;
651 if( e.preview !== this.#activeTab ){
652 this.#tabs.switchToTab(e.preview);
653 /* Will recurse into here */
654 return;
655 }
656 const content = this.editorContent.trim();
657 //console.debug("content to preview", content);
658 if( !content ){
659 return;
660 }
661 if( 0
662 && !e.preview.firstElementChild ){
663 /* On an initial first preview, inherit the editor's height to
664 reduce jumping-around of the UI. */
665 if( 0 /* does not work: height of the editor is "auto" */ ){
666 const c = window.getComputedStyle(e.editor/*tabEdit*/);
667 e.preview.style.height = c.height;
668 }else{
669 e.preview.style.height = '20em';
670 }
671 }
672 this.#isWaiting = true;
673 D.disable(this.#toDisable, e.button.submit);
674 this.#fetchPreview(content)
675 .then((c)=>{
676 this.#setPreviewContent(c);
677 D.enable(e.button.submit);
678 })
679 .catch(err=>{
680 e.preview.textContent = "Error fetching preview: "+err.message;
681 console.error("Error fetching preview:",err);
682 this.reportError(err.message);
683 })
684 .finally(()=>{
685 this.#isWaiting = false;
686 D.enable(this.#toDisable);
687 });
688 }
689
690 #validate(tgt){
691 if( this.#e.title ){
692 const v = this.#e.title.value.trim();
693 if( !v ){
694 this.reportError("A non-empty title is required.");
695 return;
696 }
697 }
698 return true;
699 }
700
701 #submit(){
702 if( this.#isWaiting ) return;
703 if( !this.#validate() ) return;
704 this.#isWaiting = true;
705 const e = this.#e;
706 D.disable(e.button.submit);
707 const fd = this.#newFormData();
708 if( this.#e.status ){
709 /* Send the status only if it was modified, otherwise we may
710 add a superfluous tag. */
711 const v = this.#e.status.value;
712 if( this.#e.status.dataset.originalValue !== v ){
713 fd.append("status", v);
714 }
715 }
716 if( e.debug ){
717 e.debug.querySelectorAll('input[type=checkbox]').forEach(cb=>{
718 if( cb.checked ){
719 fd.append(cb.value, 1);
720 //console.debug("Forum post debug option:",cb);
721 }
722 });
723 }
724 if( this.#att ){
725 this.#att.populateFormData(fd);
726 }
727 //console.warn("Ready to submit",fd);
728 if( 0 ){
729 this.#isWaiting = false;
730 return;
731 }
732 const resp = window.fetch(F.repoUrl('forumajax_save'), {
733 method: 'POST',
734 body: fd
735 }).then(r=>r.json())
736 .then(j=>{
737 j = F.nu(j);
738 console.debug("forum post editor response:",j);
739 if( j.error ){
740 throw new Error(j.error);
741 }else if( j.message ){
742 /* This is only for use in debugging during
743 * development. */
744 this.reportError(j.message);
745 return;
746 }
747 if( 1 ){
748 this.#clearDraft();
749 if( this.#opt.onsubmit instanceof Function ){
750 try{this.#opt.onsubmit(this, j);}
751 catch(e){
752 console.error("ForumPostEditor.onsubmit() threw: ", e);
753 }
754 }
755 /*
756 if( this.#opt.edit?.uuid === j.uuid ) then we know the
757 content did not change, but it's possible that attachments
758 and/or a status tag did. Ergo, we need to unconditionally
759 reload to render those changes (if any). The other option
760 is to tell the user "nothing changed" and leave them in
761 the editor, but that could be a lie because we don't know
762 if any attachments or tags were changed.
763 */
764 else if( 0 ){
765 if( this.#opt.edit.uuid === j.uuid
766 && !j.statusModified && 0===j.attachedCount ){
767 this.reportError("No changes made.");
768 }else{
769 window.location = F.repoUrl('forumpost/'+j.uuid);
770 setTimeout(()=>this.close(), 500/*just in case not redirected*/);
771 }
772 }
773 }else{
774 this.reportError(
775 "Saving worked but we're ignoring it and staying here."
776 );
777 }
778 })
779 .catch((e)=>this.reportError(e.message))
780 .finally(()=>this.#isWaiting = false);
781 }
782
783 #storeDraft(){
784 if( this.#draft ){
785 this.#draft.mtime = Date.now();
786 F.storage.setJSON(this.#opt.draftKey, this.#draft);
787 }
788 }
789
790 /** Clears any persistent draft state. Does not clear the UI
791 widgets. */
792 #clearDraft(){
793 if( this.#draft ){
794 F.storage.remove(this.#opt.draftKey);
795 this.#draft = F.nu();
796 }
797 }
798
799 /**
800 Looks for editing draft keys matching either a fixed key or a
801 regex, and removes each matching one which is older than the
802 given number of days. Pass days=0 to purge all entries
803 immediately.
804 */
805 static purgeOldDrafts(key, days=10){
806 const age = (3600 * 24 * days) * 1000/*ms*/;
807 const now = Date.now();
808 const check = (k)=>{
809 const o = F.storage.getJSON(k);
810 if( o && o.mtime && (!days || (o.mtime+age <= now)) ){
811 F.storage.remove(k);
812 }
813 };
814 if( key instanceof RegExp ){
815 for(const k of F.storage.keys(false).filter(v=>key.test(v))){
816 check(k);
817 }
818 }else{
819 check(key);
820 }
821 }
822
823 async #fetchPost(){
824 /*
825 TODO: when editing an existing post, fetch the raw body of the
826 post and populate this.e.
827 */
828 }
829 }/*ForumPostEditor*/;
830 F.ForumPostEditor = ForumPostEditor;
831
832 /**
833 When the page is loaded, this handler does the following:
834
835 1. Installs expand/collapse UI elements on "long" posts and collapses
836 them.
837
838 2. Any pikchr-generated SVGs get a source-toggle button added to them
839 which activates when the mouse is over the image or it is tapped.
840
841 3. Plugs in a new edit/reply widget to forum posts.
842
843 This is a harmless no-op if the current page has neither forum
844 post constructs for (1) and (3) nor any pikchr images for (2),
845 nor will NOT running this code cause any breakage for clients
846 with no JS support: this is all "nice-to-have", not required
847 functionality.
848 */
849 F.onPageLoad(function(){
850 const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight;
851 /* Returns an event handler which implements the post expand/collapse toggle
852 on contentElem when the given widget is activated. */
@@ -105,15 +933,20 @@
933 const eStatus = document.querySelector(
934 'form div.submenu select.submenuctrl[name="status"]'
935 );
936 if( eStatus ){
937 /* Main /forum list. Remove the 'x' form element when eStatus
938 changes, to avoid propagating x when changing the filter.
939 The problem this solves: we're browsed to page 3 of status X.
940 We change the status filter selection to Y. We're redirected
941 to page x, but Y only has 2 posts with that status, so we see
942 an empty list. When changing the filter, we need to ensure
943 that we start back and that beginning. */
944 const pForm = eStatus.parentElement?.parentElement;
945 if( pForm ){
946 eStatus.addEventListener('change', ()=>{
947 pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.();
948 }, true);
949 }
950 }else{
951 /* One of the single-post edit/view pages. Handle various UI
952 controls and attempt to keep stray double-clicks from
@@ -126,16 +959,21 @@
959 return;
960 }
961 form.dataset.submitted = '1';
962 /** If the user is left waiting "a long time," disable the
963 resubmit protection. If we don't do this and they tap the
964 browser's cancel button while waiting, they'll be stuck
965 with an unsubmittable form. It can apparently also happen,
966 via browser-back, that the form gets left in a submitted
967 state. */
968 setTimeout(()=>{delete form.dataset.submitted}, 7000);
969 return;
970 };
971
972 document.querySelectorAll("form").forEach(function(form){
973 /* Set up controls for closing posts and setting thread
974 status. */
975 form.addEventListener('submit', formSubmitted);
976 form
977 .querySelectorAll("input.action-close, input.action-reopen")
978 .forEach(function(e){
979 e.classList.remove('hidden');
@@ -149,11 +987,13 @@
987 form
988 .querySelectorAll("input[type='button'].action-status")
989 .forEach(function(btn){
990 btn.classList.remove('hidden');
991 const sel = btn.previousElementSibling;
992 const updateButton = ()=>{
993 /* Enable btn only when the status has been locally
994 modified. */
995 if( sel.dataset.initialValue ){
996 if( sel.dataset.initialValue===sel.value ){
997 btn.setAttribute('disabled','');
998 }else{
999 btn.removeAttribute('disabled');
@@ -164,17 +1004,392 @@
1004 }else{
1005 btn.removeAttribute('disabled');
1006 }
1007 }
1008 };
1009 sel.addEventListener('change', updateButton, true);
1010 updateButton();
1011 F.confirmer(btn, {
1012 confirmText: "Confirm status change",
1013 onconfirm: ()=>form.submit()
1014 });
1015 });
1016 });
1017 }
1018
1019 /* Apply page-specific tweaks for ForumPostEditor instance fpe
1020 then plug it into the UI at the end of ePost. */
1021 const initFPEWidget = (fpe, ePost)=>{
1022 ePost.eUnhideThenWhenDone = [
1023 /* List of elements to hide while editing/replying and reveal
1024 when discarding or saving. */
1025 ];
1026 for( const ee of ePost.querySelectorAll(
1027 '.forumpost-single-controls, fieldset.forum-status-selection'
1028 ) ){
1029 ee.hidden = true;
1030 ePost.eUnhideThenWhenDone.push(ee);
1031 }
1032 const w = fpe.widget;
1033 w.classList.add('animate-entrance');
1034 ePost.append(w);
1035 requestAnimationFrame(() => {
1036 w.scrollIntoView({
1037 behavior: 'smooth',
1038 block: 'nearest',
1039 inline: 'nearest'
1040 });
1041 });
1042 };
1043
1044 const plugInEditor =
1045 (new URL(window.location).searchParams).get('nojs')===null;
1046
1047 const eForumNew = (
1048 plugInEditor
1049 && (
1050 document.body.classList.contains('cpage-forumnew')
1051 || document.body.classList.contains('cpage-forume1')
1052 ))
1053 ? document.body.querySelector('#forumnew-placeholder')
1054 : null;
1055 if( plugInEditor && eForumNew ){
1056 /* /forumnew and /forume1 */
1057 const fpe = new F.ForumPostEditor({
1058 draftKey: 'draft-forumnew',
1059 hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'),
1060 hideStash: true,
1061 ondiscard: ()=>{
1062 window.location = F.repoUrl('forum');
1063 },
1064 onsubmit: (fpe, response)=>{
1065 window.location = F.repoUrl('forumpost/'+response.uuid);
1066 }
1067 });
1068 eForumNew.parentElement.insertBefore(fpe.widget, eForumNew);
1069 eForumNew.remove();
1070 fossil.page.fpe = fpe /* for testing via the console */;
1071 }/*eForumNew*/
1072 else if( plugInEditor
1073 && (document.body.classList.contains('cpage-forumpost')
1074 || document.body.classList.contains('cpage-forumthread')) ){
1075 /* /forumpost and /forumthread. Take over the Edit/Reply buttons
1076 to use a ForumPostEditor. */
1077
1078 const fetchPost = async (fpid)=>{
1079 return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid))
1080 .then(r=>r.json())
1081 .then(j=>{
1082 j = F.nu(j);
1083 if( j.error ) throw new Error(j.error);
1084 return j;
1085 });
1086 };
1087
1088 const makeDraftKey = (prefix,uuid)=>{
1089 return prefix+'-'+uuid.substr(0,12);
1090 };
1091
1092 /**
1093 Perform some init common to both Reply and Edit. ePost = the
1094 forum post DOM element. eButton = the Reply or Edit
1095 button.
1096 */
1097 const setupEditReplyElement = (ePost, eButton)=>{
1098 /* Forum posts are indented in the main forum view to
1099 represent their place in the hierarchy. In order to gain
1100 some screen space, we shift the post to the left margin and
1101 arrange to shift it back when the editor is closed. We also
1102 record the original button label so that it can be
1103 restored on close. */
1104 ePost.dataset.originalMarginLeft = ePost.style.marginLeft;
1105 ePost.style.marginLeft = 'initial';
1106 eButton.dataset.originalLabel = eButton.innerText;
1107 };
1108
1109 /** Undoes the damage done by setupEditReplyElement(). */
1110 const restoreEditReplyElement = (ePost, eButton)=>{
1111 if( ePost.dataset.originalMarginLeft ){
1112 ePost.style.marginLeft = ePost.dataset.originalMarginLeft;
1113 delete ePost.dataset.originalMarginLeft;
1114 }
1115 if( eButton.dataset.originalLabel ){
1116 eButton.innerText = eButton.dataset.originalLabel;
1117 delete eButton.dataset.originalLabel;
1118 }
1119 for(const ee of (ePost.eUnhideThenWhenDone || [])){
1120 ee.removeAttribute('hidden');
1121 }
1122 ePost.eUnhideThenWhenDone = undefined;
1123 };
1124
1125 /**
1126 Reports an error regarding the forum post element
1127 ePost, appending each entry in msg to a wrapper
1128 element with the class
1129 */
1130 const reportFPEError = (ePost,...msg)=>{
1131 const e = D.addClass(D.p(), 'error');
1132 e.append(
1133 ...msg,
1134 D.br(),
1135 D.button("Clear error", ()=>e.remove())
1136 );
1137 ePost.append(e);
1138 };
1139
1140 /**
1141 Plug in an editor widget representing a reply to a post.
1142 form = a (.forum-post-single-controls > form) element. The
1143 final 3 arguments are as documented for
1144 setupEditReplyElement().
1145 */
1146 const replyClicked = async (form, ePost, eBtnReply)=>{
1147 const fpid = ePost.dataset.fpid;
1148 const fEditHead = ePost.dataset.fedithead;
1149 const draftKey = makeDraftKey(
1150 'draft-reply', fEditHead
1151 /* The problem with firt as a key is that firt is not
1152 necessarily the root edit of that post, which is what we
1153 really want as a draft key so that the draft does not
1154 disappear if firt is later edited (giving us a new firt
1155 value here). */
1156 || fpid
1157 );
1158 let releaseLock;
1159 if( window.navigator.locks ){
1160 releaseLock = await new Promise((resolve)=>{
1161 window.navigator.locks.request(
1162 'fossil-'+draftKey,
1163 {ifAvailable: true},
1164 async (lock) => {
1165 if( !lock ){
1166 /*lock contention*/
1167 resolve(null);
1168 return;
1169 }
1170 let release;
1171 const lockReleased = new Promise(res=>release=res);
1172 resolve(release);
1173 await lockReleased/*hold the lock open*/;
1174 });
1175 });
1176 if( !releaseLock ){
1177 reportFPEError(
1178 ePost,
1179 "This post is actively being replied to ",
1180 "in another tab. To avoid losing edits, ",
1181 "it cannot be opened here until the locking ",
1182 "tab is closed."
1183 );
1184 return;
1185 }
1186 }
1187
1188 setupEditReplyElement(ePost, eBtnReply);
1189 eBtnReply.innerText = "Replying...";
1190 const ondone = (fpe, response)=>{
1191 /* onsubmit() and ondiscard() callback */
1192 restoreEditReplyElement(ePost, eBtnReply);
1193 //console.debug("ondiscard/onsubmit", fpe, artifact);
1194 if( response/*onsubmit()*/ ){
1195 window.location = F.repoUrl('forumpost/'+response.uuid);
1196 setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1197 }else{/*ondiscard() or onclose()*/
1198 }
1199 };
1200 const fpe = new F.ForumPostEditor(F.nu({
1201 hiddenFields: form.querySelectorAll(
1202 'input[type=hidden][name=csrf]'
1203 /* Do not inherit the fpid field, else this will become
1204 an edit to that post rather than a response. */
1205 ),
1206 ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1207 onsubmit: ondone,
1208 onclose: ()=>{
1209 if( releaseLock ){
1210 releaseLock();
1211 releaseLock = null;
1212 }
1213 ondone();
1214 },
1215 inReplyTo: fpid,
1216 draftKey
1217 }));
1218 initFPEWidget(fpe, ePost);
1219 }/*replyClicked()*/;
1220
1221 /**
1222 Plug in an editor widget representing an edit to a post.
1223 form = a (.forum-post-single-controls > form) element. The
1224 final 3 arguments are as documented for
1225 setupEditReplyElement().
1226 */
1227 const editClicked = async (form, ePost, eBtnEdit)=>{
1228 const fpid = ePost.dataset.fpid;
1229 const firt = ePost.dataset.firt;
1230 const fEditHead = ePost.dataset.fedithead;
1231 const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid);
1232 let releaseLock;
1233 if( navigator.locks ){
1234 releaseLock = await new Promise((resolve) => {
1235 navigator.locks.request(
1236 'fossil-'+draftKey,
1237 {ifAvailable: true},
1238 async (lock)=>{
1239 if( !lock ){
1240 resolve(null);
1241 return;
1242 }
1243 let release;
1244 const lockReleased = new Promise(res=>release=res);
1245 resolve(release);
1246 await lockReleased;
1247 });
1248 });
1249
1250 if( !releaseLock ){
1251 reportFPEError(
1252 ePost,
1253 "This post is actively being edited ",
1254 "in another tab. To avoid losing edits, ",
1255 "it cannot be opened here until the locking ",
1256 "tab is closed."
1257 );
1258 return;
1259 }
1260 }
1261 setupEditReplyElement(ePost, eBtnEdit);
1262 eBtnEdit.innerText = "Editing...";
1263 fetchPost(fpid)
1264 .then(artifact=>{
1265 const ondone = (fpe, response)=>{
1266 /* onsubmit() and ondiscard() callback */
1267 if( response/*onsubmit()*/ ){
1268 if( fpid === response.uuid
1269 && !response.statusModified
1270 && 0===response.attachedCount ){
1271 fpe.reportError("No changes made.");
1272 }else{
1273 restoreEditReplyElement(ePost, eBtnEdit);
1274 window.location = F.repoUrl('forumpost/'+response.uuid);
1275 setTimeout(()=>fpe.close(), 500/*just in case not redirected*/);
1276 }
1277 }else{
1278 /*ondiscard() or onclose()*/
1279 restoreEditReplyElement(ePost, eBtnEdit);
1280 }
1281 };
1282 const eStatusSelect = ePost.querySelector(
1283 ':scope > fieldset.forum-status-selection select[name=status]'
1284 );
1285
1286 const fpe = new F.ForumPostEditor(F.nu({
1287 hiddenFields: form.querySelectorAll('input[type=hidden]'),
1288 ondiscard: ()=>{/*need a noop here. Will call onclose()*/},
1289 onsubmit: ondone,
1290 onclose: ()=>{
1291 if(releaseLock){
1292 releaseLock();
1293 releaseLock = null;
1294 }
1295 ondone();
1296 },
1297 draftKey,
1298 edit: artifact,
1299 status: eStatusSelect?.value,
1300 inReplyTo: firt
1301 }));
1302 initFPEWidget(fpe, ePost);
1303 })
1304 .catch(err=>{
1305 if( releaseLock ){
1306 releaseLock();
1307 releaseLock = null;
1308 }
1309 restoreEditReplyElement(ePost, eBtnEdit);
1310 console.error("Error fetching post:", err);
1311 reportFPEError(ePost, "Error fetching post: ", err.message);
1312 });
1313 }/*editClicked()*/;
1314
1315 document.body.querySelectorAll(
1316 '.forumpost-single-controls > form'
1317 ).forEach(form=>{
1318 /* For each forum post... */
1319 const eThePost = form.parentElement.parentElement/*main post DOM element*/;
1320 if( !eThePost?.dataset?.fpid ){
1321 /* The server injects these dataset values. */
1322 console.warn("Unexpected missing fpid", eThePost);
1323 return;
1324 }
1325 const checkButtonForDraft = (draftKeyPrefix, eBtn)=>{
1326 /* If a draft is found associated with eThePost, mark eBtn
1327 as a draft and set up storage event listeners to update
1328 the button as new drafts come and go. */
1329 const fpid = eThePost.dataset.fpid;
1330 const fEditHead = eThePost.dataset.fedithead;
1331 const draftKey = makeDraftKey(draftKeyPrefix, fEditHead || fpid);
1332 if( F.storage.contains(draftKey) ){
1333 eBtn.classList.add('draft');
1334 }
1335 F.storage.addEventListener('set', ({detail})=>{
1336 if( draftKey === detail.key ){
1337 eBtn.classList.add('draft');
1338 }
1339 });
1340 F.storage.addEventListener('remove', ({detail})=>{
1341 if( draftKey === detail.key ){
1342 eBtn.classList.remove('draft');
1343 }
1344 });
1345 };
1346 /* Replace the Reply and Edit buttons with ones which will activate
1347 a ForumPostEditor. */
1348 const btnReply = form.querySelector('input[type=submit][name=reply]');
1349 if( btnReply ){
1350 const b = D.button("Reply", ()=>replyClicked(form, eThePost, b));
1351 b.type = 'button'/*keep container form from submitting*/;
1352 checkButtonForDraft('draft-reply',b);
1353 btnReply.parentElement.insertBefore(b, btnReply);
1354 btnReply.remove();
1355 }
1356 const btnEdit = form.querySelector('input[type=submit][name=edit]');
1357 if( btnEdit ){
1358 const b = D.button("Edit", ()=>editClicked(form, eThePost, b));
1359 b.type = 'button'/*keep container form from submitting*/;
1360 checkButtonForDraft('draft-forumedit',b);
1361 btnEdit.parentElement.insertBefore(b, btnEdit);
1362 btnEdit.remove();
1363 }
1364 })/*for-each form*/;
1365
1366 }/* /forumpost and /forumthread */
1367
1368 document.body.querySelectorAll('.remove-on-load').forEach(e=>e.remove());
1369 document.body.querySelectorAll('.initially-hidden').forEach(e=>{
1370 /* This is a workaround for a span.help-buttonlet which we need
1371 to start hidden so that it does not show up for no-JS
1372 clients. */
1373 e.classList.remove('initially-hidden');
1374 });
1375
1376 if( plugInEditor ){
1377 document.body.querySelectorAll('.remove-if-replaced').forEach(
1378 /* Remove remaining legacy UI elements. */ e=>e.remove()
1379 );
1380 /* Purge old drafts only every now and then. */
1381 const now = Date.now();
1382 const lastPurge = +F.storage.get('forum-drafts-last-purge', 0);
1383 if( now - lastPurge > (24 * 60 * 60 * 1000 /*1 day ms*/) ){
1384 F.storage.set('forum-drafts-last-purge', now);
1385 setTimeout(()=>{
1386 /* Don't block the UI while we're doing I/O */
1387 F.ForumPostEditor.purgeOldDrafts(
1388 /^draft-(reply|forumedit)-.*/
1389 /* Intentionally leaving draft-forumnew in place. */
1390 );
1391 }, 50);
1392 }
1393 }
1394 })/*F.onPageLoad callback*/;
1395 })(window.fossil);
1396
--- src/fossil.page.wikiedit.js
+++ src/fossil.page.wikiedit.js
@@ -1187,11 +1187,11 @@
11871187
if(!wi.attachments || !wi.attachments.length){
11881188
D.append(f.eAttach,
11891189
btnReload,
11901190
" No attachments found for page ["+wi.name+"]. ",
11911191
D.a(F.repoUrl('attachadd',{
1192
- page: wi.name,
1192
+ target: wi.name,
11931193
from: F.repoUrl('wikiedit',{name: wi.name})}),
11941194
"Add attachments..." )
11951195
);
11961196
return this;
11971197
}
@@ -1198,14 +1198,14 @@
11981198
D.append(
11991199
f.eAttach,
12001200
D.append(D.p(),
12011201
btnReload," ",
12021202
D.a(F.repoUrl('attachlist',{page:wi.name}),
1203
- "Attachments for page ["+wi.name+"]."),
1204
- " ",
1203
+ "Attachments for page ["+wi.name+"]"),
1204
+ ". ",
12051205
D.a(F.repoUrl('attachadd',{
1206
- page:wi.name,
1206
+ target:wi.name,
12071207
from: F.repoUrl('wikiedit',{name: wi.name})}),
12081208
"Add attachments..." )
12091209
)
12101210
);
12111211
wi.attachments.forEach(function(a){
@@ -1388,11 +1388,10 @@
13881388
setting.
13891389
*/
13901390
P.baseHrefRestore = function(){
13911391
this.base.tag.href = this.base.originalHref;
13921392
};
1393
-
13941393
13951394
/**
13961395
loadPage() loads the given wiki page and updates the relevant
13971396
UI elements to reflect the loaded state. If passed no arguments
13981397
then it re-uses the values from the currently-loaded page, reloading
@@ -1454,11 +1453,11 @@
14541453
onload(r);
14551454
}
14561455
});
14571456
return this;
14581457
};
1459
-
1458
+
14601459
/**
14611460
Fetches the page preview based on the contents and settings of
14621461
this page's input fields, and updates the UI with the
14631462
preview.
14641463
14651464
--- src/fossil.page.wikiedit.js
+++ src/fossil.page.wikiedit.js
@@ -1187,11 +1187,11 @@
1187 if(!wi.attachments || !wi.attachments.length){
1188 D.append(f.eAttach,
1189 btnReload,
1190 " No attachments found for page ["+wi.name+"]. ",
1191 D.a(F.repoUrl('attachadd',{
1192 page: wi.name,
1193 from: F.repoUrl('wikiedit',{name: wi.name})}),
1194 "Add attachments..." )
1195 );
1196 return this;
1197 }
@@ -1198,14 +1198,14 @@
1198 D.append(
1199 f.eAttach,
1200 D.append(D.p(),
1201 btnReload," ",
1202 D.a(F.repoUrl('attachlist',{page:wi.name}),
1203 "Attachments for page ["+wi.name+"]."),
1204 " ",
1205 D.a(F.repoUrl('attachadd',{
1206 page:wi.name,
1207 from: F.repoUrl('wikiedit',{name: wi.name})}),
1208 "Add attachments..." )
1209 )
1210 );
1211 wi.attachments.forEach(function(a){
@@ -1388,11 +1388,10 @@
1388 setting.
1389 */
1390 P.baseHrefRestore = function(){
1391 this.base.tag.href = this.base.originalHref;
1392 };
1393
1394
1395 /**
1396 loadPage() loads the given wiki page and updates the relevant
1397 UI elements to reflect the loaded state. If passed no arguments
1398 then it re-uses the values from the currently-loaded page, reloading
@@ -1454,11 +1453,11 @@
1454 onload(r);
1455 }
1456 });
1457 return this;
1458 };
1459
1460 /**
1461 Fetches the page preview based on the contents and settings of
1462 this page's input fields, and updates the UI with the
1463 preview.
1464
1465
--- src/fossil.page.wikiedit.js
+++ src/fossil.page.wikiedit.js
@@ -1187,11 +1187,11 @@
1187 if(!wi.attachments || !wi.attachments.length){
1188 D.append(f.eAttach,
1189 btnReload,
1190 " No attachments found for page ["+wi.name+"]. ",
1191 D.a(F.repoUrl('attachadd',{
1192 target: wi.name,
1193 from: F.repoUrl('wikiedit',{name: wi.name})}),
1194 "Add attachments..." )
1195 );
1196 return this;
1197 }
@@ -1198,14 +1198,14 @@
1198 D.append(
1199 f.eAttach,
1200 D.append(D.p(),
1201 btnReload," ",
1202 D.a(F.repoUrl('attachlist',{page:wi.name}),
1203 "Attachments for page ["+wi.name+"]"),
1204 ". ",
1205 D.a(F.repoUrl('attachadd',{
1206 target:wi.name,
1207 from: F.repoUrl('wikiedit',{name: wi.name})}),
1208 "Add attachments..." )
1209 )
1210 );
1211 wi.attachments.forEach(function(a){
@@ -1388,11 +1388,10 @@
1388 setting.
1389 */
1390 P.baseHrefRestore = function(){
1391 this.base.tag.href = this.base.originalHref;
1392 };
 
1393
1394 /**
1395 loadPage() loads the given wiki page and updates the relevant
1396 UI elements to reflect the loaded state. If passed no arguments
1397 then it re-uses the values from the currently-loaded page, reloading
@@ -1454,11 +1453,11 @@
1453 onload(r);
1454 }
1455 });
1456 return this;
1457 };
1458
1459 /**
1460 Fetches the page preview based on the contents and settings of
1461 this page's input fields, and updates the UI with the
1462 preview.
1463
1464
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -55,11 +55,11 @@
5555
from the default), the class "fossil-PopupWidget" is always set
5656
in order to allow certain app-internal CSS to account for popup
5757
windows in special cases.
5858
5959
.style: optional object of properties to copy directly into
60
- the element's style object.
60
+ the element's style object.
6161
6262
The options passed to this constructor get normalized into a
6363
separate object which includes any default values for options not
6464
provided by the caller. That object is available this the
6565
resulting PopupWidget's options property. Default values for any
@@ -161,11 +161,11 @@
161161
Sidebar: showing/hiding the widget is, as is conventional for
162162
this framework, done by removing/adding the 'hidden' CSS class
163163
to it, so that class must be defined appropriately.
164164
*/
165165
show: function(){
166
- var x = undefined, y = undefined, showIt,
166
+ let x = undefined, y = undefined, showIt,
167167
wasShown = !this.e.classList.contains('hidden');
168168
if(2===arguments.length){
169169
x = arguments[0];
170170
y = arguments[1];
171171
showIt = true;
@@ -344,11 +344,12 @@
344344
- No arguments, which is equivalent to passing the string
345345
".help-buttonlet:not(.processed)".
346346
347347
Passing the same element(s) more than once is a no-op: during
348348
initialization, each elements get the class'processed' added to
349
- it, and any elements with that class are skipped.
349
+ it, and any elements with that class are skipped. Each element
350
+ gets the 'help-buttonlet' CSS class added to it.
350351
351352
All child nodes of a help buttonlet are removed from the button
352353
during initialization and stashed away for use in a PopupWidget
353354
when the botton is clicked.
354355
@@ -374,24 +375,24 @@
374375
calculate the resulting size, then move and/or resize it.
375376
376377
This algorithm/these heuristics can certainly be improved
377378
upon.
378379
*/
379
- var popupRect, rectElem = ev.target;
380
+ let popupRect, rectElem = ev.target;
380381
while(rectElem){
381382
popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
382383
if(popupRect) break;
383384
rectElem = rectElem.parentNode;
384385
}
385386
if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
386
- var x = popupRect.left, y = popupRect.top;
387
+ let x = popupRect.left, y = popupRect.top;
387388
if(x<0) x = 0;
388389
if(y<0) y = 0;
389390
if(rectElem){
390391
/* Try to ensure that the popup's z-level is higher than this element's */
391392
const rz = window.getComputedStyle(rectElem).zIndex;
392
- var myZ;
393
+ let myZ;
393394
if(rz && !isNaN(+rz)){
394395
myZ = +rz + 1;
395396
}else{
396397
myZ = 10000/*guess!*/;
397398
}
@@ -416,11 +417,11 @@
416417
fch.popup.show(x, y);
417418
return false;
418419
};
419420
f.foreachElement = function(e){
420421
if(e.classList.contains('processed')) return;
421
- e.classList.add('processed');
422
+ e.classList.add('processed', 'help-buttonlet');
422423
e.$helpContent = [];
423424
/* We have to move all child nodes out of the way because we
424425
cannot hide TEXT nodes via CSS (which cannot select TEXT
425426
nodes). We have to do it in two steps to avoid invaliding
426427
the list during traversal. */
@@ -427,11 +428,11 @@
427428
e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
428429
e.$helpContent.forEach((ch)=>ch.remove());
429430
e.addEventListener('click', f.clickHandler, false);
430431
};
431432
}/*static init*/
432
- var elems;
433
+ let elems;
433434
if(!arguments.length){
434435
arguments[0] = '.help-buttonlet:not(.processed)';
435436
arguments.length = 1;
436437
}
437438
if(arguments.length){
@@ -443,11 +444,11 @@
443444
elems = arguments[0];
444445
}
445446
}
446447
if(elems) elems.forEach(f.foreachElement);
447448
},
448
-
449
+
449450
/**
450451
Sets up the given element as a "help buttonlet", adding the CSS
451452
class help-buttonlet to it. Any (optional) arguments after the
452453
first are appended to the element using fossil.dom.append(), so
453454
that they become the content for the buttonlet's popup help.
@@ -465,7 +466,6 @@
465466
return elem;
466467
}
467468
}/*helpButtonlets*/;
468469
469470
F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
470
-
471471
})(window.fossil);
472472
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -55,11 +55,11 @@
55 from the default), the class "fossil-PopupWidget" is always set
56 in order to allow certain app-internal CSS to account for popup
57 windows in special cases.
58
59 .style: optional object of properties to copy directly into
60 the element's style object.
61
62 The options passed to this constructor get normalized into a
63 separate object which includes any default values for options not
64 provided by the caller. That object is available this the
65 resulting PopupWidget's options property. Default values for any
@@ -161,11 +161,11 @@
161 Sidebar: showing/hiding the widget is, as is conventional for
162 this framework, done by removing/adding the 'hidden' CSS class
163 to it, so that class must be defined appropriately.
164 */
165 show: function(){
166 var x = undefined, y = undefined, showIt,
167 wasShown = !this.e.classList.contains('hidden');
168 if(2===arguments.length){
169 x = arguments[0];
170 y = arguments[1];
171 showIt = true;
@@ -344,11 +344,12 @@
344 - No arguments, which is equivalent to passing the string
345 ".help-buttonlet:not(.processed)".
346
347 Passing the same element(s) more than once is a no-op: during
348 initialization, each elements get the class'processed' added to
349 it, and any elements with that class are skipped.
 
350
351 All child nodes of a help buttonlet are removed from the button
352 during initialization and stashed away for use in a PopupWidget
353 when the botton is clicked.
354
@@ -374,24 +375,24 @@
374 calculate the resulting size, then move and/or resize it.
375
376 This algorithm/these heuristics can certainly be improved
377 upon.
378 */
379 var popupRect, rectElem = ev.target;
380 while(rectElem){
381 popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
382 if(popupRect) break;
383 rectElem = rectElem.parentNode;
384 }
385 if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
386 var x = popupRect.left, y = popupRect.top;
387 if(x<0) x = 0;
388 if(y<0) y = 0;
389 if(rectElem){
390 /* Try to ensure that the popup's z-level is higher than this element's */
391 const rz = window.getComputedStyle(rectElem).zIndex;
392 var myZ;
393 if(rz && !isNaN(+rz)){
394 myZ = +rz + 1;
395 }else{
396 myZ = 10000/*guess!*/;
397 }
@@ -416,11 +417,11 @@
416 fch.popup.show(x, y);
417 return false;
418 };
419 f.foreachElement = function(e){
420 if(e.classList.contains('processed')) return;
421 e.classList.add('processed');
422 e.$helpContent = [];
423 /* We have to move all child nodes out of the way because we
424 cannot hide TEXT nodes via CSS (which cannot select TEXT
425 nodes). We have to do it in two steps to avoid invaliding
426 the list during traversal. */
@@ -427,11 +428,11 @@
427 e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
428 e.$helpContent.forEach((ch)=>ch.remove());
429 e.addEventListener('click', f.clickHandler, false);
430 };
431 }/*static init*/
432 var elems;
433 if(!arguments.length){
434 arguments[0] = '.help-buttonlet:not(.processed)';
435 arguments.length = 1;
436 }
437 if(arguments.length){
@@ -443,11 +444,11 @@
443 elems = arguments[0];
444 }
445 }
446 if(elems) elems.forEach(f.foreachElement);
447 },
448
449 /**
450 Sets up the given element as a "help buttonlet", adding the CSS
451 class help-buttonlet to it. Any (optional) arguments after the
452 first are appended to the element using fossil.dom.append(), so
453 that they become the content for the buttonlet's popup help.
@@ -465,7 +466,6 @@
465 return elem;
466 }
467 }/*helpButtonlets*/;
468
469 F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
470
471 })(window.fossil);
472
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -55,11 +55,11 @@
55 from the default), the class "fossil-PopupWidget" is always set
56 in order to allow certain app-internal CSS to account for popup
57 windows in special cases.
58
59 .style: optional object of properties to copy directly into
60 the element's style object.
61
62 The options passed to this constructor get normalized into a
63 separate object which includes any default values for options not
64 provided by the caller. That object is available this the
65 resulting PopupWidget's options property. Default values for any
@@ -161,11 +161,11 @@
161 Sidebar: showing/hiding the widget is, as is conventional for
162 this framework, done by removing/adding the 'hidden' CSS class
163 to it, so that class must be defined appropriately.
164 */
165 show: function(){
166 let x = undefined, y = undefined, showIt,
167 wasShown = !this.e.classList.contains('hidden');
168 if(2===arguments.length){
169 x = arguments[0];
170 y = arguments[1];
171 showIt = true;
@@ -344,11 +344,12 @@
344 - No arguments, which is equivalent to passing the string
345 ".help-buttonlet:not(.processed)".
346
347 Passing the same element(s) more than once is a no-op: during
348 initialization, each elements get the class'processed' added to
349 it, and any elements with that class are skipped. Each element
350 gets the 'help-buttonlet' CSS class added to it.
351
352 All child nodes of a help buttonlet are removed from the button
353 during initialization and stashed away for use in a PopupWidget
354 when the botton is clicked.
355
@@ -374,24 +375,24 @@
375 calculate the resulting size, then move and/or resize it.
376
377 This algorithm/these heuristics can certainly be improved
378 upon.
379 */
380 let popupRect, rectElem = ev.target;
381 while(rectElem){
382 popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/;
383 if(popupRect) break;
384 rectElem = rectElem.parentNode;
385 }
386 if(!popupRect) popupRect = {x:0, y:0, left:0, right:0};
387 let x = popupRect.left, y = popupRect.top;
388 if(x<0) x = 0;
389 if(y<0) y = 0;
390 if(rectElem){
391 /* Try to ensure that the popup's z-level is higher than this element's */
392 const rz = window.getComputedStyle(rectElem).zIndex;
393 let myZ;
394 if(rz && !isNaN(+rz)){
395 myZ = +rz + 1;
396 }else{
397 myZ = 10000/*guess!*/;
398 }
@@ -416,11 +417,11 @@
417 fch.popup.show(x, y);
418 return false;
419 };
420 f.foreachElement = function(e){
421 if(e.classList.contains('processed')) return;
422 e.classList.add('processed', 'help-buttonlet');
423 e.$helpContent = [];
424 /* We have to move all child nodes out of the way because we
425 cannot hide TEXT nodes via CSS (which cannot select TEXT
426 nodes). We have to do it in two steps to avoid invaliding
427 the list during traversal. */
@@ -427,11 +428,11 @@
428 e.childNodes.forEach((ch)=>e.$helpContent.push(ch));
429 e.$helpContent.forEach((ch)=>ch.remove());
430 e.addEventListener('click', f.clickHandler, false);
431 };
432 }/*static init*/
433 let elems;
434 if(!arguments.length){
435 arguments[0] = '.help-buttonlet:not(.processed)';
436 arguments.length = 1;
437 }
438 if(arguments.length){
@@ -443,11 +444,11 @@
444 elems = arguments[0];
445 }
446 }
447 if(elems) elems.forEach(f.foreachElement);
448 },
449
450 /**
451 Sets up the given element as a "help buttonlet", adding the CSS
452 class help-buttonlet to it. Any (optional) arguments after the
453 first are appended to the element using fossil.dom.append(), so
454 that they become the content for the buttonlet's popup help.
@@ -465,7 +466,6 @@
466 return elem;
467 }
468 }/*helpButtonlets*/;
469
470 F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() );
 
471 })(window.fossil);
472
--- src/fossil.storage.js
+++ src/fossil.storage.js
@@ -82,24 +82,53 @@
8282
)+'::' : (
8383
'' /* transient storage */
8484
)
8585
);
8686
87
+ /**
88
+ Proxy for custom events. Created on demand.
89
+ */
90
+ let events;
8791
/**
8892
A proxy for localStorage or sessionStorage or a
8993
page-instance-local proxy, if neither one is availble.
9094
9195
Which exact storage implementation is uses is unspecified, and
9296
apps must not rely on it.
9397
*/
9498
F.storage = {
9599
storageKeyPrefix: storageKeyPrefix,
100
+ addEventListener(...args){
101
+ events ??= new EventTarget()
102
+ return events.addEventListener(...args);
103
+ },
104
+ removeEventListener(...args){
105
+ events ??= new EventTarget()
106
+ return events.removeEventListener(...args);
107
+ },
96108
/** Sets the storage key k to value v, implicitly converting
97
- it to a string. */
98
- set: (k,v)=>$storage.setItem(storageKeyPrefix+k,v),
109
+ it to a string.
110
+
111
+ Fires a 'set' CustomEvent with a detail value in the form
112
+ {key, value} with the new value.
113
+ */
114
+ set: (k,v)=>{
115
+ $storage.setItem(storageKeyPrefix+k,v);
116
+ if( events ){
117
+ events.dispatchEvent(
118
+ new CustomEvent('set',{
119
+ detail: F.nu({
120
+ key: k, value: v
121
+ })
122
+ })
123
+ );
124
+ }
125
+ },
99126
/** Sets storage key k to JSON.stringify(v). */
100
- setJSON: (k,v)=>$storage.setItem(storageKeyPrefix+k,JSON.stringify(v)),
127
+ setJSON: function(k,v){
128
+ return this.set(k,JSON.stringify(v));
129
+ },
101130
/** Returns the value for the given storage key, or
102131
dflt if the key is not found in the storage. */
103132
get: (k,dflt)=>$storageHolder.hasOwnProperty(
104133
storageKeyPrefix+k
105134
) ? $storage.getItem(storageKeyPrefix+k) : dflt,
@@ -121,22 +150,48 @@
121150
catch(e){return dflt}
122151
},
123152
/** Returns true if the storage contains the given key,
124153
else false. */
125154
contains: (k)=>$storageHolder.hasOwnProperty(storageKeyPrefix+k),
126
- /** Removes the given key from the storage. Returns this. */
155
+ /**
156
+ Removes the given key from the storage. Returns this.
157
+
158
+ Fires a 'remove' CustomEvent with a detail value in the form
159
+ {key}.
160
+ */
127161
remove: function(k){
128
- $storage.removeItem(storageKeyPrefix+k);
162
+ const kk = storageKeyPrefix+k;
163
+ if( events ){
164
+ const had = $storageHolder.hasOwnProperty(kk)
165
+ $storage.removeItem(kk);
166
+ if( had ){
167
+ events.dispatchEvent(
168
+ new CustomEvent('remove',{
169
+ detail: F.nu({key: k})
170
+ })
171
+ );
172
+ }
173
+ }else{
174
+ $storage.removeItem(kk);
175
+ }
129176
return this;
130177
},
131178
/** Clears ALL keys from the storage. Returns this. */
132179
clear: function(){
133180
this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k));
134181
return this;
135182
},
136
- /** Returns an array of all keys currently in the storage. */
137
- keys: ()=>Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix)),
183
+ /** Returns an array of all keys currently in the storage. If full
184
+ is true then the keys include the storage key prefix, else
185
+ they don't. It should default to false but does not for
186
+ historical compatibility. */
187
+ keys: function(full=true){
188
+ const li = Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix));
189
+ if( full ) return li;
190
+ const n = this.storageKeyPrefix.length;
191
+ return li.map(v=>v.substring(n));
192
+ },
138193
/** Returns true if this storage is transient (only available
139194
until the page is reloaded), indicating that fileStorage
140195
and sessionStorage are unavailable. */
141196
isTransient: ()=>$storageHolder!==$storage,
142197
/** Returns a symbolic name for the current storage mechanism. */
143198
--- src/fossil.storage.js
+++ src/fossil.storage.js
@@ -82,24 +82,53 @@
82 )+'::' : (
83 '' /* transient storage */
84 )
85 );
86
 
 
 
 
87 /**
88 A proxy for localStorage or sessionStorage or a
89 page-instance-local proxy, if neither one is availble.
90
91 Which exact storage implementation is uses is unspecified, and
92 apps must not rely on it.
93 */
94 F.storage = {
95 storageKeyPrefix: storageKeyPrefix,
 
 
 
 
 
 
 
 
96 /** Sets the storage key k to value v, implicitly converting
97 it to a string. */
98 set: (k,v)=>$storage.setItem(storageKeyPrefix+k,v),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99 /** Sets storage key k to JSON.stringify(v). */
100 setJSON: (k,v)=>$storage.setItem(storageKeyPrefix+k,JSON.stringify(v)),
 
 
101 /** Returns the value for the given storage key, or
102 dflt if the key is not found in the storage. */
103 get: (k,dflt)=>$storageHolder.hasOwnProperty(
104 storageKeyPrefix+k
105 ) ? $storage.getItem(storageKeyPrefix+k) : dflt,
@@ -121,22 +150,48 @@
121 catch(e){return dflt}
122 },
123 /** Returns true if the storage contains the given key,
124 else false. */
125 contains: (k)=>$storageHolder.hasOwnProperty(storageKeyPrefix+k),
126 /** Removes the given key from the storage. Returns this. */
 
 
 
 
 
127 remove: function(k){
128 $storage.removeItem(storageKeyPrefix+k);
 
 
 
 
 
 
 
 
 
 
 
 
 
129 return this;
130 },
131 /** Clears ALL keys from the storage. Returns this. */
132 clear: function(){
133 this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k));
134 return this;
135 },
136 /** Returns an array of all keys currently in the storage. */
137 keys: ()=>Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix)),
 
 
 
 
 
 
 
 
138 /** Returns true if this storage is transient (only available
139 until the page is reloaded), indicating that fileStorage
140 and sessionStorage are unavailable. */
141 isTransient: ()=>$storageHolder!==$storage,
142 /** Returns a symbolic name for the current storage mechanism. */
143
--- src/fossil.storage.js
+++ src/fossil.storage.js
@@ -82,24 +82,53 @@
82 )+'::' : (
83 '' /* transient storage */
84 )
85 );
86
87 /**
88 Proxy for custom events. Created on demand.
89 */
90 let events;
91 /**
92 A proxy for localStorage or sessionStorage or a
93 page-instance-local proxy, if neither one is availble.
94
95 Which exact storage implementation is uses is unspecified, and
96 apps must not rely on it.
97 */
98 F.storage = {
99 storageKeyPrefix: storageKeyPrefix,
100 addEventListener(...args){
101 events ??= new EventTarget()
102 return events.addEventListener(...args);
103 },
104 removeEventListener(...args){
105 events ??= new EventTarget()
106 return events.removeEventListener(...args);
107 },
108 /** Sets the storage key k to value v, implicitly converting
109 it to a string.
110
111 Fires a 'set' CustomEvent with a detail value in the form
112 {key, value} with the new value.
113 */
114 set: (k,v)=>{
115 $storage.setItem(storageKeyPrefix+k,v);
116 if( events ){
117 events.dispatchEvent(
118 new CustomEvent('set',{
119 detail: F.nu({
120 key: k, value: v
121 })
122 })
123 );
124 }
125 },
126 /** Sets storage key k to JSON.stringify(v). */
127 setJSON: function(k,v){
128 return this.set(k,JSON.stringify(v));
129 },
130 /** Returns the value for the given storage key, or
131 dflt if the key is not found in the storage. */
132 get: (k,dflt)=>$storageHolder.hasOwnProperty(
133 storageKeyPrefix+k
134 ) ? $storage.getItem(storageKeyPrefix+k) : dflt,
@@ -121,22 +150,48 @@
150 catch(e){return dflt}
151 },
152 /** Returns true if the storage contains the given key,
153 else false. */
154 contains: (k)=>$storageHolder.hasOwnProperty(storageKeyPrefix+k),
155 /**
156 Removes the given key from the storage. Returns this.
157
158 Fires a 'remove' CustomEvent with a detail value in the form
159 {key}.
160 */
161 remove: function(k){
162 const kk = storageKeyPrefix+k;
163 if( events ){
164 const had = $storageHolder.hasOwnProperty(kk)
165 $storage.removeItem(kk);
166 if( had ){
167 events.dispatchEvent(
168 new CustomEvent('remove',{
169 detail: F.nu({key: k})
170 })
171 );
172 }
173 }else{
174 $storage.removeItem(kk);
175 }
176 return this;
177 },
178 /** Clears ALL keys from the storage. Returns this. */
179 clear: function(){
180 this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k));
181 return this;
182 },
183 /** Returns an array of all keys currently in the storage. If full
184 is true then the keys include the storage key prefix, else
185 they don't. It should default to false but does not for
186 historical compatibility. */
187 keys: function(full=true){
188 const li = Object.keys($storageHolder).filter((v)=>(v||'').startsWith(storageKeyPrefix));
189 if( full ) return li;
190 const n = this.storageKeyPrefix.length;
191 return li.map(v=>v.substring(n));
192 },
193 /** Returns true if this storage is transient (only available
194 until the page is reloaded), indicating that fileStorage
195 and sessionStorage are unavailable. */
196 isTransient: ()=>$storageHolder!==$storage,
197 /** Returns a symbolic name for the current storage mechanism. */
198
--- src/fossil.tabs.js
+++ src/fossil.tabs.js
@@ -7,11 +7,11 @@
77
/**
88
Creates a TabManager. If passed a truthy first argument, it is
99
passed to init(). If passed a truthy second argument, it must be
1010
an Object holding configuration options:
1111
12
- {
12
+ {
1313
tabAccessKeys: boolean (=true)
1414
If true, tab buttons are assigned "accesskey" values
1515
equal to their 1-based tab number.
1616
}
1717
*/
@@ -56,11 +56,11 @@
5656
/**
5757
Initializes the tabs associated with the given tab container
5858
(DOM element or selector for a single element). This must be
5959
called once before using any other member functions of a given
6060
instance, noting that the constructor will call this if it is
61
- passed an argument.
61
+ passed an argument.
6262
6363
The tab container must have an 'id' attribute. This function
6464
looks through the DOM for all elements which have
6565
data-tab-parent=thatId. For each one it creates a button to
6666
switch to that tab and moves the element into this.e.tabs,
@@ -150,10 +150,11 @@
150150
e.target.$manager.switchToTab(e.target.$tab);
151151
};
152152
}
153153
tab = tabArg(tab);
154154
tab.remove();
155
+ tab.classList.add('hidden');
155156
D.append(this.e.tabs, D.addClass(tab,'tab-panel'));
156157
const tabCount = this.e.tabBar.childNodes.length+1;
157158
const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount;
158159
const btn = D.addClass(D.append(D.span(), lbl), 'tab-button');
159160
D.append(this.e.tabBar,btn);
160161
--- src/fossil.tabs.js
+++ src/fossil.tabs.js
@@ -7,11 +7,11 @@
7 /**
8 Creates a TabManager. If passed a truthy first argument, it is
9 passed to init(). If passed a truthy second argument, it must be
10 an Object holding configuration options:
11
12 {
13 tabAccessKeys: boolean (=true)
14 If true, tab buttons are assigned "accesskey" values
15 equal to their 1-based tab number.
16 }
17 */
@@ -56,11 +56,11 @@
56 /**
57 Initializes the tabs associated with the given tab container
58 (DOM element or selector for a single element). This must be
59 called once before using any other member functions of a given
60 instance, noting that the constructor will call this if it is
61 passed an argument.
62
63 The tab container must have an 'id' attribute. This function
64 looks through the DOM for all elements which have
65 data-tab-parent=thatId. For each one it creates a button to
66 switch to that tab and moves the element into this.e.tabs,
@@ -150,10 +150,11 @@
150 e.target.$manager.switchToTab(e.target.$tab);
151 };
152 }
153 tab = tabArg(tab);
154 tab.remove();
 
155 D.append(this.e.tabs, D.addClass(tab,'tab-panel'));
156 const tabCount = this.e.tabBar.childNodes.length+1;
157 const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount;
158 const btn = D.addClass(D.append(D.span(), lbl), 'tab-button');
159 D.append(this.e.tabBar,btn);
160
--- src/fossil.tabs.js
+++ src/fossil.tabs.js
@@ -7,11 +7,11 @@
7 /**
8 Creates a TabManager. If passed a truthy first argument, it is
9 passed to init(). If passed a truthy second argument, it must be
10 an Object holding configuration options:
11
12 {
13 tabAccessKeys: boolean (=true)
14 If true, tab buttons are assigned "accesskey" values
15 equal to their 1-based tab number.
16 }
17 */
@@ -56,11 +56,11 @@
56 /**
57 Initializes the tabs associated with the given tab container
58 (DOM element or selector for a single element). This must be
59 called once before using any other member functions of a given
60 instance, noting that the constructor will call this if it is
61 passed an argument.
62
63 The tab container must have an 'id' attribute. This function
64 looks through the DOM for all elements which have
65 data-tab-parent=thatId. For each one it creates a button to
66 switch to that tab and moves the element into this.e.tabs,
@@ -150,10 +150,11 @@
150 e.target.$manager.switchToTab(e.target.$tab);
151 };
152 }
153 tab = tabArg(tab);
154 tab.remove();
155 tab.classList.add('hidden');
156 D.append(this.e.tabs, D.addClass(tab,'tab-panel'));
157 const tabCount = this.e.tabBar.childNodes.length+1;
158 const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount;
159 const btn = D.addClass(D.append(D.span(), lbl), 'tab-button');
160 D.append(this.e.tabBar,btn);
161
+6 -2
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
19311931
@ Also attachment "%h(zFilename)" to
19321932
}else{
19331933
@ Attachment "%h(zFilename)" to
19341934
}
19351935
objType |= OBJTYPE_ATTACHMENT;
1936
- switch( attachment_target_type(zTarget) ){
1936
+ switch( attachment_target_type(zTarget, 1) ){
19371937
case CFTYPE_FORUM:
19381938
if( g.perm.Hyperlink && g.anon.RdForum ){
19391939
@ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
19401940
}else{
19411941
@ forum post [%S(zTarget)]
@@ -1954,16 +1954,20 @@
19541954
}else{
19551955
@ tech note [%S(zTarget)]
19561956
}
19571957
break;
19581958
case CFTYPE_WIKI:
1959
- default /* historical behavior - assume wiki */:
19601959
if( g.perm.Hyperlink && g.anon.RdWiki ){
19611960
@ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>]
19621961
}else{
19631962
@ wiki page [%h(zTarget)]
19641963
}
1964
+ break;
1965
+ default:
1966
+ /* historical behavior is to assume wiki, but we can end up
1967
+ ** showing bogus links that way to stale attachments. */
1968
+ @ unknown artifact %h(zTarget)
19651969
}
19661970
@ added by
19671971
hyperlink_to_user(zUser,zDate," on");
19681972
hyperlink_to_date(zDate,".");
19691973
cnt++;
19701974
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
1931 @ Also attachment "%h(zFilename)" to
1932 }else{
1933 @ Attachment "%h(zFilename)" to
1934 }
1935 objType |= OBJTYPE_ATTACHMENT;
1936 switch( attachment_target_type(zTarget) ){
1937 case CFTYPE_FORUM:
1938 if( g.perm.Hyperlink && g.anon.RdForum ){
1939 @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
1940 }else{
1941 @ forum post [%S(zTarget)]
@@ -1954,16 +1954,20 @@
1954 }else{
1955 @ tech note [%S(zTarget)]
1956 }
1957 break;
1958 case CFTYPE_WIKI:
1959 default /* historical behavior - assume wiki */:
1960 if( g.perm.Hyperlink && g.anon.RdWiki ){
1961 @ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>]
1962 }else{
1963 @ wiki page [%h(zTarget)]
1964 }
 
 
 
 
 
1965 }
1966 @ added by
1967 hyperlink_to_user(zUser,zDate," on");
1968 hyperlink_to_date(zDate,".");
1969 cnt++;
1970
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
1931 @ Also attachment "%h(zFilename)" to
1932 }else{
1933 @ Attachment "%h(zFilename)" to
1934 }
1935 objType |= OBJTYPE_ATTACHMENT;
1936 switch( attachment_target_type(zTarget, 1) ){
1937 case CFTYPE_FORUM:
1938 if( g.perm.Hyperlink && g.anon.RdForum ){
1939 @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
1940 }else{
1941 @ forum post [%S(zTarget)]
@@ -1954,16 +1954,20 @@
1954 }else{
1955 @ tech note [%S(zTarget)]
1956 }
1957 break;
1958 case CFTYPE_WIKI:
 
1959 if( g.perm.Hyperlink && g.anon.RdWiki ){
1960 @ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>]
1961 }else{
1962 @ wiki page [%h(zTarget)]
1963 }
1964 break;
1965 default:
1966 /* historical behavior is to assume wiki, but we can end up
1967 ** showing bogus links that way to stale attachments. */
1968 @ unknown artifact %h(zTarget)
1969 }
1970 @ added by
1971 hyperlink_to_user(zUser,zDate," on");
1972 hyperlink_to_date(zDate,".");
1973 cnt++;
1974
--- src/main.mk
+++ src/main.mk
@@ -225,10 +225,11 @@
225225
$(SRCDIR)/copybtn.js \
226226
$(SRCDIR)/default.css \
227227
$(SRCDIR)/diff.js \
228228
$(SRCDIR)/diff.tcl \
229229
$(SRCDIR)/forum.js \
230
+ $(SRCDIR)/fossil.attach.js \
230231
$(SRCDIR)/fossil.bootstrap.js \
231232
$(SRCDIR)/fossil.confirmer.js \
232233
$(SRCDIR)/fossil.copybutton.js \
233234
$(SRCDIR)/fossil.diff.js \
234235
$(SRCDIR)/fossil.dom.js \
@@ -275,10 +276,11 @@
275276
$(SRCDIR)/sounds/e.wav \
276277
$(SRCDIR)/sounds/f.wav \
277278
$(SRCDIR)/style.admin_log.css \
278279
$(SRCDIR)/style.chat.css \
279280
$(SRCDIR)/style.fileedit.css \
281
+ $(SRCDIR)/style.forum.css \
280282
$(SRCDIR)/style.pikchrshow.css \
281283
$(SRCDIR)/style.uvlist.css \
282284
$(SRCDIR)/style.wikiedit.css \
283285
$(SRCDIR)/tree.js \
284286
$(SRCDIR)/useredit.js \
285287
--- src/main.mk
+++ src/main.mk
@@ -225,10 +225,11 @@
225 $(SRCDIR)/copybtn.js \
226 $(SRCDIR)/default.css \
227 $(SRCDIR)/diff.js \
228 $(SRCDIR)/diff.tcl \
229 $(SRCDIR)/forum.js \
 
230 $(SRCDIR)/fossil.bootstrap.js \
231 $(SRCDIR)/fossil.confirmer.js \
232 $(SRCDIR)/fossil.copybutton.js \
233 $(SRCDIR)/fossil.diff.js \
234 $(SRCDIR)/fossil.dom.js \
@@ -275,10 +276,11 @@
275 $(SRCDIR)/sounds/e.wav \
276 $(SRCDIR)/sounds/f.wav \
277 $(SRCDIR)/style.admin_log.css \
278 $(SRCDIR)/style.chat.css \
279 $(SRCDIR)/style.fileedit.css \
 
280 $(SRCDIR)/style.pikchrshow.css \
281 $(SRCDIR)/style.uvlist.css \
282 $(SRCDIR)/style.wikiedit.css \
283 $(SRCDIR)/tree.js \
284 $(SRCDIR)/useredit.js \
285
--- src/main.mk
+++ src/main.mk
@@ -225,10 +225,11 @@
225 $(SRCDIR)/copybtn.js \
226 $(SRCDIR)/default.css \
227 $(SRCDIR)/diff.js \
228 $(SRCDIR)/diff.tcl \
229 $(SRCDIR)/forum.js \
230 $(SRCDIR)/fossil.attach.js \
231 $(SRCDIR)/fossil.bootstrap.js \
232 $(SRCDIR)/fossil.confirmer.js \
233 $(SRCDIR)/fossil.copybutton.js \
234 $(SRCDIR)/fossil.diff.js \
235 $(SRCDIR)/fossil.dom.js \
@@ -275,10 +276,11 @@
276 $(SRCDIR)/sounds/e.wav \
277 $(SRCDIR)/sounds/f.wav \
278 $(SRCDIR)/style.admin_log.css \
279 $(SRCDIR)/style.chat.css \
280 $(SRCDIR)/style.fileedit.css \
281 $(SRCDIR)/style.forum.css \
282 $(SRCDIR)/style.pikchrshow.css \
283 $(SRCDIR)/style.uvlist.css \
284 $(SRCDIR)/style.wikiedit.css \
285 $(SRCDIR)/tree.js \
286 $(SRCDIR)/useredit.js \
287
+1 -1
--- src/manifest.c
+++ src/manifest.c
@@ -2646,11 +2646,11 @@
26462646
" WHERE target=%Q AND filename=%Q))"
26472647
" WHERE target=%Q AND filename=%Q",
26482648
p->zAttachTarget, p->zAttachName,
26492649
p->zAttachTarget, p->zAttachName
26502650
);
2651
- switch( attachment_target_type(p->zAttachTarget) ){
2651
+ switch( attachment_target_type(p->zAttachTarget, 1) ){
26522652
case 0:
26532653
/* It is possible that p->zAttachTarget is not yet in this
26542654
** copy of the repository. If we cannot identify it yet,
26552655
** generate a generic /artifact link to it instead of a
26562656
** type-specific link or an error message. */
26572657
--- src/manifest.c
+++ src/manifest.c
@@ -2646,11 +2646,11 @@
2646 " WHERE target=%Q AND filename=%Q))"
2647 " WHERE target=%Q AND filename=%Q",
2648 p->zAttachTarget, p->zAttachName,
2649 p->zAttachTarget, p->zAttachName
2650 );
2651 switch( attachment_target_type(p->zAttachTarget) ){
2652 case 0:
2653 /* It is possible that p->zAttachTarget is not yet in this
2654 ** copy of the repository. If we cannot identify it yet,
2655 ** generate a generic /artifact link to it instead of a
2656 ** type-specific link or an error message. */
2657
--- src/manifest.c
+++ src/manifest.c
@@ -2646,11 +2646,11 @@
2646 " WHERE target=%Q AND filename=%Q))"
2647 " WHERE target=%Q AND filename=%Q",
2648 p->zAttachTarget, p->zAttachName,
2649 p->zAttachTarget, p->zAttachName
2650 );
2651 switch( attachment_target_type(p->zAttachTarget, 1) ){
2652 case 0:
2653 /* It is possible that p->zAttachTarget is not yet in this
2654 ** copy of the repository. If we cannot identify it yet,
2655 ** generate a generic /artifact link to it instead of a
2656 ** type-specific link or an error message. */
2657
+22 -3
--- src/style.c
+++ src/style.c
@@ -384,10 +384,11 @@
384384
385385
/* Use this for the $current_page variable if it is not NULL. If it
386386
** is NULL then use g.zPath.
387387
*/
388388
static char *local_zCurrentPage = 0;
389
+static char *local_zCurrentFeature = 0;
389390
390391
/*
391392
** Set the desired $current_page to something other than g.zPath
392393
*/
393394
void style_set_current_page(const char *zFormat, ...){
@@ -419,12 +420,13 @@
419420
420421
/* Initialize the URL to its baseline */
421422
url = empty_blob;
422423
blob_appendf(&url, "%R/style.css");
423424
424
- /* If page-specific CSS exists for the current page, then append
425
- ** the pathname for the page-specific CSS. The default CSS is
425
+ /* If page- or feature-specific CSS exists for the current page,
426
+ ** then append the pathname for the page-specific CSS. The default
427
+ ** CSS is
426428
**
427429
** /style.css
428430
**
429431
** But for the "/wikiedit" page (to name but one example), we
430432
** append a path as follows:
@@ -432,14 +434,25 @@
432434
** /style.css/wikiedit
433435
**
434436
** The /style.css page (implemented below) will detect this extra "wikiedit"
435437
** path information and include the page-specific CSS along with the
436438
** default CSS when it delivers the page.
439
+ **
440
+ ** Prior to 2026-06-06, this only looked at zPage but /forum and
441
+ ** friends need a per-feature style, so it now falls back to
442
+ ** local_zCurrentFeature. The current mechanism cannot support both
443
+ ** concurrently in a single request.
437444
*/
438445
zBuiltin = mprintf("style.%s.css", zPage);
439446
if( builtin_file(zBuiltin,0)!=0 ){
440
- blob_appendf(&url, "/%s", zPage);
447
+ blob_appendf(&url, "/%t", zPage);
448
+ }else if( local_zCurrentFeature ){
449
+ fossil_free(zBuiltin);
450
+ zBuiltin = mprintf("style.%s.css", local_zCurrentFeature);
451
+ if( builtin_file(zBuiltin,0)!=0 ){
452
+ blob_appendf(&url, "/%t", local_zCurrentFeature);
453
+ }
441454
}
442455
fossil_free(zBuiltin);
443456
444457
/* Add query parameters that will change whenever the skin changes
445458
** or after any updates to the CSS files
@@ -727,10 +740,12 @@
727740
** style_init_th1_vars() because that uses Th_MaybeStore() instead to
728741
** allow webpage implementations to call this before style_header()
729742
** to override that "maybe" default with something better.
730743
*/
731744
void style_set_current_feature(const char* zFeature){
745
+ fossil_free( local_zCurrentFeature );
746
+ local_zCurrentFeature = fossil_strdup(zFeature);
732747
Th_Store("current_feature", zFeature);
733748
}
734749
735750
/*
736751
** Returns the current mainmenu value from either the --mainmenu flag
@@ -1258,10 +1273,11 @@
12581273
"** Page-specific CSS for \"%s\"\n"
12591274
"***********************************************************/\n",
12601275
zPage);
12611276
blob_append(pOut, zBuiltin, nFile);
12621277
fossil_free(zFile);
1278
+ zFile = 0;
12631279
return;
12641280
}
12651281
/* Potential TODO: check for aliases/page groups. e.g. group all
12661282
** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As
12671283
** of this writing, doing so would only shave a few kb from
@@ -1418,10 +1434,13 @@
14181434
** For administators, or if the test_env_enable setting is true, then
14191435
** details of the request environment are displayed. Otherwise, just
14201436
** the error message is shown.
14211437
**
14221438
** If zFormat is an empty string, then this is the /test-env page.
1439
+**
1440
+** If the resulting formatted error message is not empty then this
1441
+** function does not return.
14231442
*/
14241443
void webpage_error(const char *zFormat, ...){
14251444
int showAll = 0;
14261445
char *zErr = 0;
14271446
int isAuth = 0;
14281447
14291448
ADDED src/style.forum.css
--- src/style.c
+++ src/style.c
@@ -384,10 +384,11 @@
384
385 /* Use this for the $current_page variable if it is not NULL. If it
386 ** is NULL then use g.zPath.
387 */
388 static char *local_zCurrentPage = 0;
 
389
390 /*
391 ** Set the desired $current_page to something other than g.zPath
392 */
393 void style_set_current_page(const char *zFormat, ...){
@@ -419,12 +420,13 @@
419
420 /* Initialize the URL to its baseline */
421 url = empty_blob;
422 blob_appendf(&url, "%R/style.css");
423
424 /* If page-specific CSS exists for the current page, then append
425 ** the pathname for the page-specific CSS. The default CSS is
 
426 **
427 ** /style.css
428 **
429 ** But for the "/wikiedit" page (to name but one example), we
430 ** append a path as follows:
@@ -432,14 +434,25 @@
432 ** /style.css/wikiedit
433 **
434 ** The /style.css page (implemented below) will detect this extra "wikiedit"
435 ** path information and include the page-specific CSS along with the
436 ** default CSS when it delivers the page.
 
 
 
 
 
437 */
438 zBuiltin = mprintf("style.%s.css", zPage);
439 if( builtin_file(zBuiltin,0)!=0 ){
440 blob_appendf(&url, "/%s", zPage);
 
 
 
 
 
 
441 }
442 fossil_free(zBuiltin);
443
444 /* Add query parameters that will change whenever the skin changes
445 ** or after any updates to the CSS files
@@ -727,10 +740,12 @@
727 ** style_init_th1_vars() because that uses Th_MaybeStore() instead to
728 ** allow webpage implementations to call this before style_header()
729 ** to override that "maybe" default with something better.
730 */
731 void style_set_current_feature(const char* zFeature){
 
 
732 Th_Store("current_feature", zFeature);
733 }
734
735 /*
736 ** Returns the current mainmenu value from either the --mainmenu flag
@@ -1258,10 +1273,11 @@
1258 "** Page-specific CSS for \"%s\"\n"
1259 "***********************************************************/\n",
1260 zPage);
1261 blob_append(pOut, zBuiltin, nFile);
1262 fossil_free(zFile);
 
1263 return;
1264 }
1265 /* Potential TODO: check for aliases/page groups. e.g. group all
1266 ** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As
1267 ** of this writing, doing so would only shave a few kb from
@@ -1418,10 +1434,13 @@
1418 ** For administators, or if the test_env_enable setting is true, then
1419 ** details of the request environment are displayed. Otherwise, just
1420 ** the error message is shown.
1421 **
1422 ** If zFormat is an empty string, then this is the /test-env page.
 
 
 
1423 */
1424 void webpage_error(const char *zFormat, ...){
1425 int showAll = 0;
1426 char *zErr = 0;
1427 int isAuth = 0;
1428
1429 DDED src/style.forum.css
--- src/style.c
+++ src/style.c
@@ -384,10 +384,11 @@
384
385 /* Use this for the $current_page variable if it is not NULL. If it
386 ** is NULL then use g.zPath.
387 */
388 static char *local_zCurrentPage = 0;
389 static char *local_zCurrentFeature = 0;
390
391 /*
392 ** Set the desired $current_page to something other than g.zPath
393 */
394 void style_set_current_page(const char *zFormat, ...){
@@ -419,12 +420,13 @@
420
421 /* Initialize the URL to its baseline */
422 url = empty_blob;
423 blob_appendf(&url, "%R/style.css");
424
425 /* If page- or feature-specific CSS exists for the current page,
426 ** then append the pathname for the page-specific CSS. The default
427 ** CSS is
428 **
429 ** /style.css
430 **
431 ** But for the "/wikiedit" page (to name but one example), we
432 ** append a path as follows:
@@ -432,14 +434,25 @@
434 ** /style.css/wikiedit
435 **
436 ** The /style.css page (implemented below) will detect this extra "wikiedit"
437 ** path information and include the page-specific CSS along with the
438 ** default CSS when it delivers the page.
439 **
440 ** Prior to 2026-06-06, this only looked at zPage but /forum and
441 ** friends need a per-feature style, so it now falls back to
442 ** local_zCurrentFeature. The current mechanism cannot support both
443 ** concurrently in a single request.
444 */
445 zBuiltin = mprintf("style.%s.css", zPage);
446 if( builtin_file(zBuiltin,0)!=0 ){
447 blob_appendf(&url, "/%t", zPage);
448 }else if( local_zCurrentFeature ){
449 fossil_free(zBuiltin);
450 zBuiltin = mprintf("style.%s.css", local_zCurrentFeature);
451 if( builtin_file(zBuiltin,0)!=0 ){
452 blob_appendf(&url, "/%t", local_zCurrentFeature);
453 }
454 }
455 fossil_free(zBuiltin);
456
457 /* Add query parameters that will change whenever the skin changes
458 ** or after any updates to the CSS files
@@ -727,10 +740,12 @@
740 ** style_init_th1_vars() because that uses Th_MaybeStore() instead to
741 ** allow webpage implementations to call this before style_header()
742 ** to override that "maybe" default with something better.
743 */
744 void style_set_current_feature(const char* zFeature){
745 fossil_free( local_zCurrentFeature );
746 local_zCurrentFeature = fossil_strdup(zFeature);
747 Th_Store("current_feature", zFeature);
748 }
749
750 /*
751 ** Returns the current mainmenu value from either the --mainmenu flag
@@ -1258,10 +1273,11 @@
1273 "** Page-specific CSS for \"%s\"\n"
1274 "***********************************************************/\n",
1275 zPage);
1276 blob_append(pOut, zBuiltin, nFile);
1277 fossil_free(zFile);
1278 zFile = 0;
1279 return;
1280 }
1281 /* Potential TODO: check for aliases/page groups. e.g. group all
1282 ** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As
1283 ** of this writing, doing so would only shave a few kb from
@@ -1418,10 +1434,13 @@
1434 ** For administators, or if the test_env_enable setting is true, then
1435 ** details of the request environment are displayed. Otherwise, just
1436 ** the error message is shown.
1437 **
1438 ** If zFormat is an empty string, then this is the /test-env page.
1439 **
1440 ** If the resulting formatted error message is not empty then this
1441 ** function does not return.
1442 */
1443 void webpage_error(const char *zFormat, ...){
1444 int showAll = 0;
1445 char *zErr = 0;
1446 int isAuth = 0;
1447
1448 DDED src/style.forum.css
--- a/src/style.forum.css
+++ b/src/style.forum.css
@@ -0,0 +1,32 @@
1
+/* Styles specific to the forum family of pages */
2
+
3
+fieldset.forum-status-selection {
4
+ max-width: max-content;
5
+ border-radius: 0.5em;
6
+ padding: 0 0.5em;
7
+ margin-bottom: 0.35em;
8
+}
9
+
10
+body.forum .forumpost-single-controls button.draft:after {
11
+ /* Reply/Edit buttons on posts which have local draft edits. */
12
+ content: " [draft]";
13
+}
14
+
15
+/* .ForumPostEditor is the top container element used by the JS
16
+ ForumPostEditor class in fossil.page.forumpost.js. */
17
+.ForumPostEditor {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 1em;
21
+ padding: 0.5em;
22
+}
23
+.ForumPostEditor > .tab-bar{
24
+}
25
+.ForumPostEditor > .tab-container {
26
+}
27
+.ForumPostEditor > .tab-container > .tabs {
28
+ /*min-height: 10em;*/
29
+}
30
+.ForumPostEditor > .tab-container > .tabs > .tab-panel.debug {
31
+ display: flex;
32
+ flex-direction: column;
--- a/src/style.forum.css
+++ b/src/style.forum.css
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/src/style.forum.css
+++ b/src/style.forum.css
@@ -0,0 +1,32 @@
1 /* Styles specific to the forum family of pages */
2
3 fieldset.forum-status-selection {
4 max-width: max-content;
5 border-radius: 0.5em;
6 padding: 0 0.5em;
7 margin-bottom: 0.35em;
8 }
9
10 body.forum .forumpost-single-controls button.draft:after {
11 /* Reply/Edit buttons on posts which have local draft edits. */
12 content: " [draft]";
13 }
14
15 /* .ForumPostEditor is the top container element used by the JS
16 ForumPostEditor class in fossil.page.forumpost.js. */
17 .ForumPostEditor {
18 display: flex;
19 flex-direction: column;
20 gap: 1em;
21 padding: 0.5em;
22 }
23 .ForumPostEditor > .tab-bar{
24 }
25 .ForumPostEditor > .tab-container {
26 }
27 .ForumPostEditor > .tab-container > .tabs {
28 /*min-height: 10em;*/
29 }
30 .ForumPostEditor > .tab-container > .tabs > .tab-panel.debug {
31 display: flex;
32 flex-direction: column;
+11 -6
--- src/tkt.c
+++ src/tkt.c
@@ -749,13 +749,16 @@
749749
}
750750
}
751751
if( g.anon.NewTkt ){
752752
style_submenu_element("New Ticket", "%R/tktnew");
753753
}
754
+ zFullName = db_text(0,
755
+ "SELECT tkt_uuid FROM ticket"
756
+ " WHERE tkt_uuid GLOB '%q*'", zUuid);
754757
if( g.anon.ApndTkt && g.anon.Attach ){
755
- style_submenu_element("Attach", "%R/attachadd?tkt=%T&from=%R/tktview/%t",
756
- zUuid, zUuid);
758
+ style_submenu_element("Attach", "%R/attachadd?target=%T&from=%R/tktview/%t",
759
+ zFullName, zUuid);
757760
}
758761
if( P("plaintext") ){
759762
style_submenu_element("Formatted", "%R/tktview/%s", zUuid);
760763
}else{
761764
style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid);
@@ -773,13 +776,10 @@
773776
}
774777
}
775778
if( !showTimeline && g.perm.Hyperlink ){
776779
style_submenu_element("Timeline", "%R/info/%T", zUuid);
777780
}
778
- zFullName = db_text(0,
779
- "SELECT tkt_uuid FROM ticket"
780
- " WHERE tkt_uuid GLOB '%q*'", zUuid);
781781
if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1);
782782
ticket_init();
783783
initializeVariablesFromCGI();
784784
getAllTicketFields();
785785
initializeVariablesFromDb();
@@ -789,11 +789,16 @@
789789
safe_html_context(DOCSRC_TICKET);
790790
Th_Render(zScript);
791791
if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1);
792792
793793
if( zFullName ){
794
- attachment_list(zFullName, "<h2>Attachments:</h2>", 1);
794
+ char * z = mprintf(
795
+ "<h2><a href='%R/attachlist?tkt=%t'>Attachments</a>:</h2>",
796
+ zFullName
797
+ );
798
+ attachment_list(zFullName, z, 1);
799
+ fossil_free(z);
795800
}
796801
797802
builtin_fossil_js_bundle_or("dom", "storage", NULL);
798803
builtin_request_js("fossil.page.ticket.js");
799804
builtin_fulfill_js_requests();
800805
--- src/tkt.c
+++ src/tkt.c
@@ -749,13 +749,16 @@
749 }
750 }
751 if( g.anon.NewTkt ){
752 style_submenu_element("New Ticket", "%R/tktnew");
753 }
 
 
 
754 if( g.anon.ApndTkt && g.anon.Attach ){
755 style_submenu_element("Attach", "%R/attachadd?tkt=%T&from=%R/tktview/%t",
756 zUuid, zUuid);
757 }
758 if( P("plaintext") ){
759 style_submenu_element("Formatted", "%R/tktview/%s", zUuid);
760 }else{
761 style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid);
@@ -773,13 +776,10 @@
773 }
774 }
775 if( !showTimeline && g.perm.Hyperlink ){
776 style_submenu_element("Timeline", "%R/info/%T", zUuid);
777 }
778 zFullName = db_text(0,
779 "SELECT tkt_uuid FROM ticket"
780 " WHERE tkt_uuid GLOB '%q*'", zUuid);
781 if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1);
782 ticket_init();
783 initializeVariablesFromCGI();
784 getAllTicketFields();
785 initializeVariablesFromDb();
@@ -789,11 +789,16 @@
789 safe_html_context(DOCSRC_TICKET);
790 Th_Render(zScript);
791 if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1);
792
793 if( zFullName ){
794 attachment_list(zFullName, "<h2>Attachments:</h2>", 1);
 
 
 
 
 
795 }
796
797 builtin_fossil_js_bundle_or("dom", "storage", NULL);
798 builtin_request_js("fossil.page.ticket.js");
799 builtin_fulfill_js_requests();
800
--- src/tkt.c
+++ src/tkt.c
@@ -749,13 +749,16 @@
749 }
750 }
751 if( g.anon.NewTkt ){
752 style_submenu_element("New Ticket", "%R/tktnew");
753 }
754 zFullName = db_text(0,
755 "SELECT tkt_uuid FROM ticket"
756 " WHERE tkt_uuid GLOB '%q*'", zUuid);
757 if( g.anon.ApndTkt && g.anon.Attach ){
758 style_submenu_element("Attach", "%R/attachadd?target=%T&from=%R/tktview/%t",
759 zFullName, zUuid);
760 }
761 if( P("plaintext") ){
762 style_submenu_element("Formatted", "%R/tktview/%s", zUuid);
763 }else{
764 style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid);
@@ -773,13 +776,10 @@
776 }
777 }
778 if( !showTimeline && g.perm.Hyperlink ){
779 style_submenu_element("Timeline", "%R/info/%T", zUuid);
780 }
 
 
 
781 if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1);
782 ticket_init();
783 initializeVariablesFromCGI();
784 getAllTicketFields();
785 initializeVariablesFromDb();
@@ -789,11 +789,16 @@
789 safe_html_context(DOCSRC_TICKET);
790 Th_Render(zScript);
791 if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1);
792
793 if( zFullName ){
794 char * z = mprintf(
795 "<h2><a href='%R/attachlist?tkt=%t'>Attachments</a>:</h2>",
796 zFullName
797 );
798 attachment_list(zFullName, z, 1);
799 fossil_free(z);
800 }
801
802 builtin_fossil_js_bundle_or("dom", "storage", NULL);
803 builtin_request_js("fossil.page.ticket.js");
804 builtin_fulfill_js_requests();
805
+11 -39
--- src/wiki.c
+++ src/wiki.c
@@ -626,10 +626,14 @@
626626
style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
627627
}
628628
if( g.perm.Hyperlink ){
629629
style_submenu_element("History", "%R/whistory?name=%T", zPageName);
630630
}
631
+ if( rid>0 && attach_user_may(rid, CFTYPE_WIKI) ){
632
+ style_submenu_element("Attach", "%R/attachadd?target=%T",
633
+ zPageName);
634
+ }
631635
}
632636
if( !isPopup ){
633637
style_set_current_page("%T?name=%T", g.zPath, zPageName);
634638
wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "");
635639
if( !noSubmenu ){
@@ -826,49 +830,15 @@
826830
** mtime order.
827831
*/
828832
static void wiki_ajax_emit_page_attachments(Manifest * pWiki,
829833
int latestOnly,
830834
int nullIfEmpty){
831
- int i = 0;
832
- Stmt q = empty_Stmt;
833
- db_prepare(&q,
834
- "SELECT datetime(mtime), src, target, filename, isLatest,"
835
- " (SELECT uuid FROM blob WHERE rid=attachid) uuid"
836
- " FROM attachment"
837
- " WHERE target=%Q"
838
- " AND (isLatest OR %d)"
839
- " ORDER BY target, isLatest DESC, mtime DESC",
840
- pWiki->zWikiTitle, !latestOnly
841
- );
842
- while(SQLITE_ROW == db_step(&q)){
843
- const char * zTime = db_column_text(&q, 0);
844
- const char * zSrc = db_column_text(&q, 1);
845
- const char * zTarget = db_column_text(&q, 2);
846
- const char * zName = db_column_text(&q, 3);
847
- const int isLatest = db_column_int(&q, 4);
848
- const char * zUuid = db_column_text(&q, 5);
849
- if(!i++){
850
- CX("[");
851
- }else{
852
- CX(",");
853
- }
854
- CX("{");
855
- CX("\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
856
- "\"filename\": %!j, \"mtime\": %!j, \"isLatest\": %s}",
857
- zUuid, zSrc, zTarget,
858
- zName, zTime, isLatest ? "true" : "false");
859
- }
860
- db_finalize(&q);
861
- if(!i){
862
- if(nullIfEmpty){
863
- CX("null");
864
- }else{
865
- CX("[]");
866
- }
867
- }else{
868
- CX("]");
869
- }
835
+ Blob b = BLOB_INITIALIZER;
836
+ attachments_to_json(pWiki, &b, latestOnly,
837
+ nullIfEmpty ? -1 : 1);
838
+ CX("%b", &b);
839
+ blob_reset(&b);
870840
}
871841
872842
/*
873843
** Proxy for wiki_ajax_emit_page_attachments() which attempts to load
874844
** the given wiki page artifact. Returns true if it can load the given
@@ -1139,10 +1109,12 @@
11391109
**
11401110
** URL params:
11411111
**
11421112
** mimetype = the wiki page mimetype (determines rendering style)
11431113
** content = the wiki page content
1114
+**
1115
+** Responds with a partial HTML document.
11441116
*/
11451117
static void wiki_ajax_route_preview(void){
11461118
const char * zContent = P("content");
11471119
11481120
if( zContent==0 ){
11491121
--- src/wiki.c
+++ src/wiki.c
@@ -626,10 +626,14 @@
626 style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
627 }
628 if( g.perm.Hyperlink ){
629 style_submenu_element("History", "%R/whistory?name=%T", zPageName);
630 }
 
 
 
 
631 }
632 if( !isPopup ){
633 style_set_current_page("%T?name=%T", g.zPath, zPageName);
634 wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "");
635 if( !noSubmenu ){
@@ -826,49 +830,15 @@
826 ** mtime order.
827 */
828 static void wiki_ajax_emit_page_attachments(Manifest * pWiki,
829 int latestOnly,
830 int nullIfEmpty){
831 int i = 0;
832 Stmt q = empty_Stmt;
833 db_prepare(&q,
834 "SELECT datetime(mtime), src, target, filename, isLatest,"
835 " (SELECT uuid FROM blob WHERE rid=attachid) uuid"
836 " FROM attachment"
837 " WHERE target=%Q"
838 " AND (isLatest OR %d)"
839 " ORDER BY target, isLatest DESC, mtime DESC",
840 pWiki->zWikiTitle, !latestOnly
841 );
842 while(SQLITE_ROW == db_step(&q)){
843 const char * zTime = db_column_text(&q, 0);
844 const char * zSrc = db_column_text(&q, 1);
845 const char * zTarget = db_column_text(&q, 2);
846 const char * zName = db_column_text(&q, 3);
847 const int isLatest = db_column_int(&q, 4);
848 const char * zUuid = db_column_text(&q, 5);
849 if(!i++){
850 CX("[");
851 }else{
852 CX(",");
853 }
854 CX("{");
855 CX("\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
856 "\"filename\": %!j, \"mtime\": %!j, \"isLatest\": %s}",
857 zUuid, zSrc, zTarget,
858 zName, zTime, isLatest ? "true" : "false");
859 }
860 db_finalize(&q);
861 if(!i){
862 if(nullIfEmpty){
863 CX("null");
864 }else{
865 CX("[]");
866 }
867 }else{
868 CX("]");
869 }
870 }
871
872 /*
873 ** Proxy for wiki_ajax_emit_page_attachments() which attempts to load
874 ** the given wiki page artifact. Returns true if it can load the given
@@ -1139,10 +1109,12 @@
1139 **
1140 ** URL params:
1141 **
1142 ** mimetype = the wiki page mimetype (determines rendering style)
1143 ** content = the wiki page content
 
 
1144 */
1145 static void wiki_ajax_route_preview(void){
1146 const char * zContent = P("content");
1147
1148 if( zContent==0 ){
1149
--- src/wiki.c
+++ src/wiki.c
@@ -626,10 +626,14 @@
626 style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName);
627 }
628 if( g.perm.Hyperlink ){
629 style_submenu_element("History", "%R/whistory?name=%T", zPageName);
630 }
631 if( rid>0 && attach_user_may(rid, CFTYPE_WIKI) ){
632 style_submenu_element("Attach", "%R/attachadd?target=%T",
633 zPageName);
634 }
635 }
636 if( !isPopup ){
637 style_set_current_page("%T?name=%T", g.zPath, zPageName);
638 wiki_page_header(WIKITYPE_UNKNOWN, zPageName, "");
639 if( !noSubmenu ){
@@ -826,49 +830,15 @@
830 ** mtime order.
831 */
832 static void wiki_ajax_emit_page_attachments(Manifest * pWiki,
833 int latestOnly,
834 int nullIfEmpty){
835 Blob b = BLOB_INITIALIZER;
836 attachments_to_json(pWiki, &b, latestOnly,
837 nullIfEmpty ? -1 : 1);
838 CX("%b", &b);
839 blob_reset(&b);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840 }
841
842 /*
843 ** Proxy for wiki_ajax_emit_page_attachments() which attempts to load
844 ** the given wiki page artifact. Returns true if it can load the given
@@ -1139,10 +1109,12 @@
1109 **
1110 ** URL params:
1111 **
1112 ** mimetype = the wiki page mimetype (determines rendering style)
1113 ** content = the wiki page content
1114 **
1115 ** Responds with a partial HTML document.
1116 */
1117 static void wiki_ajax_route_preview(void){
1118 const char * zContent = P("content");
1119
1120 if( zContent==0 ){
1121
--- win/Makefile.mingw
+++ win/Makefile.mingw
@@ -611,10 +611,11 @@
611611
$(SRCDIR)/copybtn.js \
612612
$(SRCDIR)/default.css \
613613
$(SRCDIR)/diff.js \
614614
$(SRCDIR)/diff.tcl \
615615
$(SRCDIR)/forum.js \
616
+ $(SRCDIR)/fossil.attach.js \
616617
$(SRCDIR)/fossil.bootstrap.js \
617618
$(SRCDIR)/fossil.confirmer.js \
618619
$(SRCDIR)/fossil.copybutton.js \
619620
$(SRCDIR)/fossil.diff.js \
620621
$(SRCDIR)/fossil.dom.js \
@@ -661,10 +662,11 @@
661662
$(SRCDIR)/sounds/e.wav \
662663
$(SRCDIR)/sounds/f.wav \
663664
$(SRCDIR)/style.admin_log.css \
664665
$(SRCDIR)/style.chat.css \
665666
$(SRCDIR)/style.fileedit.css \
667
+ $(SRCDIR)/style.forum.css \
666668
$(SRCDIR)/style.pikchrshow.css \
667669
$(SRCDIR)/style.uvlist.css \
668670
$(SRCDIR)/style.wikiedit.css \
669671
$(SRCDIR)/tree.js \
670672
$(SRCDIR)/useredit.js \
671673
--- win/Makefile.mingw
+++ win/Makefile.mingw
@@ -611,10 +611,11 @@
611 $(SRCDIR)/copybtn.js \
612 $(SRCDIR)/default.css \
613 $(SRCDIR)/diff.js \
614 $(SRCDIR)/diff.tcl \
615 $(SRCDIR)/forum.js \
 
616 $(SRCDIR)/fossil.bootstrap.js \
617 $(SRCDIR)/fossil.confirmer.js \
618 $(SRCDIR)/fossil.copybutton.js \
619 $(SRCDIR)/fossil.diff.js \
620 $(SRCDIR)/fossil.dom.js \
@@ -661,10 +662,11 @@
661 $(SRCDIR)/sounds/e.wav \
662 $(SRCDIR)/sounds/f.wav \
663 $(SRCDIR)/style.admin_log.css \
664 $(SRCDIR)/style.chat.css \
665 $(SRCDIR)/style.fileedit.css \
 
666 $(SRCDIR)/style.pikchrshow.css \
667 $(SRCDIR)/style.uvlist.css \
668 $(SRCDIR)/style.wikiedit.css \
669 $(SRCDIR)/tree.js \
670 $(SRCDIR)/useredit.js \
671
--- win/Makefile.mingw
+++ win/Makefile.mingw
@@ -611,10 +611,11 @@
611 $(SRCDIR)/copybtn.js \
612 $(SRCDIR)/default.css \
613 $(SRCDIR)/diff.js \
614 $(SRCDIR)/diff.tcl \
615 $(SRCDIR)/forum.js \
616 $(SRCDIR)/fossil.attach.js \
617 $(SRCDIR)/fossil.bootstrap.js \
618 $(SRCDIR)/fossil.confirmer.js \
619 $(SRCDIR)/fossil.copybutton.js \
620 $(SRCDIR)/fossil.diff.js \
621 $(SRCDIR)/fossil.dom.js \
@@ -661,10 +662,11 @@
662 $(SRCDIR)/sounds/e.wav \
663 $(SRCDIR)/sounds/f.wav \
664 $(SRCDIR)/style.admin_log.css \
665 $(SRCDIR)/style.chat.css \
666 $(SRCDIR)/style.fileedit.css \
667 $(SRCDIR)/style.forum.css \
668 $(SRCDIR)/style.pikchrshow.css \
669 $(SRCDIR)/style.uvlist.css \
670 $(SRCDIR)/style.wikiedit.css \
671 $(SRCDIR)/tree.js \
672 $(SRCDIR)/useredit.js \
673
--- win/Makefile.msc
+++ win/Makefile.msc
@@ -576,10 +576,11 @@
576576
"$(SRCDIR)\copybtn.js" \
577577
"$(SRCDIR)\default.css" \
578578
"$(SRCDIR)\diff.js" \
579579
"$(SRCDIR)\diff.tcl" \
580580
"$(SRCDIR)\forum.js" \
581
+ "$(SRCDIR)\fossil.attach.js" \
581582
"$(SRCDIR)\fossil.bootstrap.js" \
582583
"$(SRCDIR)\fossil.confirmer.js" \
583584
"$(SRCDIR)\fossil.copybutton.js" \
584585
"$(SRCDIR)\fossil.diff.js" \
585586
"$(SRCDIR)\fossil.dom.js" \
@@ -626,10 +627,11 @@
626627
"$(SRCDIR)\sounds\e.wav" \
627628
"$(SRCDIR)\sounds\f.wav" \
628629
"$(SRCDIR)\style.admin_log.css" \
629630
"$(SRCDIR)\style.chat.css" \
630631
"$(SRCDIR)\style.fileedit.css" \
632
+ "$(SRCDIR)\style.forum.css" \
631633
"$(SRCDIR)\style.pikchrshow.css" \
632634
"$(SRCDIR)\style.uvlist.css" \
633635
"$(SRCDIR)\style.wikiedit.css" \
634636
"$(SRCDIR)\tree.js" \
635637
"$(SRCDIR)\useredit.js" \
@@ -1214,10 +1216,11 @@
12141216
echo "$(SRCDIR)\copybtn.js" >> $@
12151217
echo "$(SRCDIR)\default.css" >> $@
12161218
echo "$(SRCDIR)\diff.js" >> $@
12171219
echo "$(SRCDIR)\diff.tcl" >> $@
12181220
echo "$(SRCDIR)\forum.js" >> $@
1221
+ echo "$(SRCDIR)\fossil.attach.js" >> $@
12191222
echo "$(SRCDIR)\fossil.bootstrap.js" >> $@
12201223
echo "$(SRCDIR)\fossil.confirmer.js" >> $@
12211224
echo "$(SRCDIR)\fossil.copybutton.js" >> $@
12221225
echo "$(SRCDIR)\fossil.diff.js" >> $@
12231226
echo "$(SRCDIR)\fossil.dom.js" >> $@
@@ -1264,10 +1267,11 @@
12641267
echo "$(SRCDIR)\sounds/e.wav" >> $@
12651268
echo "$(SRCDIR)\sounds/f.wav" >> $@
12661269
echo "$(SRCDIR)\style.admin_log.css" >> $@
12671270
echo "$(SRCDIR)\style.chat.css" >> $@
12681271
echo "$(SRCDIR)\style.fileedit.css" >> $@
1272
+ echo "$(SRCDIR)\style.forum.css" >> $@
12691273
echo "$(SRCDIR)\style.pikchrshow.css" >> $@
12701274
echo "$(SRCDIR)\style.uvlist.css" >> $@
12711275
echo "$(SRCDIR)\style.wikiedit.css" >> $@
12721276
echo "$(SRCDIR)\tree.js" >> $@
12731277
echo "$(SRCDIR)\useredit.js" >> $@
12741278
--- win/Makefile.msc
+++ win/Makefile.msc
@@ -576,10 +576,11 @@
576 "$(SRCDIR)\copybtn.js" \
577 "$(SRCDIR)\default.css" \
578 "$(SRCDIR)\diff.js" \
579 "$(SRCDIR)\diff.tcl" \
580 "$(SRCDIR)\forum.js" \
 
581 "$(SRCDIR)\fossil.bootstrap.js" \
582 "$(SRCDIR)\fossil.confirmer.js" \
583 "$(SRCDIR)\fossil.copybutton.js" \
584 "$(SRCDIR)\fossil.diff.js" \
585 "$(SRCDIR)\fossil.dom.js" \
@@ -626,10 +627,11 @@
626 "$(SRCDIR)\sounds\e.wav" \
627 "$(SRCDIR)\sounds\f.wav" \
628 "$(SRCDIR)\style.admin_log.css" \
629 "$(SRCDIR)\style.chat.css" \
630 "$(SRCDIR)\style.fileedit.css" \
 
631 "$(SRCDIR)\style.pikchrshow.css" \
632 "$(SRCDIR)\style.uvlist.css" \
633 "$(SRCDIR)\style.wikiedit.css" \
634 "$(SRCDIR)\tree.js" \
635 "$(SRCDIR)\useredit.js" \
@@ -1214,10 +1216,11 @@
1214 echo "$(SRCDIR)\copybtn.js" >> $@
1215 echo "$(SRCDIR)\default.css" >> $@
1216 echo "$(SRCDIR)\diff.js" >> $@
1217 echo "$(SRCDIR)\diff.tcl" >> $@
1218 echo "$(SRCDIR)\forum.js" >> $@
 
1219 echo "$(SRCDIR)\fossil.bootstrap.js" >> $@
1220 echo "$(SRCDIR)\fossil.confirmer.js" >> $@
1221 echo "$(SRCDIR)\fossil.copybutton.js" >> $@
1222 echo "$(SRCDIR)\fossil.diff.js" >> $@
1223 echo "$(SRCDIR)\fossil.dom.js" >> $@
@@ -1264,10 +1267,11 @@
1264 echo "$(SRCDIR)\sounds/e.wav" >> $@
1265 echo "$(SRCDIR)\sounds/f.wav" >> $@
1266 echo "$(SRCDIR)\style.admin_log.css" >> $@
1267 echo "$(SRCDIR)\style.chat.css" >> $@
1268 echo "$(SRCDIR)\style.fileedit.css" >> $@
 
1269 echo "$(SRCDIR)\style.pikchrshow.css" >> $@
1270 echo "$(SRCDIR)\style.uvlist.css" >> $@
1271 echo "$(SRCDIR)\style.wikiedit.css" >> $@
1272 echo "$(SRCDIR)\tree.js" >> $@
1273 echo "$(SRCDIR)\useredit.js" >> $@
1274
--- win/Makefile.msc
+++ win/Makefile.msc
@@ -576,10 +576,11 @@
576 "$(SRCDIR)\copybtn.js" \
577 "$(SRCDIR)\default.css" \
578 "$(SRCDIR)\diff.js" \
579 "$(SRCDIR)\diff.tcl" \
580 "$(SRCDIR)\forum.js" \
581 "$(SRCDIR)\fossil.attach.js" \
582 "$(SRCDIR)\fossil.bootstrap.js" \
583 "$(SRCDIR)\fossil.confirmer.js" \
584 "$(SRCDIR)\fossil.copybutton.js" \
585 "$(SRCDIR)\fossil.diff.js" \
586 "$(SRCDIR)\fossil.dom.js" \
@@ -626,10 +627,11 @@
627 "$(SRCDIR)\sounds\e.wav" \
628 "$(SRCDIR)\sounds\f.wav" \
629 "$(SRCDIR)\style.admin_log.css" \
630 "$(SRCDIR)\style.chat.css" \
631 "$(SRCDIR)\style.fileedit.css" \
632 "$(SRCDIR)\style.forum.css" \
633 "$(SRCDIR)\style.pikchrshow.css" \
634 "$(SRCDIR)\style.uvlist.css" \
635 "$(SRCDIR)\style.wikiedit.css" \
636 "$(SRCDIR)\tree.js" \
637 "$(SRCDIR)\useredit.js" \
@@ -1214,10 +1216,11 @@
1216 echo "$(SRCDIR)\copybtn.js" >> $@
1217 echo "$(SRCDIR)\default.css" >> $@
1218 echo "$(SRCDIR)\diff.js" >> $@
1219 echo "$(SRCDIR)\diff.tcl" >> $@
1220 echo "$(SRCDIR)\forum.js" >> $@
1221 echo "$(SRCDIR)\fossil.attach.js" >> $@
1222 echo "$(SRCDIR)\fossil.bootstrap.js" >> $@
1223 echo "$(SRCDIR)\fossil.confirmer.js" >> $@
1224 echo "$(SRCDIR)\fossil.copybutton.js" >> $@
1225 echo "$(SRCDIR)\fossil.diff.js" >> $@
1226 echo "$(SRCDIR)\fossil.dom.js" >> $@
@@ -1264,10 +1267,11 @@
1267 echo "$(SRCDIR)\sounds/e.wav" >> $@
1268 echo "$(SRCDIR)\sounds/f.wav" >> $@
1269 echo "$(SRCDIR)\style.admin_log.css" >> $@
1270 echo "$(SRCDIR)\style.chat.css" >> $@
1271 echo "$(SRCDIR)\style.fileedit.css" >> $@
1272 echo "$(SRCDIR)\style.forum.css" >> $@
1273 echo "$(SRCDIR)\style.pikchrshow.css" >> $@
1274 echo "$(SRCDIR)\style.uvlist.css" >> $@
1275 echo "$(SRCDIR)\style.wikiedit.css" >> $@
1276 echo "$(SRCDIR)\tree.js" >> $@
1277 echo "$(SRCDIR)\useredit.js" >> $@
1278
--- www/changes.wiki
+++ www/changes.wiki
@@ -14,12 +14,14 @@
1414
the unmanaged file, even if that unmanaged file is read-only.
1515
<li> Improve the default prompts used by the
1616
"[/help/sqlite3|fossil sql]" command.
1717
<li> The captcha now uses light-gray boxes as the background, instead of
1818
spaces, to work around width inconsistencies in some fonts.
19
+ <li> Forum post editing and replying was overhauled with a new UI. Clients
20
+ with JavaScript disabled will still see the older forms.
1921
<li> Forum posts may now have attachments if their poster has the new "B"
20
- capability.</li>
22
+ capability.
2123
<li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
2224
to limit the size of file attachments to wiki pages, tech notes,
2325
tickets, and forum posts.
2426
</ol>
2527
2628
--- www/changes.wiki
+++ www/changes.wiki
@@ -14,12 +14,14 @@
14 the unmanaged file, even if that unmanaged file is read-only.
15 <li> Improve the default prompts used by the
16 "[/help/sqlite3|fossil sql]" command.
17 <li> The captcha now uses light-gray boxes as the background, instead of
18 spaces, to work around width inconsistencies in some fonts.
 
 
19 <li> Forum posts may now have attachments if their poster has the new "B"
20 capability.</li>
21 <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
22 to limit the size of file attachments to wiki pages, tech notes,
23 tickets, and forum posts.
24 </ol>
25
26
--- www/changes.wiki
+++ www/changes.wiki
@@ -14,12 +14,14 @@
14 the unmanaged file, even if that unmanaged file is read-only.
15 <li> Improve the default prompts used by the
16 "[/help/sqlite3|fossil sql]" command.
17 <li> The captcha now uses light-gray boxes as the background, instead of
18 spaces, to work around width inconsistencies in some fonts.
19 <li> Forum post editing and replying was overhauled with a new UI. Clients
20 with JavaScript disabled will still see the older forms.
21 <li> Forum posts may now have attachments if their poster has the new "B"
22 capability.
23 <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
24 to limit the size of file attachments to wiki pages, tech notes,
25 tickets, and forum posts.
26 </ol>
27
28
+92 -6
--- www/forum.wiki
+++ www/forum.wiki
@@ -474,22 +474,23 @@
474474
rendering a newer version.
475475
476476
Caveat: a "closed" status is not recommended because it's easy to confuse with
477477
the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably
478478
differently and predates that "status" tag support by about three years. The
479
-"closed" semantics cannot be trivially consolidated with those of "status".
479
+"closed" semantics cannot be trivially consolidated with those of "status"
480
+but we reserve the right to do so at some point.
480481
481482
<h2 name="attachments">Attachments</h2>
482483
483
-As of version 2.29 users with the [./caps/index.md|'B' capability]
484
-may attach files to forum posts. Files may not be attached until a
485
-forum post is saved for the first time, after which an "Attach" button
486
-will appear in the post when it is selected. Attached files undergo
484
+As of version 2.29 users with the [./caps/index.md|'B' capability] may
485
+attach files to forum posts. Files may not be attached until a forum
486
+post is saved for the first time, after which an "Attach" button will
487
+appear in the post when it is selected. Attached files undergo
487488
moderation exactly like forum posts do. When a moderator accepts a
488489
pending-moderation posts, all files attached to it which are also
489490
pending approval are also approved. Similarly, when a moderator
490
-rejects a pending-moderation post, all files attached to it when are
491
+rejects a pending-moderation post, all files attached to it which are
491492
also pending approval are rejected.
492493
493494
Developer notes regarding the save-before-attach limitation:
494495
495496
* We cannot add the attachment form to the current post editor
@@ -513,5 +514,90 @@
513514
notified, admin reads post, user attaches a file at that time,
514515
admin approves post.
515516
516517
TBD is whether to accept that case or to remove automatic approval of
517518
attached files.
519
+
520
+<h2 name="drafts">Draft Edits</h2>
521
+
522
+The forum uses, if available, JavaScript's <code>localStorage</code>
523
+or <code>sessionStorage</code> to store local drafts of new
524
+posts, replies, and edits to existing posts. Each time an editor
525
+input field loses focus, the draft is saved. When viewing a forum
526
+thread, the Edit and Reply buttons get clearly marked if they
527
+have an associated local draft.
528
+
529
+Local drafts for replies and responses (but not new posts) are
530
+automatically purged at semi-random intervals. Every Nth visit to a
531
+forum page has a small chance to run the cleanup, and drafts older
532
+than 10 days old are purged.
533
+
534
+If neither <code>localStorage</code> nor <code>sessionStorage</code>
535
+are available, it uses a transient storage object which is recreated
536
+on each page-load, so it cannot store drafts outside of a single
537
+thread at a time.
538
+
539
+Beware: Each draft is keyed to the post it is editing or replied to,
540
+which means that editing or replying to the same post from different
541
+tabs will cause collisions. The editor to most recently lose the focus
542
+will save the draft, overwriting the other.
543
+
544
+In browsers which support Web Locks, attempts to edit resp. reply to a
545
+post which is already being edited resp. replied to in another tab
546
+will show an error for the second and subsequent tabs. The first tab
547
+to use the edit/reply buttons will hold a separate Web Lock for each
548
+and forbid edit/reply access to the post in other tabs until the
549
+locking tab is closed.
550
+
551
+
552
+<h2 name="padding">Curious Extra Page Padding</h2>
553
+
554
+Users may notice that the forum pages get a bunch of padding added to
555
+the bottom of the page when an editor widget is opened. This is not a
556
+bug, but a workaround to help avoid the UI jumping around when
557
+rendering a preview wildly resizes the widget. The effect is
558
+especially helpful when working on posts while the bottom footer of
559
+the page is in the viewport, as the footer is a hard boundary against
560
+scrolling. With the extra padding, the editor widget's top edge shifts
561
+around less when the preview is shown and hidden.
562
+
563
+<h2 name="fork">Forked Edits</h2>
564
+
565
+It is possible to "fork" forum messages, in the same way that any
566
+given check-in may cause a fork. This can happen when a message is
567
+edited concurrently from two or more browser tabs or via two or more
568
+fossil instances which subsequently sync with each other.
569
+
570
+When a forum post is forked, the UI currently behaves as it does for
571
+concurrent wiki page edits, showing only the most recent
572
+edit. Currently (2026-06) the UI is incapable of showing the older
573
+forked copies but (A) the data are still part of the SCM history and
574
+(B) there are plans to improve the UI to show such forks.
575
+
576
+For trivia's sake, here's a query which finds posts which have
577
+forks:
578
+
579
+<pre><verbatim>
580
+SELECT bp.rid AS parentRid, bp.uuid parentUuid, count(f.fprev) forkCount
581
+FROM blob b, blob bp, forumpost f
582
+WHERE b.rid=f.fpid
583
+AND f.fprev=bp.rid
584
+GROUP BY f.fprev
585
+HAVING forkCount>1;
586
+</verbatim></pre>
587
+
588
+And here's one which lists forked posts:
589
+
590
+<pre><verbatim>
591
+WITH multiparent(mpid) AS (
592
+ SELECT fprev FROM forumpost f
593
+ GROUP BY fprev
594
+ HAVING count(fprev)>1
595
+)
596
+SELECT datetime(fmtime), forumpost.*
597
+FROM forumpost, multiparent
598
+WHERE fprev=mpid
599
+ORDER BY mpid, fpid, fmtime;
600
+</verbatim></pre>
601
+
602
+The telling part is the <code>fprev</code> column: those with matching
603
+values forked from the post referred to by <code>fprev</code>.
518604
--- www/forum.wiki
+++ www/forum.wiki
@@ -474,22 +474,23 @@
474 rendering a newer version.
475
476 Caveat: a "closed" status is not recommended because it's easy to confuse with
477 the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably
478 differently and predates that "status" tag support by about three years. The
479 "closed" semantics cannot be trivially consolidated with those of "status".
 
480
481 <h2 name="attachments">Attachments</h2>
482
483 As of version 2.29 users with the [./caps/index.md|'B' capability]
484 may attach files to forum posts. Files may not be attached until a
485 forum post is saved for the first time, after which an "Attach" button
486 will appear in the post when it is selected. Attached files undergo
487 moderation exactly like forum posts do. When a moderator accepts a
488 pending-moderation posts, all files attached to it which are also
489 pending approval are also approved. Similarly, when a moderator
490 rejects a pending-moderation post, all files attached to it when are
491 also pending approval are rejected.
492
493 Developer notes regarding the save-before-attach limitation:
494
495 * We cannot add the attachment form to the current post editor
@@ -513,5 +514,90 @@
513 notified, admin reads post, user attaches a file at that time,
514 admin approves post.
515
516 TBD is whether to accept that case or to remove automatic approval of
517 attached files.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
--- www/forum.wiki
+++ www/forum.wiki
@@ -474,22 +474,23 @@
474 rendering a newer version.
475
476 Caveat: a "closed" status is not recommended because it's easy to confuse with
477 the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably
478 differently and predates that "status" tag support by about three years. The
479 "closed" semantics cannot be trivially consolidated with those of "status"
480 but we reserve the right to do so at some point.
481
482 <h2 name="attachments">Attachments</h2>
483
484 As of version 2.29 users with the [./caps/index.md|'B' capability] may
485 attach files to forum posts. Files may not be attached until a forum
486 post is saved for the first time, after which an "Attach" button will
487 appear in the post when it is selected. Attached files undergo
488 moderation exactly like forum posts do. When a moderator accepts a
489 pending-moderation posts, all files attached to it which are also
490 pending approval are also approved. Similarly, when a moderator
491 rejects a pending-moderation post, all files attached to it which are
492 also pending approval are rejected.
493
494 Developer notes regarding the save-before-attach limitation:
495
496 * We cannot add the attachment form to the current post editor
@@ -513,5 +514,90 @@
514 notified, admin reads post, user attaches a file at that time,
515 admin approves post.
516
517 TBD is whether to accept that case or to remove automatic approval of
518 attached files.
519
520 <h2 name="drafts">Draft Edits</h2>
521
522 The forum uses, if available, JavaScript's <code>localStorage</code>
523 or <code>sessionStorage</code> to store local drafts of new
524 posts, replies, and edits to existing posts. Each time an editor
525 input field loses focus, the draft is saved. When viewing a forum
526 thread, the Edit and Reply buttons get clearly marked if they
527 have an associated local draft.
528
529 Local drafts for replies and responses (but not new posts) are
530 automatically purged at semi-random intervals. Every Nth visit to a
531 forum page has a small chance to run the cleanup, and drafts older
532 than 10 days old are purged.
533
534 If neither <code>localStorage</code> nor <code>sessionStorage</code>
535 are available, it uses a transient storage object which is recreated
536 on each page-load, so it cannot store drafts outside of a single
537 thread at a time.
538
539 Beware: Each draft is keyed to the post it is editing or replied to,
540 which means that editing or replying to the same post from different
541 tabs will cause collisions. The editor to most recently lose the focus
542 will save the draft, overwriting the other.
543
544 In browsers which support Web Locks, attempts to edit resp. reply to a
545 post which is already being edited resp. replied to in another tab
546 will show an error for the second and subsequent tabs. The first tab
547 to use the edit/reply buttons will hold a separate Web Lock for each
548 and forbid edit/reply access to the post in other tabs until the
549 locking tab is closed.
550
551
552 <h2 name="padding">Curious Extra Page Padding</h2>
553
554 Users may notice that the forum pages get a bunch of padding added to
555 the bottom of the page when an editor widget is opened. This is not a
556 bug, but a workaround to help avoid the UI jumping around when
557 rendering a preview wildly resizes the widget. The effect is
558 especially helpful when working on posts while the bottom footer of
559 the page is in the viewport, as the footer is a hard boundary against
560 scrolling. With the extra padding, the editor widget's top edge shifts
561 around less when the preview is shown and hidden.
562
563 <h2 name="fork">Forked Edits</h2>
564
565 It is possible to "fork" forum messages, in the same way that any
566 given check-in may cause a fork. This can happen when a message is
567 edited concurrently from two or more browser tabs or via two or more
568 fossil instances which subsequently sync with each other.
569
570 When a forum post is forked, the UI currently behaves as it does for
571 concurrent wiki page edits, showing only the most recent
572 edit. Currently (2026-06) the UI is incapable of showing the older
573 forked copies but (A) the data are still part of the SCM history and
574 (B) there are plans to improve the UI to show such forks.
575
576 For trivia's sake, here's a query which finds posts which have
577 forks:
578
579 <pre><verbatim>
580 SELECT bp.rid AS parentRid, bp.uuid parentUuid, count(f.fprev) forkCount
581 FROM blob b, blob bp, forumpost f
582 WHERE b.rid=f.fpid
583 AND f.fprev=bp.rid
584 GROUP BY f.fprev
585 HAVING forkCount>1;
586 </verbatim></pre>
587
588 And here's one which lists forked posts:
589
590 <pre><verbatim>
591 WITH multiparent(mpid) AS (
592 SELECT fprev FROM forumpost f
593 GROUP BY fprev
594 HAVING count(fprev)>1
595 )
596 SELECT datetime(fmtime), forumpost.*
597 FROM forumpost, multiparent
598 WHERE fprev=mpid
599 ORDER BY mpid, fpid, fmtime;
600 </verbatim></pre>
601
602 The telling part is the <code>fprev</code> column: those with matching
603 values forked from the post referred to by <code>fprev</code>.
604

Keyboard Shortcuts

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