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].
Commit
ca906591c3cc71224e592707eb31738f66492ca6551b90cd08d548607d884b40
Parent
afd15bbf004526a…
29 files changed
+17
-41
+43
-85
+30
-37
+72
-10
+931
-109
+20
-1
+3
-2
+160
-1
+14
-7
+536
-77
+663
+8
-5
+4
+1227
-12
+5
-6
+10
-10
+62
-7
+3
-2
+6
-2
+2
+1
-1
+22
-3
+32
+11
-6
+11
-39
+2
+4
+3
-1
+92
-6
~
skins/ardoise/css.txt
~
skins/blitz/css.txt
~
skins/darkmode/css.txt
~
src/ajax.c
~
src/attach.c
~
src/builtin.c
~
src/cgi.c
~
src/default.css
~
src/event.c
~
src/forum.c
~
src/fossil.attach.js
~
src/fossil.bootstrap.js
~
src/fossil.dom.js
~
src/fossil.page.forumpost.js
~
src/fossil.page.wikiedit.js
~
src/fossil.popupwidget.js
~
src/fossil.storage.js
~
src/fossil.tabs.js
~
src/info.c
~
src/main.mk
~
src/manifest.c
~
src/style.c
~
src/style.forum.css
~
src/tkt.c
~
src/wiki.c
~
win/Makefile.mingw
~
win/Makefile.msc
~
www/changes.wiki
~
www/forum.wiki
+17
-41
| --- skins/ardoise/css.txt | ||
| +++ skins/ardoise/css.txt | ||
| @@ -154,24 +154,19 @@ | ||
| 154 | 154 | button, |
| 155 | 155 | select { |
| 156 | 156 | text-transform: none |
| 157 | 157 | } |
| 158 | 158 | button, |
| 159 | -html input[type=button], | |
| 159 | +input[type=button], | |
| 160 | 160 | input[type=reset], |
| 161 | -input[type=submit] { | |
| 161 | +input[type=submit]{ | |
| 162 | 162 | cursor: pointer |
| 163 | 163 | } |
| 164 | -button[disabled], | |
| 165 | -html input[disabled] { | |
| 164 | +button:disabled, | |
| 165 | +input:disabled { | |
| 166 | 166 | cursor: default |
| 167 | 167 | } |
| 168 | -button::-moz-focus-inner, | |
| 169 | -input::-moz-focus-inner { | |
| 170 | - border: 0; | |
| 171 | - padding: 0 | |
| 172 | -} | |
| 173 | 168 | input { |
| 174 | 169 | line-height: normal |
| 175 | 170 | } |
| 176 | 171 | input[type=checkbox], |
| 177 | 172 | input[type=radio] { |
| @@ -307,11 +302,11 @@ | ||
| 307 | 302 | text-decoration: none; |
| 308 | 303 | text-align: center; |
| 309 | 304 | white-space: nowrap; |
| 310 | 305 | cursor: pointer |
| 311 | 306 | } |
| 312 | -input[type=submit]:disabled { | |
| 307 | +input:disabled { | |
| 313 | 308 | color: rgb(70,70,70); |
| 314 | 309 | background-color: rgb(153,153,153); |
| 315 | 310 | } |
| 316 | 311 | |
| 317 | 312 | @media (min-width:550px) { |
| @@ -416,30 +411,20 @@ | ||
| 416 | 411 | .offset-by-eleven.columns { |
| 417 | 412 | margin-left: 95.33333% |
| 418 | 413 | } |
| 419 | 414 | } |
| 420 | 415 | .button, |
| 421 | -button { | |
| 422 | - color: #aaa; | |
| 423 | - background-color: #444; | |
| 424 | - border-radius: 5px; | |
| 425 | - border: 0 | |
| 426 | -} | |
| 416 | +button, | |
| 427 | 417 | input[type=button], |
| 428 | 418 | input[type=reset], |
| 429 | 419 | input[type=submit] { |
| 430 | 420 | color: #ddd; |
| 431 | 421 | background-color: #446979; |
| 432 | 422 | border: 0; |
| 433 | 423 | border-radius: 5px |
| 434 | 424 | } |
| 435 | -.button:hover, | |
| 436 | -button:hover { | |
| 437 | - color: #444; | |
| 438 | - background-color: #aaa; | |
| 439 | - outline: 0 | |
| 440 | -} | |
| 425 | +button:hover, | |
| 441 | 426 | input[type=button]:hover, |
| 442 | 427 | input[type=reset]:hover, |
| 443 | 428 | input[type=submit]:hover { |
| 444 | 429 | color: #446979; |
| 445 | 430 | background-color: #ddd; |
| @@ -452,29 +437,20 @@ | ||
| 452 | 437 | input[type=submit]:focus { |
| 453 | 438 | color: #333; |
| 454 | 439 | border-color: #888; |
| 455 | 440 | outline: 0 |
| 456 | 441 | } |
| 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 | + | |
| 476 | 452 | input[type=email], |
| 477 | 453 | input[type=number], |
| 478 | 454 | input[type=password], |
| 479 | 455 | input[type=search], |
| 480 | 456 | input[type=tel], |
| 481 | 457 |
| --- 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 |
+43
-85
| --- skins/blitz/css.txt | ||
| +++ skins/blitz/css.txt | ||
| @@ -1,17 +1,9 @@ | ||
| 1 | 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ |
| 2 | 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 | 3 | html { |
| 10 | 4 | font-family: sans-serif; /* 1 */ |
| 11 | - -ms-text-size-adjust: 100%; /* 2 */ | |
| 12 | - -webkit-text-size-adjust: 100%; /* 2 */ | |
| 13 | 5 | } |
| 14 | 6 | |
| 15 | 7 | /** |
| 16 | 8 | * Remove default margin. |
| 17 | 9 | */ |
| @@ -207,11 +199,10 @@ | ||
| 207 | 199 | /** |
| 208 | 200 | * Address differences between Firefox and other browsers. |
| 209 | 201 | */ |
| 210 | 202 | |
| 211 | 203 | hr { |
| 212 | - -moz-box-sizing: content-box; | |
| 213 | 204 | box-sizing: content-box; |
| 214 | 205 | height: 0; |
| 215 | 206 | } |
| 216 | 207 | |
| 217 | 208 | /** |
| @@ -277,47 +268,27 @@ | ||
| 277 | 268 | button, |
| 278 | 269 | select { |
| 279 | 270 | text-transform: none; |
| 280 | 271 | } |
| 281 | 272 | |
| 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 | 273 | 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; | |
| 298 | 278 | } |
| 299 | 279 | |
| 300 | 280 | /** |
| 301 | 281 | * Re-set default cursor for disabled elements. |
| 302 | 282 | */ |
| 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{ | |
| 306 | 287 | cursor: default; |
| 307 | 288 | } |
| 308 | 289 | |
| 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 | 290 | /** |
| 320 | 291 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in |
| 321 | 292 | * the UA stylesheet. |
| 322 | 293 | */ |
| 323 | 294 | |
| @@ -348,20 +319,11 @@ | ||
| 348 | 319 | input[type="number"]::-webkit-inner-spin-button, |
| 349 | 320 | input[type="number"]::-webkit-outer-spin-button { |
| 350 | 321 | height: auto; |
| 351 | 322 | } |
| 352 | 323 | |
| 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 | 324 | input[type="search"] { |
| 360 | - -webkit-appearance: textfield; /* 1 */ | |
| 361 | - -moz-box-sizing: content-box; | |
| 362 | - -webkit-box-sizing: content-box; /* 2 */ | |
| 363 | 325 | box-sizing: content-box; |
| 364 | 326 | } |
| 365 | 327 | |
| 366 | 328 | /** |
| 367 | 329 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. |
| @@ -517,73 +479,65 @@ | ||
| 517 | 479 | |
| 518 | 480 | /* Buttons |
| 519 | 481 | ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */ |
| 520 | 482 | .button, |
| 521 | 483 | 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]{ | |
| 527 | 487 | display: inline-block; |
| 528 | 488 | height: 3.3rem; |
| 529 | 489 | padding: 0 2.2rem; |
| 530 | - color: #555 !important; | |
| 490 | + color: #444; | |
| 491 | + background-color: #f8f8f8;; | |
| 492 | + border-color: #446979; | |
| 531 | 493 | text-align: center; |
| 532 | 494 | font-size: 1.1rem; |
| 533 | 495 | font-weight: 700; |
| 534 | 496 | line-height: 3.3rem; |
| 535 | 497 | letter-spacing: .08rem; |
| 536 | 498 | text-transform: uppercase; |
| 537 | 499 | text-decoration: none; |
| 538 | 500 | white-space: nowrap; |
| 539 | - background-color: transparent; | |
| 540 | 501 | border-radius: 4px; |
| 541 | - border: 1px solid #ccc; | |
| 502 | + /*border: 1px solid #ccc;*/ | |
| 503 | + border: 1px solid #446979; | |
| 542 | 504 | cursor: pointer; |
| 543 | 505 | box-sizing: border-box; |
| 544 | 506 | } |
| 545 | 507 | |
| 546 | 508 | .button:hover, |
| 547 | 509 | 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, | |
| 551 | 514 | 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{ | |
| 557 | 518 | 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; | |
| 575 | 520 | background-color: #648898; |
| 576 | 521 | border-color: #648898; |
| 577 | 522 | } |
| 578 | 523 | |
| 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; | |
| 583 | 530 | background-color: rgb(153,153,153); |
| 584 | 531 | } |
| 532 | + | |
| 533 | +.content a.button, | |
| 534 | +.submenu a.button, | |
| 535 | +.submenu a.button:visited { | |
| 536 | + color: #444; | |
| 537 | + background: #f8f8f8; | |
| 538 | +} | |
| 585 | 539 | |
| 586 | 540 | |
| 587 | 541 | /* Forms |
| 588 | 542 | ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */ |
| 589 | 543 | input[type="email"], |
| @@ -611,12 +565,10 @@ | ||
| 611 | 565 | input[type="text"], |
| 612 | 566 | input[type="tel"], |
| 613 | 567 | input[type="url"], |
| 614 | 568 | input[type="password"], |
| 615 | 569 | textarea { |
| 616 | - -webkit-appearance: none; | |
| 617 | - -moz-appearance: none; | |
| 618 | 570 | appearance: none; |
| 619 | 571 | } |
| 620 | 572 | |
| 621 | 573 | textarea { |
| 622 | 574 | height: inherit; |
| @@ -1293,5 +1245,11 @@ | ||
| 1293 | 1245 | } |
| 1294 | 1246 | |
| 1295 | 1247 | body.forum .forumPosts.fileage a:visited { |
| 1296 | 1248 | color: #648999; |
| 1297 | 1249 | } |
| 1250 | + | |
| 1251 | +/* Chat | |
| 1252 | +––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– */ | |
| 1253 | +body.chat .chat-input-field:focus { | |
| 1254 | + border-color: #446979; | |
| 1255 | +} | |
| 1298 | 1256 |
| --- 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 |
+30
-37
| --- skins/darkmode/css.txt | ||
| +++ skins/darkmode/css.txt | ||
| @@ -98,50 +98,56 @@ | ||
| 98 | 98 | } |
| 99 | 99 | .fileage tr:hover, |
| 100 | 100 | div.filetreeline:hover { |
| 101 | 101 | background-color: #333; |
| 102 | 102 | } |
| 103 | -div.file-change-line button { | |
| 103 | +div.file-change-line button{ | |
| 104 | 104 | background-color: #484848 |
| 105 | 105 | } |
| 106 | +button, | |
| 107 | +input, | |
| 108 | +optgroup, | |
| 109 | +select, | |
| 110 | +textarea { | |
| 111 | + background: inherit; | |
| 112 | + color: inherit; | |
| 113 | + font: inherit; | |
| 114 | + margin: 0 | |
| 115 | +} | |
| 106 | 116 | .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, | |
| 119 | 118 | input[type=button], |
| 120 | -input[type=reset], | |
| 121 | -input[type=submit] { | |
| 119 | +input[type=submit], | |
| 120 | +input[type=reset]{ | |
| 122 | 121 | color: #ddd; |
| 123 | 122 | background-color: #446979; |
| 123 | + border-radius: 5px; | |
| 124 | 124 | border: 0; |
| 125 | - border-radius: 5px | |
| 126 | 125 | } |
| 126 | +.button:hover, | |
| 127 | +button:hover, | |
| 127 | 128 | input[type=button]:hover, |
| 128 | -input[type=reset]:hover, | |
| 129 | -input[type=submit]:hover { | |
| 129 | +input[type=submit]:hover, | |
| 130 | +input[type=reset]:hover { | |
| 130 | 131 | background-color: #FF4500f0; |
| 131 | 132 | color: rgba(24,24,24,0.8); |
| 132 | - outline: 0 | |
| 133 | + outline: 0; | |
| 133 | 134 | } |
| 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; | |
| 137 | 143 | } |
| 138 | 144 | .button:focus, |
| 139 | 145 | button:focus, |
| 140 | 146 | input[type=button]:focus, |
| 141 | -input[type=reset]:focus, | |
| 142 | -input[type=submit]:focus { | |
| 147 | +input[type=submit]:focus, | |
| 148 | +input[type=reset]:focus { | |
| 143 | 149 | outline: 2px outset #333; |
| 144 | 150 | border-color: #888; |
| 145 | 151 | } |
| 146 | 152 | |
| 147 | 153 | /* All page content from the bottom of the menu or submenu down to |
| @@ -529,23 +535,10 @@ | ||
| 529 | 535 | } |
| 530 | 536 | span.snippet>mark { |
| 531 | 537 | color: white; |
| 532 | 538 | font-weight: bold; |
| 533 | 539 | } |
| 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 | 540 | input, textarea, select { |
| 548 | 541 | border: 1px solid rgba(127, 201, 255, 0.9); |
| 549 | 542 | padding: 1px; |
| 550 | 543 | } |
| 551 | 544 | select { |
| 552 | 545 |
| --- 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 @@ | ||
| 191 | 191 | ** {error: formatted message} |
| 192 | 192 | ** |
| 193 | 193 | ** If httpCode<=0 then it defaults to 500. |
| 194 | 194 | ** |
| 195 | 195 | ** After calling this, the caller should immediately return. |
| 196 | +** | |
| 197 | +** Returns the resulting http code. | |
| 196 | 198 | */ |
| 197 | -void ajax_route_error(int httpCode, const char * zFmt, ...){ | |
| 199 | +int ajax_route_error(int httpCode, const char * zFmt, ...){ | |
| 198 | 200 | Blob msg = empty_blob; |
| 199 | 201 | Blob content = empty_blob; |
| 200 | 202 | va_list vargs; |
| 203 | + | |
| 204 | + if( httpCode<=0 ) httpCode=500; | |
| 201 | 205 | va_start(vargs,zFmt); |
| 202 | 206 | blob_vappendf(&msg, zFmt, vargs); |
| 203 | 207 | va_end(vargs); |
| 204 | 208 | blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg)); |
| 205 | 209 | blob_reset(&msg); |
| 206 | 210 | cgi_set_content(&content); |
| 207 | - cgi_set_status(httpCode>0 ? httpCode : 500, "Error"); | |
| 211 | + cgi_set_status(httpCode, "Error"); | |
| 208 | 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; | |
| 209 | 238 | } |
| 210 | 239 | |
| 211 | 240 | /* |
| 212 | 241 | ** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as |
| 213 | 242 | ** logging in the user. |
| @@ -224,17 +253,18 @@ | ||
| 224 | 253 | int ajax_route_bootstrap(int requireWrite, int requirePost){ |
| 225 | 254 | login_check_credentials(); |
| 226 | 255 | if( requireWrite!=0 && g.perm.Write==0 ){ |
| 227 | 256 | ajax_route_error(403,"Write permissions required."); |
| 228 | 257 | return 0; |
| 229 | - }else if(0==cgi_csrf_safe(requirePost)){ | |
| 258 | + }else if(requirePost && 0==cgi_csrf_safe(requirePost)){ | |
| 230 | 259 | ajax_route_error(403, |
| 231 | 260 | "CSRF violation (make sure sending of HTTP " |
| 232 | 261 | "Referer headers is enabled for XHR " |
| 233 | 262 | "connections)."); |
| 234 | 263 | return 0; |
| 235 | 264 | } |
| 265 | + cgi_set_content_type("application/json"); | |
| 236 | 266 | return 1; |
| 237 | 267 | } |
| 238 | 268 | |
| 239 | 269 | /* |
| 240 | 270 | ** Helper for collecting filename/check-in request parameters. |
| @@ -283,12 +313,10 @@ | ||
| 283 | 313 | ** AJAX_RENDER_PLAIN_TEXT mode. |
| 284 | 314 | ** |
| 285 | 315 | ** iframe_height=integer (default=40) Height, in EMs of HTML preview |
| 286 | 316 | ** iframe. |
| 287 | 317 | ** |
| 288 | -** User must have Write access to use this page. | |
| 289 | -** | |
| 290 | 318 | ** Responds with the HTML content of the preview. On error it produces |
| 291 | 319 | ** a JSON response as documented for ajax_route_error(). |
| 292 | 320 | ** |
| 293 | 321 | ** Extra response headers: |
| 294 | 322 | ** |
| @@ -341,10 +369,37 @@ | ||
| 341 | 369 | } |
| 342 | 370 | if(zRenderMode!=0){ |
| 343 | 371 | cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode); |
| 344 | 372 | } |
| 345 | 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 | +} | |
| 346 | 401 | |
| 347 | 402 | #if INTERFACE |
| 348 | 403 | /* |
| 349 | 404 | ** Internal mapping of ajax sub-route names to various metadata. |
| 350 | 405 | */ |
| @@ -351,11 +406,12 @@ | ||
| 351 | 406 | struct AjaxRoute { |
| 352 | 407 | const char *zName; /* Name part of the route after "ajax/" */ |
| 353 | 408 | void (*xCallback)(); /* Impl function for the route. */ |
| 354 | 409 | int bWriteMode; /* True if requires write mode */ |
| 355 | 410 | int bPost; /* True if requires POST (i.e. CSRF |
| 356 | - ** verification) */ | |
| 411 | + ** verification). Value is passed to | |
| 412 | + ** cgi_csrf_safe(). */ | |
| 357 | 413 | }; |
| 358 | 414 | typedef struct AjaxRoute AjaxRoute; |
| 359 | 415 | #endif /*INTERFACE*/ |
| 360 | 416 | |
| 361 | 417 | /* |
| @@ -392,20 +448,26 @@ | ||
| 392 | 448 | const char * zName = P("name"); |
| 393 | 449 | AjaxRoute routeName = {0,0,0,0}; |
| 394 | 450 | const AjaxRoute * pRoute = 0; |
| 395 | 451 | const AjaxRoute routes[] = { |
| 396 | 452 | /* Keep these sorted by zName (for bsearch()) */ |
| 453 | + {"artifact.json", ajax_route_artifact_json, 0, 0}, | |
| 397 | 454 | {"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: | |
| 401 | 459 | ** |
| 402 | 460 | ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898 |
| 403 | 461 | ** |
| 404 | 462 | ** This particular route is used by /fileedit and /chat, whereas |
| 405 | 463 | ** /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 | + } | |
| 407 | 469 | }; |
| 408 | 470 | |
| 409 | 471 | if(zName==0 || zName[0]==0){ |
| 410 | 472 | ajax_route_error(400,"Missing required [route] 'name' parameter."); |
| 411 | 473 | return; |
| 412 | 474 |
| --- 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 @@ | ||
| 22 | 22 | #include <assert.h> |
| 23 | 23 | |
| 24 | 24 | /* |
| 25 | 25 | ** Given a presumedly legal attachment target name, this guesses the |
| 26 | 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. | |
| 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. | |
| 29 | 52 | ** |
| 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. | |
| 34 | 58 | */ |
| 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(). */ | |
| 39 | 67 | return CFTYPE_FORUM; |
| 40 | 68 | } |
| 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, | |
| 44 | 93 | "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" | |
| 48 | 100 | "ELSE 0 END", |
| 49 | - CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI | |
| 101 | + zTarget, CFTYPE_TICKET, | |
| 102 | + zTarget, CFTYPE_WIKI, | |
| 103 | + zTarget, CFTYPE_EVENT | |
| 50 | 104 | ); |
| 51 | 105 | } |
| 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; | |
| 55 | 191 | } |
| 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>\ | |
| 58 | 205 | } |
| 59 | 206 | |
| 60 | 207 | /* |
| 61 | 208 | ** WEBPAGE: attachlist |
| 62 | 209 | ** List attachments. |
| @@ -69,20 +216,21 @@ | ||
| 69 | 216 | ** At most one of technote=, tkt=, forumpost=, or page= may be supplied. |
| 70 | 217 | ** |
| 71 | 218 | ** If none are given, all attachments are listed. If one is given, only |
| 72 | 219 | ** attachments for the designated technote, ticket or wiki page are shown. |
| 73 | 220 | ** |
| 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. | |
| 78 | 225 | */ |
| 79 | 226 | void attachlist_page(void){ |
| 80 | 227 | const char *zPage = P("page"); |
| 81 | 228 | const char *zTkt = P("tkt"); |
| 82 | 229 | const char *zTechNote = P("technote"); |
| 83 | 230 | const char *zForumPost = P("forumpost"); |
| 231 | + char *zLink = 0; | |
| 84 | 232 | Blob sql; |
| 85 | 233 | Stmt q; |
| 86 | 234 | |
| 87 | 235 | if( zPage && zTkt ) zTkt = 0; |
| 88 | 236 | login_check_credentials(); |
| @@ -102,32 +250,47 @@ | ||
| 102 | 250 | if( fnid<=0 ){ |
| 103 | 251 | webpage_error("Invalid forum post ID: %h", zForumPost); |
| 104 | 252 | } |
| 105 | 253 | blob_append_sql(&sql, " WHERE target=" |
| 106 | 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); | |
| 107 | 257 | }else if( zPage ){ |
| 108 | 258 | if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; } |
| 109 | 259 | style_header("Attachments To Wiki page %h", zPage); |
| 110 | 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); | |
| 111 | 263 | }else if( zTkt ){ |
| 112 | 264 | if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; } |
| 113 | 265 | style_header("Attachments To Ticket %S", zTkt); |
| 114 | 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); | |
| 115 | 269 | }else if( zTechNote ){ |
| 116 | 270 | if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; } |
| 117 | 271 | style_header("Attachments To Tech Note %S", zTechNote); |
| 118 | 272 | blob_append_sql(&sql, " WHERE target GLOB '%q*'", |
| 119 | 273 | zTechNote); |
| 274 | + zLink = mprintf("tech-note <a href='%R/technote?name=%t'>%#h</a>", | |
| 275 | + zTechNote, hash_digits(0), zTechNote); | |
| 120 | 276 | }else{ |
| 121 | 277 | if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){ |
| 122 | 278 | login_needed(g.anon.RdTkt || g.anon.RdWiki); |
| 123 | 279 | return; |
| 124 | 280 | } |
| 125 | 281 | style_header("All Attachments"); |
| 126 | 282 | } |
| 127 | 283 | blob_append_sql(&sql, " ORDER BY mtime DESC"); |
| 128 | 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 | + | |
| 129 | 292 | @ <ol> |
| 130 | 293 | while( db_step(&q)==SQLITE_ROW ){ |
| 131 | 294 | const char *zDate; |
| 132 | 295 | const char *zSrc; |
| 133 | 296 | const char *zTarget; |
| @@ -137,10 +300,11 @@ | ||
| 137 | 300 | const char *zUuid; |
| 138 | 301 | const char *zDispUser; |
| 139 | 302 | const int attachid = db_column_int(&q, 7); |
| 140 | 303 | int type; |
| 141 | 304 | int i; |
| 305 | + int bDeleted; | |
| 142 | 306 | char *zUrlTail = 0; |
| 143 | 307 | |
| 144 | 308 | if( moderation_pending(attachid) |
| 145 | 309 | && !moderation_user_could(attachid, 1, 0) ){ |
| 146 | 310 | /* Elide entries which are currently pending moderation unless |
| @@ -160,11 +324,12 @@ | ||
| 160 | 324 | if( zFilename[i]=='/' && zFilename[i+1]!=0 ){ |
| 161 | 325 | zFilename = &zFilename[i+1]; |
| 162 | 326 | i = -1; |
| 163 | 327 | } |
| 164 | 328 | } |
| 165 | - type = attachment_target_type(zTarget); | |
| 329 | + bDeleted = 0==zSrc || 0==zSrc[0]; | |
| 330 | + type = attachment_target_type(zTarget, 1); | |
| 166 | 331 | switch( type ){ |
| 167 | 332 | case CFTYPE_TICKET: |
| 168 | 333 | zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename); |
| 169 | 334 | break; |
| 170 | 335 | case CFTYPE_EVENT: |
| @@ -176,20 +341,32 @@ | ||
| 176 | 341 | case CFTYPE_WIKI: |
| 177 | 342 | zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename); |
| 178 | 343 | break; |
| 179 | 344 | } |
| 180 | 345 | @ <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>\ | |
| 182 | 350 | 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> | |
| 185 | 358 | if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++; |
| 186 | 359 | 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> | |
| 188 | 365 | } |
| 189 | 366 | if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){ |
| 190 | - if( zSrc==0 || zSrc[0]==0 ){ | |
| 367 | + if( bDeleted ){ | |
| 191 | 368 | zSrc = "Deleted from"; |
| 192 | 369 | }else { |
| 193 | 370 | zSrc = "Added to"; |
| 194 | 371 | } |
| 195 | 372 | switch( type ){ |
| @@ -362,41 +539,81 @@ | ||
| 362 | 539 | Manifest *pManifest; |
| 363 | 540 | |
| 364 | 541 | db_begin_transaction(); |
| 365 | 542 | blob_init(&content, aContent, szContent); |
| 366 | 543 | pManifest = manifest_parse(&content, 0, 0); |
| 544 | + addCompress = pManifest!=0; | |
| 367 | 545 | manifest_destroy(pManifest); |
| 368 | 546 | blob_init(&content, aContent, szContent); |
| 369 | - if( pManifest ){ | |
| 547 | + if( addCompress ){ | |
| 370 | 548 | blob_compress(&content, &content); |
| 371 | - addCompress = 1; | |
| 372 | 549 | } |
| 373 | 550 | 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); | |
| 375 | 552 | blob_zero(&manifest); |
| 376 | 553 | for(i=n=0; zName[i]; i++){ |
| 377 | 554 | if( zName[i]=='/' || zName[i]=='\\' ) n = i+1; |
| 378 | 555 | } |
| 379 | 556 | zName += n; |
| 380 | 557 | if( zName[0]==0 ) zName = "unknown"; |
| 381 | 558 | blob_appendf(&manifest, "A %F%s %F %s\n", |
| 382 | 559 | 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 | + } | |
| 388 | 567 | } |
| 389 | 568 | zDate = date_in_standard_format("now"); |
| 390 | - blob_appendf(&manifest, "D %s\n", zDate); | |
| 569 | + blob_appendf(&manifest, "D %z\n", zDate); | |
| 391 | 570 | blob_appendf(&manifest, "U %F\n", login_name()); |
| 392 | 571 | md5sum_blob(&manifest, &cksum); |
| 393 | 572 | blob_appendf(&manifest, "Z %b\n", &cksum); |
| 394 | 573 | attach_put(&manifest, rid, needModerator); |
| 395 | 574 | assert( blob_is_reset(&manifest) ); |
| 396 | 575 | db_end_transaction(0); |
| 397 | 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 | +} | |
| 398 | 615 | |
| 399 | 616 | /* |
| 400 | 617 | ** WEBPAGE: attachadd |
| 401 | 618 | ** Add a new attachment. |
| 402 | 619 | ** |
| @@ -404,33 +621,54 @@ | ||
| 404 | 621 | ** page=WIKIPAGE |
| 405 | 622 | ** technote=HASH |
| 406 | 623 | ** forumpost=HASH |
| 407 | 624 | ** from=URL |
| 408 | 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(). | |
| 409 | 633 | */ |
| 410 | 634 | 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; | |
| 419 | 642 | 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 */ | |
| 421 | 645 | char *zTargetType = 0; |
| 422 | 646 | char *zExtraFree = 0; |
| 423 | - int szContent = atoi(PD("f:bytes","0")); | |
| 647 | + int szContent; | |
| 424 | 648 | int goodCaptcha = 1; |
| 425 | 649 | int szLimit = 0; |
| 426 | 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 | + | |
| 427 | 665 | if( zFrom==0 ) zFrom = mprintf("%R/home"); |
| 428 | 666 | if( P("cancel") ) cgi_redirect(zFrom); |
| 429 | 667 | if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){ |
| 430 | 668 | webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X," |
| 431 | - " or technote=X"); | |
| 669 | + " technote=X, or target=X"); | |
| 432 | 670 | } |
| 433 | 671 | login_check_credentials(); |
| 434 | 672 | if( zForumPost ){ |
| 435 | 673 | int fpid; |
| 436 | 674 | if( g.perm.AttachForum==0 ){ |
| @@ -445,26 +683,23 @@ | ||
| 445 | 683 | "forum posts."); |
| 446 | 684 | } |
| 447 | 685 | zTarget = zExtraFree = rid_to_uuid(fpid); |
| 448 | 686 | zTargetType = mprintf("Forum post <a href=\"%R/forumpost/%S\">%h</a>", |
| 449 | 687 | 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); | |
| 455 | 689 | }else if( zPage ){ |
| 456 | 690 | if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){ |
| 457 | 691 | login_needed(g.anon.ApndWiki && g.anon.Attach); |
| 458 | 692 | return; |
| 459 | 693 | } |
| 460 | 694 | if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){ |
| 461 | 695 | fossil_redirect_home(); |
| 462 | 696 | } |
| 463 | 697 | 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>", | |
| 465 | 699 | zPage, zPage); |
| 700 | + zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget); | |
| 466 | 701 | }else if ( zTechNote ){ |
| 467 | 702 | if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){ |
| 468 | 703 | login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach); |
| 469 | 704 | return; |
| 470 | 705 | } |
| @@ -474,11 +709,11 @@ | ||
| 474 | 709 | if( zTechNote==0) fossil_redirect_home(); |
| 475 | 710 | } |
| 476 | 711 | zTarget = zTechNote; |
| 477 | 712 | zTargetType = mprintf("Tech Note <a href=\"%R/technote/%s\">%S</a>", |
| 478 | 713 | zTechNote, zTechNote); |
| 479 | - | |
| 714 | + zTo = zFrom ? 0 : mprintf("%R/technote/%S", zTarget); | |
| 480 | 715 | }else{ |
| 481 | 716 | assert( zTkt ); |
| 482 | 717 | if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){ |
| 483 | 718 | login_needed(g.anon.ApndTkt && g.anon.Attach); |
| 484 | 719 | return; |
| @@ -487,21 +722,25 @@ | ||
| 487 | 722 | zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag" |
| 488 | 723 | " WHERE tagname GLOB 'tkt-%q*'", zTkt); |
| 489 | 724 | if( zTkt==0 ) fossil_redirect_home(); |
| 490 | 725 | } |
| 491 | 726 | zTarget = zTkt; |
| 492 | - zTargetType = mprintf("Ticket <a href=\"%R/tktview/%s\">%S</a>", | |
| 727 | + zTargetType = mprintf("Ticket <a href=\"%R/tktview/%S\">%S</a>", | |
| 493 | 728 | zTkt, zTkt); |
| 729 | + zTo = zFrom ? 0 : mprintf("%R/tktview/%S", zTarget); | |
| 494 | 730 | } |
| 495 | 731 | szLimit = db_get_int("attachment-size-limit", 0); |
| 496 | 732 | if( szContent<0 || (szLimit && szContent>szLimit) ){ |
| 497 | 733 | /* This check must be done late so that zTargetType is set up. */ |
| 498 | 734 | @ <p class="generalError">Attachment %h(zName) is too large. |
| 499 | 735 | @ <a href="%R/help/attachment-size-limit">Limit</a> is |
| 500 | 736 | @ %d(szLimit ? szLimit : 0x7fffffff) bytes</p> |
| 501 | 737 | /* 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)) ){ | |
| 503 | 742 | int needModerator = (zForumPost!=0 && forum_need_moderation()) || |
| 504 | 743 | (zTkt!=0 && ticket_need_moderation(0)) || |
| 505 | 744 | (zPage!=0 && wiki_need_moderation(0)); |
| 506 | 745 | attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment); |
| 507 | 746 | cgi_redirect(zTo ? zTo : zFrom); |
| @@ -511,35 +750,374 @@ | ||
| 511 | 750 | style_header("Add Attachment"); |
| 512 | 751 | if( !goodCaptcha ){ |
| 513 | 752 | @ <p class="generalError">Error: Incorrect security code.</p> |
| 514 | 753 | } |
| 515 | 754 | @ <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; | |
| 529 | 967 | }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)"> | |
| 531 | 1106 | } |
| 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 | + } | |
| 536 | 1110 | 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); | |
| 538 | 1115 | style_finish_page(); |
| 539 | 1116 | fossil_free(zTargetType); |
| 540 | 1117 | fossil_free(zExtraFree); |
| 1118 | + fossil_free(zTo); | |
| 541 | 1119 | } |
| 542 | 1120 | |
| 543 | 1121 | /* |
| 544 | 1122 | ** WEBPAGE: ainfo |
| 545 | 1123 | ** URL: /ainfo?name=ARTIFACTID |
| @@ -600,15 +1178,17 @@ | ||
| 600 | 1178 | && db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget) |
| 601 | 1179 | ){ |
| 602 | 1180 | if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; } |
| 603 | 1181 | zTktUuid = zTarget; |
| 604 | 1182 | 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) ){ | |
| 606 | 1185 | if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; } |
| 607 | 1186 | zWikiName = zTarget; |
| 608 | 1187 | 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) ){ | |
| 610 | 1190 | if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; } |
| 611 | 1191 | zTNUuid = zTarget; |
| 612 | 1192 | showDelMenu = g.perm.Write && g.perm.WrWiki; |
| 613 | 1193 | } |
| 614 | 1194 | if( showDelMenu ){ |
| @@ -632,11 +1212,13 @@ | ||
| 632 | 1212 | Blob cksum; |
| 633 | 1213 | const char *zFile = zName; |
| 634 | 1214 | |
| 635 | 1215 | if( !bUserIsOwner ){ |
| 636 | 1216 | 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 | + ); | |
| 638 | 1220 | } |
| 639 | 1221 | } |
| 640 | 1222 | db_begin_transaction(); |
| 641 | 1223 | blob_zero(&manifest); |
| 642 | 1224 | for(i=n=0; zFile[i]; i++){ |
| @@ -656,14 +1238,14 @@ | ||
| 656 | 1238 | @ <p>The attachment below has been deleted.</p> |
| 657 | 1239 | fossil_free(zNewDate); |
| 658 | 1240 | } |
| 659 | 1241 | |
| 660 | 1242 | 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)) | |
| 665 | 1247 | ){ |
| 666 | 1248 | form_begin(0, "%R/ainfo/%!S", zUuid); |
| 667 | 1249 | @ <p>Confirm you want to delete the attachment shown below. |
| 668 | 1250 | @ <input type="submit" name="confirm" value="Confirm"> |
| 669 | 1251 | login_insert_csrf_secret(); |
| @@ -711,20 +1293,22 @@ | ||
| 711 | 1293 | @ (%d(rid)) |
| 712 | 1294 | } |
| 713 | 1295 | modPending = moderation_pending_www(rid); |
| 714 | 1296 | if( zForumPost ){ |
| 715 | 1297 | @ <tr><th>Forum 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> | |
| 717 | 1300 | }else if( zTktUuid ){ |
| 718 | 1301 | @ <tr><th>Ticket:</th> |
| 719 | 1302 | @ <td>%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)</a></td></tr> |
| 720 | 1303 | }else if( zTNUuid ){ |
| 721 | 1304 | @ <tr><th>Tech Note:</th> |
| 722 | 1305 | @ <td>%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)</a></td></tr> |
| 723 | 1306 | }else if( zWikiName ){ |
| 724 | 1307 | @ <tr><th>Wiki 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> | |
| 726 | 1310 | } |
| 727 | 1311 | @ <tr><th>Date:</th><td> |
| 728 | 1312 | hyperlink_to_date(zDate, "</td></tr>"); |
| 729 | 1313 | @ <tr><th>User:</th><td> |
| 730 | 1314 | hyperlink_to_user(pAttach->zUser, zDate, "</td></tr>"); |
| @@ -735,22 +1319,42 @@ | ||
| 735 | 1319 | } |
| 736 | 1320 | @ <tr><th>Filename:</th><td>%h(zName)</td></tr> |
| 737 | 1321 | if( g.perm.Setup ){ |
| 738 | 1322 | @ <tr><th>MIME-Type:</th><td>%h(zMime)</td></tr> |
| 739 | 1323 | } |
| 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> | |
| 741 | 1327 | @ </table> |
| 742 | 1328 | |
| 743 | 1329 | if( modPending && (isModerator || bUserIsOwner) ){ |
| 744 | 1330 | @ <div class="section">Moderation</div> |
| 745 | 1331 | @ <blockquote> |
| 746 | 1332 | form_begin(0, "%R/ainfo/%s", zUuid); |
| 747 | 1333 | @ <label><input type="radio" name="modaction" value="delete"> |
| 748 | 1334 | @ Delete this attachment</label><br> |
| 749 | 1335 | 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 | |
| 752 | 1356 | } |
| 753 | 1357 | @ <input type="submit" value="Submit"> |
| 754 | 1358 | login_insert_csrf_secret(); |
| 755 | 1359 | @ </form> |
| 756 | 1360 | @ </blockquote> |
| @@ -767,11 +1371,12 @@ | ||
| 767 | 1371 | const char *z; |
| 768 | 1372 | content_get(ridSrc, &attach); |
| 769 | 1373 | blob_to_utf8_no_bom(&attach, 0); |
| 770 | 1374 | z = blob_str(&attach); |
| 771 | 1375 | 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); | |
| 773 | 1378 | }else{ |
| 774 | 1379 | @ <pre> |
| 775 | 1380 | @ %h(z) |
| 776 | 1381 | @ </pre> |
| 777 | 1382 | } |
| @@ -798,10 +1403,13 @@ | ||
| 798 | 1403 | */ |
| 799 | 1404 | #define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert <hr> above header */ |
| 800 | 1405 | #define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */ |
| 801 | 1406 | #define ATTACHLIST_SIZE 0x04 /* add size */ |
| 802 | 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 */ | |
| 803 | 1411 | #endif |
| 804 | 1412 | |
| 805 | 1413 | /* |
| 806 | 1414 | ** Output HTML to show a list of attachments. |
| 807 | 1415 | */ |
| @@ -810,19 +1418,25 @@ | ||
| 810 | 1418 | const char *zHeader, /* Header to display with attachments */ |
| 811 | 1419 | const int flags /* ATTACHLIST_... flags */ |
| 812 | 1420 | ){ |
| 813 | 1421 | int cnt = 0; |
| 814 | 1422 | 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) | |
| 816 | 1424 | ? " target=\"_blank\"" : ""; |
| 1425 | + const int bUseDetail = flags & | |
| 1426 | + (ATTACHLIST_DETAILS_CLOSED | ATTACHLIST_DETAILS_OPEN); | |
| 817 | 1427 | Stmt q; |
| 1428 | + | |
| 818 | 1429 | 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" | |
| 824 | 1438 | " ORDER BY mtime DESC", |
| 825 | 1439 | zTarget |
| 826 | 1440 | ); |
| 827 | 1441 | while( db_step(&q)==SQLITE_ROW ){ |
| 828 | 1442 | const char *zDate = db_column_text(&q, 0); |
| @@ -832,36 +1446,51 @@ | ||
| 832 | 1446 | const char *zSrc = db_column_text(&q, 4); |
| 833 | 1447 | const char *zTarget = db_column_text(&q, 5); |
| 834 | 1448 | const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous"; |
| 835 | 1449 | const char *zTypeArg = 0; /* URL arg name for /attachdownload */ |
| 836 | 1450 | 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); | |
| 838 | 1452 | if( (flags & ATTACHLIST_HIDE_UNAPPROVED) |
| 839 | 1453 | && moderation_pending(aid) |
| 840 | 1454 | && !moderation_user_could(aid, 1, 0) ){ |
| 841 | 1455 | continue; |
| 1456 | + } | |
| 1457 | + if( sz<1 && (flags & ATTACHLIST_HIDE_EMPTY) ){ | |
| 1458 | + /* Deleted or phantom items. */ | |
| 1459 | + continue; | |
| 842 | 1460 | } |
| 843 | 1461 | 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 | + } | |
| 845 | 1471 | if( flags & ATTACHLIST_HRULE_ABOVE ){ |
| 846 | 1472 | @ <hr> |
| 847 | 1473 | } |
| 848 | - @ %s(zHeader) | |
| 1474 | + if( bUseDetail ){ | |
| 1475 | + @ <summary>%s(zHeader)</summary> | |
| 1476 | + }else{ | |
| 1477 | + @ %s(zHeader) | |
| 1478 | + } | |
| 849 | 1479 | @ <ul> |
| 850 | 1480 | } |
| 851 | 1481 | cnt++; |
| 852 | - switch( iAType ){ | |
| 1482 | + switch( attachment_target_type(zTarget, 1) ){ | |
| 853 | 1483 | case CFTYPE_TICKET: zTypeArg = "tkt"; break; |
| 854 | 1484 | case CFTYPE_FORUM: zTypeArg = "forumpost"; break; |
| 855 | 1485 | case CFTYPE_EVENT: zTypeArg = "technote"; break; |
| 856 | 1486 | case CFTYPE_WIKI: |
| 857 | 1487 | default: zTypeArg = "page"; break; |
| 858 | 1488 | } |
| 859 | 1489 | @ <li> |
| 860 | 1490 | @ <a href="%R/artifact/%!S(zSrc)"%s(zLinkTgt)>%h(zFile)</a> |
| 861 | 1491 | if( flags & ATTACHLIST_SIZE ){ |
| 862 | - const int sz = db_int(0,"SELECT size FROM blob WHERE uuid=%Q", zSrc); | |
| 863 | 1492 | sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz); |
| 864 | 1493 | } |
| 865 | 1494 | @ [<a href="%R/attachdownload/%t(zFile)?%s(zTypeArg)=%t(zTarget)\ |
| 866 | 1495 | @&file=%t(zFile)%s(zLinkTgt)">download</a>%s(szBuf)] |
| 867 | 1496 | @ added by %h(zDispUser) on |
| @@ -870,11 +1499,15 @@ | ||
| 870 | 1499 | moderation_pending_www(aid); |
| 871 | 1500 | @ </li> |
| 872 | 1501 | } |
| 873 | 1502 | if( cnt ){ |
| 874 | 1503 | @ </ul> |
| 875 | - @ </section> | |
| 1504 | + if( bUseDetail ){ | |
| 1505 | + @ </details> | |
| 1506 | + }else{ | |
| 1507 | + @ </section> | |
| 1508 | + } | |
| 876 | 1509 | } |
| 877 | 1510 | db_finalize(&q); |
| 878 | 1511 | } |
| 879 | 1512 | |
| 880 | 1513 | /* |
| @@ -1023,15 +1656,204 @@ | ||
| 1023 | 1656 | } |
| 1024 | 1657 | for(i = 2; i < g.argc; ++i){ |
| 1025 | 1658 | const char *zPage = g.argv[i]; |
| 1026 | 1659 | db_bind_text(&q, ":tgtname", zPage); |
| 1027 | 1660 | 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); | |
| 1032 | 1665 | printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName); |
| 1033 | 1666 | } |
| 1034 | 1667 | db_reset(&q); |
| 1035 | 1668 | } |
| 1036 | 1669 | db_finalize(&q); |
| 1037 | 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 | +} | |
| 1038 | 1860 |
| --- 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 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 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 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 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 @@ | ||
| 668 | 668 | CX("editStateMarkers: {" |
| 669 | 669 | "/*Symbolic markers to denote certain edit states.*/" |
| 670 | 670 | "isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n"); |
| 671 | 671 | CX("confirmerButtonTicks: 3 " |
| 672 | 672 | "/*default fossil.confirmer tick count.*/,\n"); |
| 673 | + CX("attachmentSizeLimit: %d,\n", | |
| 674 | + db_get_int("attachment-size-limit",0)); | |
| 673 | 675 | /* Inject certain info about the current skin... */ |
| 674 | 676 | CX("skin:{"); |
| 675 | 677 | /* can leak a local filesystem path: |
| 676 | 678 | CX("name: %!j,", skin_in_use());*/ |
| 677 | 679 | CX("isDark: %s" |
| 678 | 680 | "/*true if the current skin has the 'white-foreground' detail*/", |
| 679 | 681 | skin_detail_boolean("white-foreground") ? "true" : "false"); |
| 680 | 682 | CX("}\n"/*fossil.config.skin*/); |
| 681 | 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") | |
| 682 | 696 | CX("window.fossil.user = {"); |
| 683 | 697 | 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())); | |
| 685 | 702 | CX("};\n"/*fossil.user*/); |
| 686 | 703 | CX("if(fossil.config.skin.isDark) " |
| 687 | 704 | "document.body.classList.add('fossil-dark-style');\n"); |
| 688 | 705 | /* |
| 689 | 706 | ** fossil.page holds info about the current page. This is also |
| @@ -699,10 +716,11 @@ | ||
| 699 | 716 | } |
| 700 | 717 | /* The remaining window.fossil bootstrap code is not dependent on |
| 701 | 718 | ** C-runtime state... */ |
| 702 | 719 | builtin_request_js("fossil.bootstrap.js"); |
| 703 | 720 | } |
| 721 | +#undef JBOOL | |
| 704 | 722 | } |
| 705 | 723 | |
| 706 | 724 | /* |
| 707 | 725 | ** Given the NAME part of fossil.NAME.js, this function checks whether |
| 708 | 726 | ** that module has been emitted by this function before. If it has, |
| @@ -732,10 +750,11 @@ | ||
| 732 | 750 | ** entries: all known deps of this one. Each |
| 733 | 751 | ** REQUIRES an EXPLICIT trailing \0, including |
| 734 | 752 | ** the final one! */ |
| 735 | 753 | } fjs[] = { |
| 736 | 754 | /* This list ordering isn't strictly important. */ |
| 755 | + {"attach", 0, "dom\0"}, | |
| 737 | 756 | {"confirmer", 0, 0}, |
| 738 | 757 | {"copybutton", 0, "dom\0"}, |
| 739 | 758 | {"diff", 0, "dom\0fetch\0storage\0" |
| 740 | 759 | /* maintenance note: "diff" needs "storage" for storing the |
| 741 | 760 | ** sbs-sync-scroll toggle. */}, |
| 742 | 761 |
| --- 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 @@ | ||
| 1335 | 1335 | } |
| 1336 | 1336 | fossil_free(zErr); |
| 1337 | 1337 | } |
| 1338 | 1338 | } |
| 1339 | 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. */ | |
| 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(). */ | |
| 1342 | 1343 | rc |= 0x04; |
| 1343 | 1344 | g.syncInfo.zLoginCard = fossil_strdup(z); |
| 1344 | 1345 | g.syncInfo.fLoginCardMode |= 0x02; |
| 1345 | 1346 | cgi_delete_parameter("x-f-l-c"); |
| 1346 | 1347 | } |
| 1347 | 1348 |
| --- 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 @@ | ||
| 1693 | 1693 | -0.6836761,0.240014 -1.4255375,0.720042 V 3.0698267 q 0.8800513,-0.3054724 1.6073661,-0.4509353 \ |
| 1694 | 1694 | 0.7273151,-0.145463 1.403718,-0.145463 1.7746486,0 2.7056104,0.727315 0.930965,0.720042 \ |
| 1695 | 1695 | 0.930965,2.1092135 0,0.7127686 -0.283654,1.2800746 -0.283652,0.5600324 -0.967329,1.2073428 \ |
| 1696 | 1696 | L 10.025425,8.2119439 Q 9.530851,8.6628792 9.3781148,8.9392588 9.2253789,9.2083654 \ |
| 1697 | 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 "); | |
| 1698 | +style='fill:%23f8f8f8;stroke-width:1.35412836' /%3e%3c/svg%3e "); | |
| 1699 | 1699 | background-repeat: no-repeat; |
| 1700 | 1700 | background-position: center; |
| 1701 | 1701 | /* When not using a background image, this additional style works |
| 1702 | 1702 | reasonably well along with a ::before content of "?": */ |
| 1703 | 1703 | /*border-width: 1px; |
| @@ -2007,10 +2007,169 @@ | ||
| 2007 | 2007 | margin: 0; |
| 2008 | 2008 | } |
| 2009 | 2009 | div.helpPage blockquote { |
| 2010 | 2010 | margin-left: 0.2em; |
| 2011 | 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 | +} | |
| 2012 | 2171 | |
| 2013 | 2172 | /* Objects in the "desktoponly" class are invisible on mobile */ |
| 2014 | 2173 | @media screen and (max-width: 600px) { |
| 2015 | 2174 | .desktoponly { |
| 2016 | 2175 | display: none; |
| 2017 | 2176 |
| --- 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 @@ | ||
| 117 | 117 | style_header("No Such Tech-Note"); |
| 118 | 118 | @ Cannot locate a technical note called <b>%h(zId)</b>. |
| 119 | 119 | style_finish_page(); |
| 120 | 120 | return; |
| 121 | 121 | } |
| 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); | |
| 123 | 127 | zVerbose = P("v"); |
| 124 | 128 | if( !zVerbose ){ |
| 125 | 129 | zVerbose = P("verbose"); |
| 126 | 130 | } |
| 127 | 131 | if( !zVerbose ){ |
| @@ -157,11 +161,11 @@ | ||
| 157 | 161 | style_header("%s", blob_str(&title)); |
| 158 | 162 | if( g.perm.WrWiki && g.perm.Write && nextRid==0 ){ |
| 159 | 163 | style_submenu_element("Edit", "%R/technoteedit?name=%!S", zId); |
| 160 | 164 | if( g.perm.Attach ){ |
| 161 | 165 | 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); | |
| 163 | 167 | } |
| 164 | 168 | } |
| 165 | 169 | zETime = db_text(0, "SELECT datetime(%.17g)", pTNote->rEventDate); |
| 166 | 170 | style_submenu_element("Context", "%R/timeline?c=%.20s", zId); |
| 167 | 171 | if( g.perm.Hyperlink ){ |
| @@ -225,15 +229,18 @@ | ||
| 225 | 229 | }else{ |
| 226 | 230 | @ <pre> |
| 227 | 231 | @ %h(blob_str(&fullbody)) |
| 228 | 232 | @ </pre> |
| 229 | 233 | } |
| 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 | + } | |
| 235 | 242 | document_emit_js(); |
| 236 | 243 | style_finish_page(); |
| 237 | 244 | manifest_destroy(pTNote); |
| 238 | 245 | } |
| 239 | 246 | |
| 240 | 247 |
| --- 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 @@ | ||
| 96 | 96 | |
| 97 | 97 | /* |
| 98 | 98 | ** Returns a high-level representation of the forum-statuses setting. |
| 99 | 99 | ** This is a singleton, cached across calls. |
| 100 | 100 | */ |
| 101 | -static const ForumStatusList * forum_statuses(void){ | |
| 101 | +const ForumStatusList * forum_statuses(void){ | |
| 102 | 102 | static ForumStatusList fses = {0,0}; |
| 103 | 103 | static int once = 0; |
| 104 | 104 | while( !once ){ |
| 105 | 105 | ++once; |
| 106 | 106 | /* Read `forum-statuses` setting and transform it into the |
| @@ -151,11 +151,13 @@ | ||
| 151 | 151 | ** found, the corresponding object is returned. If no match is found |
| 152 | 152 | ** then (A) if bFirst is false then 0 is returned, else (B) the first |
| 153 | 153 | ** entry in the list is returned, noting that the list may be empty, |
| 154 | 154 | ** in which case 0 is returned. |
| 155 | 155 | */ |
| 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 | +){ | |
| 157 | 159 | const ForumStatusList * const fses = forum_statuses(); |
| 158 | 160 | const ForumStatus * fs0 = 0; |
| 159 | 161 | unsigned int i; |
| 160 | 162 | if( !fses->n ) return 0; |
| 161 | 163 | for( i = 0; i < fses->n; ++i ){ |
| @@ -227,18 +229,18 @@ | ||
| 227 | 229 | } |
| 228 | 230 | |
| 229 | 231 | /* |
| 230 | 232 | ** Works like forumpost_head_rid() but expects zUuid to be an |
| 231 | 233 | ** 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. | |
| 234 | 236 | */ |
| 235 | 237 | int forumpost_head_rid2(const char *zUuid){ |
| 236 | 238 | const int fpid = symbolic_name_to_rid(zUuid, "f"); |
| 237 | 239 | return fpid>0 |
| 238 | 240 | ? forumpost_head_rid(fpid) |
| 239 | - : 0; | |
| 241 | + : fpid; | |
| 240 | 242 | } |
| 241 | 243 | |
| 242 | 244 | /* |
| 243 | 245 | ** Given a forum post RID and user name, returns true if zUserName |
| 244 | 246 | ** matches the event.(euser,user) field for a formpost entry with the |
| @@ -408,18 +410,17 @@ | ||
| 408 | 410 | ** no tag is added. Similarly, it will only remove a tag from a post |
| 409 | 411 | ** which has its own tag, and will not remove an inherited one from a |
| 410 | 412 | ** parent post. |
| 411 | 413 | ** |
| 412 | 414 | ** 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. | |
| 421 | 422 | ** |
| 422 | 423 | ** If it returns true then state from previously-loaded posts may be |
| 423 | 424 | ** invalidated if they refer to the amended post or a response to it. |
| 424 | 425 | ** e.g. if zTagName is "closed" then ForumPost::iClosed values may be |
| 425 | 426 | ** stale. |
| @@ -438,11 +439,12 @@ | ||
| 438 | 439 | ** |
| 439 | 440 | ** - The applied tag is propagating so so that "closed" tags can |
| 440 | 441 | ** account for how edits of posts are handled. This differs from |
| 441 | 442 | ** closure of a branch, where a non-propagating tag is used. |
| 442 | 443 | */ |
| 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, | |
| 444 | 446 | const char *zValue){ |
| 445 | 447 | Blob artifact = BLOB_INITIALIZER; /* Output artifact */ |
| 446 | 448 | Blob cksum = BLOB_INITIALIZER; /* Z-card */ |
| 447 | 449 | int iTagged; /* true if frid is already tagged */ |
| 448 | 450 | int trid; /* RID of new control artifact */ |
| @@ -461,11 +463,11 @@ | ||
| 461 | 463 | zValue = 0; |
| 462 | 464 | } |
| 463 | 465 | if( addTag && iTagged ){ |
| 464 | 466 | char *zOld = 0; |
| 465 | 467 | int cmp; |
| 466 | - rid_has_tag2(iTagged, zTagName, &zOld); | |
| 468 | + rid_has_tag2(frid, zTagName, &zOld); | |
| 467 | 469 | cmp = fossil_strcmp(zOld, zValue); |
| 468 | 470 | fossil_free(zOld); |
| 469 | 471 | if( 0==cmp ){ |
| 470 | 472 | /* Same value - leave it as is. */ |
| 471 | 473 | db_end_transaction(0); |
| @@ -481,22 +483,58 @@ | ||
| 481 | 483 | md5sum_blob(&artifact, &cksum); |
| 482 | 484 | blob_appendf(&artifact, "Z %b\n", &cksum); |
| 483 | 485 | blob_reset(&cksum); |
| 484 | 486 | trid = content_put_ex(&artifact, 0, 0, 0, 0); |
| 485 | 487 | if( trid==0 ){ |
| 486 | - fossil_fatal("Error saving tag artifact: %s", g.zErrMsg); | |
| 488 | + return -1; | |
| 487 | 489 | } |
| 488 | 490 | if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){ |
| 489 | - fossil_fatal("%s", g.zErrMsg); | |
| 491 | + return -2; | |
| 490 | 492 | } |
| 491 | 493 | assert( blob_is_reset(&artifact) ); |
| 492 | 494 | db_add_unsent(trid); |
| 493 | 495 | admin_log("Tag forum post %S with %c%s", |
| 494 | 496 | zUuid, addTag ? '*' : '-', zTagName); |
| 495 | 497 | fossil_free(zUuid); |
| 496 | 498 | 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); | |
| 498 | 536 | } |
| 499 | 537 | |
| 500 | 538 | /* |
| 501 | 539 | ** Returns true if the forum-close-policy setting is true, else false, |
| 502 | 540 | ** caching the result for subsequent calls. |
| @@ -894,11 +932,19 @@ | ||
| 894 | 932 | break; |
| 895 | 933 | } |
| 896 | 934 | } |
| 897 | 935 | if( !sCurrent ) sCurrent = &fss->aStatus[0]; |
| 898 | 936 | 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>\ | |
| 900 | 946 | if( forum_may_set_status(fp->fpid) ){ |
| 901 | 947 | @ <form method="post" action='%R/forumpost_status'> |
| 902 | 948 | login_insert_csrf_secret(); |
| 903 | 949 | @ <input type='hidden' name='fpid' value='%s(fp->zUuid)' /> |
| 904 | 950 | @ <select name='status' data-fpid='%s(fp->zUuid)'\ |
| @@ -917,11 +963,11 @@ | ||
| 917 | 963 | @ </form> |
| 918 | 964 | /* Form is activated in fossil.page.forumpost.js */ |
| 919 | 965 | }else{ |
| 920 | 966 | @ <button disabled>Status: %h(sCurrent->zLabel)</button> |
| 921 | 967 | } |
| 922 | - @ </span> | |
| 968 | + @ </fieldset> | |
| 923 | 969 | fossil_free(zCurrent); |
| 924 | 970 | } |
| 925 | 971 | } |
| 926 | 972 | |
| 927 | 973 | /* |
| @@ -1045,17 +1091,26 @@ | ||
| 1045 | 1091 | /* |
| 1046 | 1092 | ** Renders the attachment list for the given forum post. |
| 1047 | 1093 | ** Emits no output if there are no attachments. |
| 1048 | 1094 | */ |
| 1049 | 1095 | 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, "📎 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 | |
| 1057 | 1112 | } |
| 1058 | 1113 | |
| 1059 | 1114 | /* |
| 1060 | 1115 | ** Renders the attachment list for p or (if not NULL) pEditHead. |
| 1061 | 1116 | */ |
| @@ -1098,24 +1153,33 @@ | ||
| 1098 | 1153 | |
| 1099 | 1154 | /* Get the manifest for the post. Abort if not found (e.g. shunned). */ |
| 1100 | 1155 | pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0); |
| 1101 | 1156 | if( !pManifest ) return; |
| 1102 | 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; | |
| 1103 | 1161 | /* When not in raw mode, create the border around the post. */ |
| 1104 | 1162 | if( !bRaw ){ |
| 1105 | 1163 | /* Open the <div> enclosing the post. Set the class string to mark the post |
| 1106 | 1164 | ** as selected and/or obsolete. */ |
| 1107 | 1165 | 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\ | |
| 1109 | 1167 | @ %s(bSelect ? " forumSel" : "")\ |
| 1110 | 1168 | @ %s(iClosed ? " forumClosed" : "")\ |
| 1111 | 1169 | @ %s(p->pEditTail ? " forumObs" : "")' \ |
| 1112 | 1170 | 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)" \ | |
| 1116 | 1179 | } |
| 1180 | + @ data-fpid="%s(p->zUuid)">\ | |
| 1117 | 1181 | |
| 1118 | 1182 | /* If this is the first post (or an edit thereof), emit the thread title. */ |
| 1119 | 1183 | if( pManifest->zThreadTitle ){ |
| 1120 | 1184 | @ <h1>%h(pManifest->zThreadTitle)</h1> |
| 1121 | 1185 | } |
| @@ -1196,32 +1260,33 @@ | ||
| 1196 | 1260 | /* Provide a link to the raw source code. */ |
| 1197 | 1261 | if( !bUnf ){ |
| 1198 | 1262 | @ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a> |
| 1199 | 1263 | } |
| 1200 | 1264 | @ </h3> |
| 1265 | + | |
| 1266 | + if( bPrivate && (bSameUser || g.perm.Admin || g.perm.ModForum) ){ | |
| 1267 | + moderation_pending_www(p->fpid); | |
| 1268 | + } | |
| 1201 | 1269 | }/*!bRaw*/ |
| 1202 | 1270 | |
| 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. */ | |
| 1209 | 1273 | if( bPrivate && !g.perm.ModForum && !bSameUser ){ |
| 1210 | 1274 | @ <p><span class="modpending">Awaiting Moderator Approval</span></p> |
| 1211 | 1275 | }else{ |
| 1212 | 1276 | if( bRaw || bUnf || p->pEditTail ){ |
| 1213 | 1277 | zMimetype = "text/plain"; |
| 1214 | 1278 | }else{ |
| 1215 | 1279 | zMimetype = pManifest->zMimetype; |
| 1216 | 1280 | } |
| 1217 | 1281 | forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw); |
| 1218 | - forum_render_attachment_list2(p); | |
| 1219 | 1282 | } |
| 1220 | 1283 | |
| 1221 | 1284 | /* When not in raw mode, finish creating the border around the post. */ |
| 1222 | 1285 | if( !bRaw ){ |
| 1286 | + int bBrBeforeAttach = 0; /* Layout kludge for Attach button */ | |
| 1287 | + forum_render_attachment_list2(p); | |
| 1223 | 1288 | /* If the user is able to write to the forum and if this post has not been |
| 1224 | 1289 | ** edited, create a form with various interaction buttons. */ |
| 1225 | 1290 | if( g.perm.WrForum && !p->pEditTail ){ |
| 1226 | 1291 | @ <div class="forumpost-single-controls">\ |
| 1227 | 1292 | @ <form action="%R/forumedit" method="POST"> |
| @@ -1250,10 +1315,11 @@ | ||
| 1250 | 1315 | @ <br><label><input type="checkbox" name="trust"> |
| 1251 | 1316 | @ Trust user "%h(pManifest->zUser)" so that future posts by \ |
| 1252 | 1317 | @ "%h(pManifest->zUser)" do not require moderation. |
| 1253 | 1318 | @ </label> |
| 1254 | 1319 | @ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)"> |
| 1320 | + bBrBeforeAttach = 1 /* slightly unmangle the layout */; | |
| 1255 | 1321 | } |
| 1256 | 1322 | }else if( bSameUser ){ |
| 1257 | 1323 | /* Allow users to delete (reject) their own pending posts. */ |
| 1258 | 1324 | @ <input type="submit" name="reject" value="Delete"> |
| 1259 | 1325 | } |
| @@ -1273,23 +1339,19 @@ | ||
| 1273 | 1339 | @ %s(iClosed ? "action-reopen" : "action-close")'/> |
| 1274 | 1340 | /* ^^^ activated by fossil.page.forumpost.js */ |
| 1275 | 1341 | } |
| 1276 | 1342 | @ </form> |
| 1277 | 1343 | } |
| 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) ){ | |
| 1281 | 1345 | /* When an admin edits someone else's post, the admin |
| 1282 | 1346 | ** effectively takes over ownership of it (and we currently |
| 1283 | 1347 | ** have no way of passing it back). Because of this, we |
| 1284 | 1348 | ** 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); | |
| 1291 | 1353 | } |
| 1292 | 1354 | } |
| 1293 | 1355 | @ </div> |
| 1294 | 1356 | } |
| 1295 | 1357 | if( !p->pIrt && (flags & FDISPLAY_SELECTED)){ |
| @@ -1475,11 +1537,12 @@ | ||
| 1475 | 1537 | ** to all forum-related pages. It does not include page-specific |
| 1476 | 1538 | ** code (e.g. "forum.js"). |
| 1477 | 1539 | */ |
| 1478 | 1540 | static void forum_emit_js(void){ |
| 1479 | 1541 | builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer", |
| 1480 | - NULL); | |
| 1542 | + "attach", "tabs", "storage", | |
| 1543 | + "popupwidget", NULL); | |
| 1481 | 1544 | builtin_request_js("fossil.page.forumpost.js"); |
| 1482 | 1545 | } |
| 1483 | 1546 | |
| 1484 | 1547 | /* |
| 1485 | 1548 | ** WEBPAGE: forumpost |
| @@ -1635,21 +1698,13 @@ | ||
| 1635 | 1698 | if( g.perm.WrTForum ) return 0; |
| 1636 | 1699 | if( g.perm.ModForum ) return 0; |
| 1637 | 1700 | return 1; |
| 1638 | 1701 | } |
| 1639 | 1702 | |
| 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 | 1703 | /* Flags for use with forum_post() */ |
| 1650 | 1704 | #define FPOST_NO_ALERT 1 /* do not send any alerts */ |
| 1705 | +#define FPOST_DRYRUN 2 /* do not save the artifact */ | |
| 1651 | 1706 | |
| 1652 | 1707 | /* |
| 1653 | 1708 | ** Return a flags value for use with the final argument to |
| 1654 | 1709 | ** forum_post(), extracted from the CGI environment. |
| 1655 | 1710 | */ |
| @@ -1656,10 +1711,13 @@ | ||
| 1656 | 1711 | static int forum_post_flags(void){ |
| 1657 | 1712 | int iPostFlags = 0; |
| 1658 | 1713 | if( g.perm.Debug && P("fpsilent")!=0 ){ |
| 1659 | 1714 | iPostFlags |= FPOST_NO_ALERT; |
| 1660 | 1715 | } |
| 1716 | + if( P("dryrun")!=0 ){ | |
| 1717 | + iPostFlags |= FPOST_DRYRUN; | |
| 1718 | + } | |
| 1661 | 1719 | return iPostFlags; |
| 1662 | 1720 | } |
| 1663 | 1721 | |
| 1664 | 1722 | /* |
| 1665 | 1723 | ** Add a new Forum Post artifact to the repository. |
| @@ -1687,11 +1745,11 @@ | ||
| 1687 | 1745 | if( !g.perm.Admin && (iEdit || iInReplyTo) |
| 1688 | 1746 | && forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){ |
| 1689 | 1747 | forumpost_error_closed(); |
| 1690 | 1748 | return 0; |
| 1691 | 1749 | } |
| 1692 | - if( iEdit==0 && whitespace_only(zContent) ){ | |
| 1750 | + if( iEdit==0 && fossil_all_whitespace(zContent) ){ | |
| 1693 | 1751 | return 0; |
| 1694 | 1752 | } |
| 1695 | 1753 | if( iInReplyTo==0 && iEdit>0 ){ |
| 1696 | 1754 | iBasis = iEdit; |
| 1697 | 1755 | iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit); |
| @@ -1712,17 +1770,19 @@ | ||
| 1712 | 1770 | fossil_free(zG); |
| 1713 | 1771 | } |
| 1714 | 1772 | if( zTitle ){ |
| 1715 | 1773 | blob_appendf(&x, "H %F\n", zTitle); |
| 1716 | 1774 | } |
| 1717 | - zI = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iInReplyTo); | |
| 1775 | + zI = rid_to_uuid(iInReplyTo); | |
| 1718 | 1776 | if( zI ){ |
| 1719 | 1777 | blob_appendf(&x, "I %s\n", zI); |
| 1720 | 1778 | fossil_free(zI); |
| 1721 | 1779 | } |
| 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); | |
| 1724 | 1784 | } |
| 1725 | 1785 | if( iEdit>0 ){ |
| 1726 | 1786 | char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit); |
| 1727 | 1787 | if( zP==0 ) webpage_error("missing edit artifact %d", iEdit); |
| 1728 | 1788 | blob_appendf(&x, "P %s\n", zP); |
| @@ -1750,11 +1810,11 @@ | ||
| 1750 | 1810 | webpage_error("malformed forum post artifact - %s", blob_str(&errMsg)); |
| 1751 | 1811 | } |
| 1752 | 1812 | webpage_assert( pPost->type==CFTYPE_FORUM ); |
| 1753 | 1813 | manifest_destroy(pPost); |
| 1754 | 1814 | |
| 1755 | - if( P("dryrun") ){ | |
| 1815 | + if( (iFlags & FPOST_DRYRUN)!=0 ){ | |
| 1756 | 1816 | @ <div class='debug'> |
| 1757 | 1817 | @ This is the artifact that would have been generated: |
| 1758 | 1818 | @ <pre>%h(blob_str(&x))</pre> |
| 1759 | 1819 | @ </div> |
| 1760 | 1820 | blob_reset(&x); |
| @@ -1813,12 +1873,17 @@ | ||
| 1813 | 1873 | int addTag, int validFpid){ |
| 1814 | 1874 | if( !cgi_csrf_safe(2) ){ |
| 1815 | 1875 | webpage_error("CSRF validation failed"); |
| 1816 | 1876 | }else{ |
| 1817 | 1877 | 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 | + } | |
| 1820 | 1885 | } |
| 1821 | 1886 | } |
| 1822 | 1887 | |
| 1823 | 1888 | /* |
| 1824 | 1889 | ** WEBPAGE: forumpost_close hidden |
| @@ -1965,11 +2030,11 @@ | ||
| 1965 | 2030 | @ it.</div> |
| 1966 | 2031 | } |
| 1967 | 2032 | } |
| 1968 | 2033 | |
| 1969 | 2034 | /* |
| 1970 | -** WEBPAGE: forume1 | |
| 2035 | +** WEBPAGE: forume1 hidden | |
| 1971 | 2036 | ** |
| 1972 | 2037 | ** Start a new forum thread. |
| 1973 | 2038 | */ |
| 1974 | 2039 | void forumnew_page(void){ |
| 1975 | 2040 | const char *zTitle = PDT("title",""); |
| @@ -1983,36 +2048,45 @@ | ||
| 1983 | 2048 | } |
| 1984 | 2049 | if( P("submit") && cgi_csrf_safe(2) ){ |
| 1985 | 2050 | if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent, |
| 1986 | 2051 | forum_post_flags()) ) return; |
| 1987 | 2052 | } |
| 1988 | - if( P("preview") && !whitespace_only(zContent) ){ | |
| 2053 | + if( P("preview") && !fossil_all_whitespace(zContent) ){ | |
| 1989 | 2054 | @ <h1>Preview:</h1> |
| 1990 | 2055 | forum_render(zTitle, zMimetype, zContent, "forumEdit", 1); |
| 1991 | 2056 | } |
| 1992 | 2057 | style_set_current_feature("forum"); |
| 1993 | 2058 | 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"> | |
| 1995 | 2062 | @ <h1>New Thread:</h1> |
| 1996 | 2063 | forum_from_line(); |
| 1997 | 2064 | forum_post_widget(zTitle, zMimetype, zContent); |
| 1998 | 2065 | @ <input type="submit" name="preview" value="Preview"> |
| 1999 | - if( P("preview") && !whitespace_only(zContent) ){ | |
| 2066 | + if( P("preview") && !fossil_all_whitespace(zContent) ){ | |
| 2000 | 2067 | @ <input type="submit" name="submit" value="Submit"> |
| 2001 | 2068 | }else{ |
| 2002 | 2069 | @ <input type="submit" name="submit" value="Submit" disabled> |
| 2003 | 2070 | } |
| 2004 | 2071 | forum_render_debug_options(); |
| 2005 | 2072 | login_insert_csrf_secret(); |
| 2006 | 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> | |
| 2007 | 2081 | forum_render_attachment_notice(); |
| 2008 | 2082 | forum_emit_js(); |
| 2009 | 2083 | style_finish_page(); |
| 2010 | 2084 | } |
| 2011 | 2085 | |
| 2012 | 2086 | /* |
| 2013 | -** WEBPAGE: forume2 | |
| 2087 | +** WEBPAGE: forume2 hidden | |
| 2014 | 2088 | ** |
| 2015 | 2089 | ** Edit an existing forum message. |
| 2016 | 2090 | ** Query parameters: |
| 2017 | 2091 | ** |
| 2018 | 2092 | ** fpid=X Hash of the post to be edited. REQUIRED |
| @@ -2097,11 +2171,11 @@ | ||
| 2097 | 2171 | style_set_current_feature("forum"); |
| 2098 | 2172 | isDelete = P("nullout")!=0; |
| 2099 | 2173 | if( P("submit") |
| 2100 | 2174 | && isCsrfSafe |
| 2101 | 2175 | && (zContent = PDT("content",""))!=0 |
| 2102 | - && (!whitespace_only(zContent) || isDelete) | |
| 2176 | + && (isDelete || !fossil_all_whitespace(zContent)) | |
| 2103 | 2177 | ){ |
| 2104 | 2178 | int done = 1; |
| 2105 | 2179 | const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE); |
| 2106 | 2180 | if( bReply ){ |
| 2107 | 2181 | done = forum_post(0, fpid, 0, 0, zMimetype, zContent, |
| @@ -2175,11 +2249,11 @@ | ||
| 2175 | 2249 | zDisplayName = display_name_from_login(pPost->zUser); |
| 2176 | 2250 | @ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3> |
| 2177 | 2251 | fossil_free(zDisplayName); |
| 2178 | 2252 | fossil_free(zDate); |
| 2179 | 2253 | forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1); |
| 2180 | - if( bPreview && !whitespace_only(zContent) ){ | |
| 2254 | + if( bPreview && !fossil_all_whitespace(zContent) ){ | |
| 2181 | 2255 | @ <h2>Preview:</h2> |
| 2182 | 2256 | forum_render(0, zMimetype,zContent, "forumEdit", 1); |
| 2183 | 2257 | } |
| 2184 | 2258 | @ <h2>Enter Reply:</h2> |
| 2185 | 2259 | @ <form action="%R/forume2" method="POST"> |
| @@ -2190,11 +2264,11 @@ | ||
| 2190 | 2264 | } |
| 2191 | 2265 | if( !isDelete ){ |
| 2192 | 2266 | @ <input type="submit" name="preview" value="Preview"> |
| 2193 | 2267 | } |
| 2194 | 2268 | @ <input type="submit" name="cancel" value="Cancel"> |
| 2195 | - if( (bPreview && !whitespace_only(zContent)) || isDelete ){ | |
| 2269 | + if( isDelete || (bPreview && !fossil_all_whitespace(zContent)) ){ | |
| 2196 | 2270 | if( !iClosed || g.perm.Admin ) { |
| 2197 | 2271 | @ <input type="submit" name="submit" value="Submit"> |
| 2198 | 2272 | } |
| 2199 | 2273 | } |
| 2200 | 2274 | forum_render_debug_options(); |
| @@ -2201,11 +2275,13 @@ | ||
| 2201 | 2275 | login_insert_csrf_secret(); |
| 2202 | 2276 | @ </form> |
| 2203 | 2277 | if( !bReply ){ |
| 2204 | 2278 | forum_render_attachment_list(rid_to_uuid(fpid)); |
| 2205 | 2279 | } |
| 2206 | - forum_render_attachment_notice(); | |
| 2280 | + if( !isDelete ){ | |
| 2281 | + forum_render_attachment_notice(); | |
| 2282 | + } | |
| 2207 | 2283 | forum_emit_js(); |
| 2208 | 2284 | style_finish_page(); |
| 2209 | 2285 | } |
| 2210 | 2286 | |
| 2211 | 2287 | /* |
| @@ -2214,21 +2290,21 @@ | ||
| 2214 | 2290 | ** to closed posts. If false, only administrators may do so. Note that |
| 2215 | 2291 | ** this only affects the forum web UI, not post-closing tags which |
| 2216 | 2292 | ** arrive via the command-line or from synchronization with a remote. |
| 2217 | 2293 | ** This policy also determines whether moderators may delete forum |
| 2218 | 2294 | ** attachments. |
| 2219 | -*/ | |
| 2220 | -/* | |
| 2295 | +** | |
| 2221 | 2296 | ** SETTING: forum-title width=20 default=Forum |
| 2222 | 2297 | ** This is the name or "title" of the Forum for this repository. The |
| 2223 | 2298 | ** default is just "Forum". But in some setups, admins might want to |
| 2224 | 2299 | ** change it to "Developer Forum" or "User Forum" or whatever other name |
| 2225 | 2300 | ** seems more appropriate for the particular usage. |
| 2226 | 2301 | ** |
| 2227 | 2302 | ** 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. | |
| 2230 | 2306 | ** |
| 2231 | 2307 | ** SETTING: forum-statuses width=40 block-text |
| 2232 | 2308 | ** This JSON5-formatted value defines an array of objects describing |
| 2233 | 2309 | ** the available statuses of forum posts. Each entry of the array must |
| 2234 | 2310 | ** be an object in the form {label:"X",value:"Y"}. |
| @@ -2723,5 +2799,388 @@ | ||
| 2723 | 2799 | ** URL arg when the status selection list is activated. */ |
| 2724 | 2800 | forum_emit_js(); |
| 2725 | 2801 | } |
| 2726 | 2802 | style_finish_page(); |
| 2727 | 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 | +} | |
| 2728 | 3187 | |
| 2729 | 3188 | 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, "📎 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 |
+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); |
| --- 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); |
+8
-5
| --- src/fossil.bootstrap.js | ||
| +++ src/fossil.bootstrap.js | ||
| @@ -17,10 +17,14 @@ | ||
| 17 | 17 | initialized that object. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | const F = global.fossil; |
| 21 | 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 | + | |
| 22 | 26 | /** |
| 23 | 27 | Returns the current time in something approximating |
| 24 | 28 | ISO-8601 format. |
| 25 | 29 | */ |
| 26 | 30 | const timestring = function f(){ |
| @@ -54,11 +58,11 @@ | ||
| 54 | 58 | ** removed from the object. Pass it a falsy value to clear the target |
| 55 | 59 | ** element. |
| 56 | 60 | ** |
| 57 | 61 | ** Returns this object. |
| 58 | 62 | */ |
| 59 | - F.message = function f(msg){ | |
| 63 | + F.message = function f(){ | |
| 60 | 64 | const args = Array.prototype.slice.call(arguments,0); |
| 61 | 65 | const tgt = f.targetElement; |
| 62 | 66 | if(args.length) args.unshift( |
| 63 | 67 | localTimeString()+':' |
| 64 | 68 | //timestring(),'UTC:' |
| @@ -84,26 +88,25 @@ | ||
| 84 | 88 | F.message.targetElement.addEventListener( |
| 85 | 89 | 'dblclick', ()=>F.message(), false |
| 86 | 90 | ); |
| 87 | 91 | } |
| 88 | 92 | /* |
| 89 | - ** By default fossil.error() sends its first argument to | |
| 93 | + ** By default fossil.error() sends all arguments to | |
| 90 | 94 | ** console.error(). If fossil.message.targetElement (yes, |
| 91 | 95 | ** fossil.message) is set, it adds the 'error' CSS class to |
| 92 | 96 | ** that element and sets its content as defined for message(). |
| 93 | 97 | ** |
| 94 | 98 | ** Returns this object. |
| 95 | 99 | */ |
| 96 | - F.error = function f(msg){ | |
| 100 | + F.error = function f(){ | |
| 97 | 101 | const args = Array.prototype.slice.call(arguments,0); |
| 98 | 102 | const tgt = F.message.targetElement; |
| 99 | 103 | args.unshift(timestring(),'UTC:'); |
| 100 | 104 | if(tgt){ |
| 101 | 105 | tgt.classList.add('error'); |
| 102 | 106 | tgt.innerText = args.join(' '); |
| 103 | - } | |
| 104 | - else{ | |
| 107 | + }else{ | |
| 105 | 108 | args.unshift('Fossil error:'); |
| 106 | 109 | console.error.apply(console,args); |
| 107 | 110 | } |
| 108 | 111 | return this; |
| 109 | 112 | }; |
| 110 | 113 |
| --- 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 |
+4
| --- src/fossil.dom.js | ||
| +++ src/fossil.dom.js | ||
| @@ -80,10 +80,14 @@ | ||
| 80 | 80 | Returns a LABEL element. If passed an argument, |
| 81 | 81 | it must be an id or an HTMLElement with an id, |
| 82 | 82 | and that id is set as the 'for' attribute of the |
| 83 | 83 | label. If passed 2 arguments, the 2nd is text or |
| 84 | 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. | |
| 85 | 89 | */ |
| 86 | 90 | dom.label = function(forElem, text){ |
| 87 | 91 | const rc = document.createElement('label'); |
| 88 | 92 | if(forElem){ |
| 89 | 93 | if(forElem instanceof HTMLElement){ |
| 90 | 94 |
| --- 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 |
+1227
-12
| --- 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 | +*/ | |
| 1 | 5 | (function(F/*the fossil object*/){ |
| 2 | 6 | "use strict"; |
| 3 | 7 | /* JS code for /forumpost and friends. Requires fossil.dom |
| 4 | 8 | and can optionally use fossil.pikchr. */ |
| 5 | 9 | const P = F.page, D = F.dom; |
| 6 | 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 | + | |
| 7 | 832 | /** |
| 8 | 833 | When the page is loaded, this handler does the following: |
| 9 | 834 | |
| 10 | - - Installs expand/collapse UI elements on "long" posts and collapses | |
| 835 | + 1. Installs expand/collapse UI elements on "long" posts and collapses | |
| 11 | 836 | them. |
| 12 | 837 | |
| 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 | |
| 14 | 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. | |
| 15 | 842 | |
| 16 | 843 | 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. | |
| 20 | 848 | */ |
| 21 | 849 | F.onPageLoad(function(){ |
| 22 | 850 | const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight; |
| 23 | 851 | /* Returns an event handler which implements the post expand/collapse toggle |
| 24 | 852 | on contentElem when the given widget is activated. */ |
| @@ -105,15 +933,20 @@ | ||
| 105 | 933 | const eStatus = document.querySelector( |
| 106 | 934 | 'form div.submenu select.submenuctrl[name="status"]' |
| 107 | 935 | ); |
| 108 | 936 | if( eStatus ){ |
| 109 | 937 | /* 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. */ | |
| 111 | 944 | const pForm = eStatus.parentElement?.parentElement; |
| 112 | 945 | if( pForm ){ |
| 113 | 946 | eStatus.addEventListener('change', ()=>{ |
| 114 | - pForm.querySelector('input[type="hidden"][name="x"]')?.remove(); | |
| 947 | + pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.(); | |
| 115 | 948 | }, true); |
| 116 | 949 | } |
| 117 | 950 | }else{ |
| 118 | 951 | /* One of the single-post edit/view pages. Handle various UI |
| 119 | 952 | controls and attempt to keep stray double-clicks from |
| @@ -126,16 +959,21 @@ | ||
| 126 | 959 | return; |
| 127 | 960 | } |
| 128 | 961 | form.dataset.submitted = '1'; |
| 129 | 962 | /** If the user is left waiting "a long time," disable the |
| 130 | 963 | 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. */ | |
| 133 | 968 | setTimeout(()=>{delete form.dataset.submitted}, 7000); |
| 134 | 969 | return; |
| 135 | 970 | }; |
| 971 | + | |
| 136 | 972 | document.querySelectorAll("form").forEach(function(form){ |
| 973 | + /* Set up controls for closing posts and setting thread | |
| 974 | + status. */ | |
| 137 | 975 | form.addEventListener('submit', formSubmitted); |
| 138 | 976 | form |
| 139 | 977 | .querySelectorAll("input.action-close, input.action-reopen") |
| 140 | 978 | .forEach(function(e){ |
| 141 | 979 | e.classList.remove('hidden'); |
| @@ -149,11 +987,13 @@ | ||
| 149 | 987 | form |
| 150 | 988 | .querySelectorAll("input[type='button'].action-status") |
| 151 | 989 | .forEach(function(btn){ |
| 152 | 990 | btn.classList.remove('hidden'); |
| 153 | 991 | const sel = btn.previousElementSibling; |
| 154 | - const updateAble = ()=>{ | |
| 992 | + const updateButton = ()=>{ | |
| 993 | + /* Enable btn only when the status has been locally | |
| 994 | + modified. */ | |
| 155 | 995 | if( sel.dataset.initialValue ){ |
| 156 | 996 | if( sel.dataset.initialValue===sel.value ){ |
| 157 | 997 | btn.setAttribute('disabled',''); |
| 158 | 998 | }else{ |
| 159 | 999 | btn.removeAttribute('disabled'); |
| @@ -164,17 +1004,392 @@ | ||
| 164 | 1004 | }else{ |
| 165 | 1005 | btn.removeAttribute('disabled'); |
| 166 | 1006 | } |
| 167 | 1007 | } |
| 168 | 1008 | }; |
| 169 | - sel.addEventListener('change', updateAble, true); | |
| 170 | - updateAble(); | |
| 1009 | + sel.addEventListener('change', updateButton, true); | |
| 1010 | + updateButton(); | |
| 171 | 1011 | F.confirmer(btn, { |
| 172 | 1012 | confirmText: "Confirm status change", |
| 173 | 1013 | onconfirm: ()=>form.submit() |
| 174 | 1014 | }); |
| 175 | 1015 | }); |
| 176 | 1016 | }); |
| 177 | 1017 | } |
| 178 | 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 | + } | |
| 179 | 1394 | })/*F.onPageLoad callback*/; |
| 180 | 1395 | })(window.fossil); |
| 181 | 1396 |
| --- 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 |
+5
-6
| --- src/fossil.page.wikiedit.js | ||
| +++ src/fossil.page.wikiedit.js | ||
| @@ -1187,11 +1187,11 @@ | ||
| 1187 | 1187 | if(!wi.attachments || !wi.attachments.length){ |
| 1188 | 1188 | D.append(f.eAttach, |
| 1189 | 1189 | btnReload, |
| 1190 | 1190 | " No attachments found for page ["+wi.name+"]. ", |
| 1191 | 1191 | D.a(F.repoUrl('attachadd',{ |
| 1192 | - page: wi.name, | |
| 1192 | + target: wi.name, | |
| 1193 | 1193 | from: F.repoUrl('wikiedit',{name: wi.name})}), |
| 1194 | 1194 | "Add attachments..." ) |
| 1195 | 1195 | ); |
| 1196 | 1196 | return this; |
| 1197 | 1197 | } |
| @@ -1198,14 +1198,14 @@ | ||
| 1198 | 1198 | D.append( |
| 1199 | 1199 | f.eAttach, |
| 1200 | 1200 | D.append(D.p(), |
| 1201 | 1201 | btnReload," ", |
| 1202 | 1202 | D.a(F.repoUrl('attachlist',{page:wi.name}), |
| 1203 | - "Attachments for page ["+wi.name+"]."), | |
| 1204 | - " ", | |
| 1203 | + "Attachments for page ["+wi.name+"]"), | |
| 1204 | + ". ", | |
| 1205 | 1205 | D.a(F.repoUrl('attachadd',{ |
| 1206 | - page:wi.name, | |
| 1206 | + target:wi.name, | |
| 1207 | 1207 | from: F.repoUrl('wikiedit',{name: wi.name})}), |
| 1208 | 1208 | "Add attachments..." ) |
| 1209 | 1209 | ) |
| 1210 | 1210 | ); |
| 1211 | 1211 | wi.attachments.forEach(function(a){ |
| @@ -1388,11 +1388,10 @@ | ||
| 1388 | 1388 | setting. |
| 1389 | 1389 | */ |
| 1390 | 1390 | P.baseHrefRestore = function(){ |
| 1391 | 1391 | this.base.tag.href = this.base.originalHref; |
| 1392 | 1392 | }; |
| 1393 | - | |
| 1394 | 1393 | |
| 1395 | 1394 | /** |
| 1396 | 1395 | loadPage() loads the given wiki page and updates the relevant |
| 1397 | 1396 | UI elements to reflect the loaded state. If passed no arguments |
| 1398 | 1397 | then it re-uses the values from the currently-loaded page, reloading |
| @@ -1454,11 +1453,11 @@ | ||
| 1454 | 1453 | onload(r); |
| 1455 | 1454 | } |
| 1456 | 1455 | }); |
| 1457 | 1456 | return this; |
| 1458 | 1457 | }; |
| 1459 | - | |
| 1458 | + | |
| 1460 | 1459 | /** |
| 1461 | 1460 | Fetches the page preview based on the contents and settings of |
| 1462 | 1461 | this page's input fields, and updates the UI with the |
| 1463 | 1462 | preview. |
| 1464 | 1463 | |
| 1465 | 1464 |
| --- 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 |
+10
-10
| --- src/fossil.popupwidget.js | ||
| +++ src/fossil.popupwidget.js | ||
| @@ -55,11 +55,11 @@ | ||
| 55 | 55 | from the default), the class "fossil-PopupWidget" is always set |
| 56 | 56 | in order to allow certain app-internal CSS to account for popup |
| 57 | 57 | windows in special cases. |
| 58 | 58 | |
| 59 | 59 | .style: optional object of properties to copy directly into |
| 60 | - the element's style object. | |
| 60 | + the element's style object. | |
| 61 | 61 | |
| 62 | 62 | The options passed to this constructor get normalized into a |
| 63 | 63 | separate object which includes any default values for options not |
| 64 | 64 | provided by the caller. That object is available this the |
| 65 | 65 | resulting PopupWidget's options property. Default values for any |
| @@ -161,11 +161,11 @@ | ||
| 161 | 161 | Sidebar: showing/hiding the widget is, as is conventional for |
| 162 | 162 | this framework, done by removing/adding the 'hidden' CSS class |
| 163 | 163 | to it, so that class must be defined appropriately. |
| 164 | 164 | */ |
| 165 | 165 | show: function(){ |
| 166 | - var x = undefined, y = undefined, showIt, | |
| 166 | + let x = undefined, y = undefined, showIt, | |
| 167 | 167 | wasShown = !this.e.classList.contains('hidden'); |
| 168 | 168 | if(2===arguments.length){ |
| 169 | 169 | x = arguments[0]; |
| 170 | 170 | y = arguments[1]; |
| 171 | 171 | showIt = true; |
| @@ -344,11 +344,12 @@ | ||
| 344 | 344 | - No arguments, which is equivalent to passing the string |
| 345 | 345 | ".help-buttonlet:not(.processed)". |
| 346 | 346 | |
| 347 | 347 | Passing the same element(s) more than once is a no-op: during |
| 348 | 348 | 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. | |
| 350 | 351 | |
| 351 | 352 | All child nodes of a help buttonlet are removed from the button |
| 352 | 353 | during initialization and stashed away for use in a PopupWidget |
| 353 | 354 | when the botton is clicked. |
| 354 | 355 | |
| @@ -374,24 +375,24 @@ | ||
| 374 | 375 | calculate the resulting size, then move and/or resize it. |
| 375 | 376 | |
| 376 | 377 | This algorithm/these heuristics can certainly be improved |
| 377 | 378 | upon. |
| 378 | 379 | */ |
| 379 | - var popupRect, rectElem = ev.target; | |
| 380 | + let popupRect, rectElem = ev.target; | |
| 380 | 381 | while(rectElem){ |
| 381 | 382 | popupRect = rectElem.getClientRects()[0]/*undefined if off-screen!*/; |
| 382 | 383 | if(popupRect) break; |
| 383 | 384 | rectElem = rectElem.parentNode; |
| 384 | 385 | } |
| 385 | 386 | 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; | |
| 387 | 388 | if(x<0) x = 0; |
| 388 | 389 | if(y<0) y = 0; |
| 389 | 390 | if(rectElem){ |
| 390 | 391 | /* Try to ensure that the popup's z-level is higher than this element's */ |
| 391 | 392 | const rz = window.getComputedStyle(rectElem).zIndex; |
| 392 | - var myZ; | |
| 393 | + let myZ; | |
| 393 | 394 | if(rz && !isNaN(+rz)){ |
| 394 | 395 | myZ = +rz + 1; |
| 395 | 396 | }else{ |
| 396 | 397 | myZ = 10000/*guess!*/; |
| 397 | 398 | } |
| @@ -416,11 +417,11 @@ | ||
| 416 | 417 | fch.popup.show(x, y); |
| 417 | 418 | return false; |
| 418 | 419 | }; |
| 419 | 420 | f.foreachElement = function(e){ |
| 420 | 421 | if(e.classList.contains('processed')) return; |
| 421 | - e.classList.add('processed'); | |
| 422 | + e.classList.add('processed', 'help-buttonlet'); | |
| 422 | 423 | e.$helpContent = []; |
| 423 | 424 | /* We have to move all child nodes out of the way because we |
| 424 | 425 | cannot hide TEXT nodes via CSS (which cannot select TEXT |
| 425 | 426 | nodes). We have to do it in two steps to avoid invaliding |
| 426 | 427 | the list during traversal. */ |
| @@ -427,11 +428,11 @@ | ||
| 427 | 428 | e.childNodes.forEach((ch)=>e.$helpContent.push(ch)); |
| 428 | 429 | e.$helpContent.forEach((ch)=>ch.remove()); |
| 429 | 430 | e.addEventListener('click', f.clickHandler, false); |
| 430 | 431 | }; |
| 431 | 432 | }/*static init*/ |
| 432 | - var elems; | |
| 433 | + let elems; | |
| 433 | 434 | if(!arguments.length){ |
| 434 | 435 | arguments[0] = '.help-buttonlet:not(.processed)'; |
| 435 | 436 | arguments.length = 1; |
| 436 | 437 | } |
| 437 | 438 | if(arguments.length){ |
| @@ -443,11 +444,11 @@ | ||
| 443 | 444 | elems = arguments[0]; |
| 444 | 445 | } |
| 445 | 446 | } |
| 446 | 447 | if(elems) elems.forEach(f.foreachElement); |
| 447 | 448 | }, |
| 448 | - | |
| 449 | + | |
| 449 | 450 | /** |
| 450 | 451 | Sets up the given element as a "help buttonlet", adding the CSS |
| 451 | 452 | class help-buttonlet to it. Any (optional) arguments after the |
| 452 | 453 | first are appended to the element using fossil.dom.append(), so |
| 453 | 454 | that they become the content for the buttonlet's popup help. |
| @@ -465,7 +466,6 @@ | ||
| 465 | 466 | return elem; |
| 466 | 467 | } |
| 467 | 468 | }/*helpButtonlets*/; |
| 468 | 469 | |
| 469 | 470 | F.onDOMContentLoaded( ()=>F.helpButtonlets.setup() ); |
| 470 | - | |
| 471 | 471 | })(window.fossil); |
| 472 | 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 | 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 |
+62
-7
| --- src/fossil.storage.js | ||
| +++ src/fossil.storage.js | ||
| @@ -82,24 +82,53 @@ | ||
| 82 | 82 | )+'::' : ( |
| 83 | 83 | '' /* transient storage */ |
| 84 | 84 | ) |
| 85 | 85 | ); |
| 86 | 86 | |
| 87 | + /** | |
| 88 | + Proxy for custom events. Created on demand. | |
| 89 | + */ | |
| 90 | + let events; | |
| 87 | 91 | /** |
| 88 | 92 | A proxy for localStorage or sessionStorage or a |
| 89 | 93 | page-instance-local proxy, if neither one is availble. |
| 90 | 94 | |
| 91 | 95 | Which exact storage implementation is uses is unspecified, and |
| 92 | 96 | apps must not rely on it. |
| 93 | 97 | */ |
| 94 | 98 | F.storage = { |
| 95 | 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 | + }, | |
| 96 | 108 | /** 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 | + }, | |
| 99 | 126 | /** 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 | + }, | |
| 101 | 130 | /** Returns the value for the given storage key, or |
| 102 | 131 | dflt if the key is not found in the storage. */ |
| 103 | 132 | get: (k,dflt)=>$storageHolder.hasOwnProperty( |
| 104 | 133 | storageKeyPrefix+k |
| 105 | 134 | ) ? $storage.getItem(storageKeyPrefix+k) : dflt, |
| @@ -121,22 +150,48 @@ | ||
| 121 | 150 | catch(e){return dflt} |
| 122 | 151 | }, |
| 123 | 152 | /** Returns true if the storage contains the given key, |
| 124 | 153 | else false. */ |
| 125 | 154 | 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 | + */ | |
| 127 | 161 | 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 | + } | |
| 129 | 176 | return this; |
| 130 | 177 | }, |
| 131 | 178 | /** Clears ALL keys from the storage. Returns this. */ |
| 132 | 179 | clear: function(){ |
| 133 | 180 | this.keys().forEach((k)=>$storage.removeItem(/*w/o prefix*/k)); |
| 134 | 181 | return this; |
| 135 | 182 | }, |
| 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 | + }, | |
| 138 | 193 | /** Returns true if this storage is transient (only available |
| 139 | 194 | until the page is reloaded), indicating that fileStorage |
| 140 | 195 | and sessionStorage are unavailable. */ |
| 141 | 196 | isTransient: ()=>$storageHolder!==$storage, |
| 142 | 197 | /** Returns a symbolic name for the current storage mechanism. */ |
| 143 | 198 |
| --- 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 |
+3
-2
| --- src/fossil.tabs.js | ||
| +++ src/fossil.tabs.js | ||
| @@ -7,11 +7,11 @@ | ||
| 7 | 7 | /** |
| 8 | 8 | Creates a TabManager. If passed a truthy first argument, it is |
| 9 | 9 | passed to init(). If passed a truthy second argument, it must be |
| 10 | 10 | an Object holding configuration options: |
| 11 | 11 | |
| 12 | - { | |
| 12 | + { | |
| 13 | 13 | tabAccessKeys: boolean (=true) |
| 14 | 14 | If true, tab buttons are assigned "accesskey" values |
| 15 | 15 | equal to their 1-based tab number. |
| 16 | 16 | } |
| 17 | 17 | */ |
| @@ -56,11 +56,11 @@ | ||
| 56 | 56 | /** |
| 57 | 57 | Initializes the tabs associated with the given tab container |
| 58 | 58 | (DOM element or selector for a single element). This must be |
| 59 | 59 | called once before using any other member functions of a given |
| 60 | 60 | instance, noting that the constructor will call this if it is |
| 61 | - passed an argument. | |
| 61 | + passed an argument. | |
| 62 | 62 | |
| 63 | 63 | The tab container must have an 'id' attribute. This function |
| 64 | 64 | looks through the DOM for all elements which have |
| 65 | 65 | data-tab-parent=thatId. For each one it creates a button to |
| 66 | 66 | switch to that tab and moves the element into this.e.tabs, |
| @@ -150,10 +150,11 @@ | ||
| 150 | 150 | e.target.$manager.switchToTab(e.target.$tab); |
| 151 | 151 | }; |
| 152 | 152 | } |
| 153 | 153 | tab = tabArg(tab); |
| 154 | 154 | tab.remove(); |
| 155 | + tab.classList.add('hidden'); | |
| 155 | 156 | D.append(this.e.tabs, D.addClass(tab,'tab-panel')); |
| 156 | 157 | const tabCount = this.e.tabBar.childNodes.length+1; |
| 157 | 158 | const lbl = tab.dataset.tabLabel || 'Tab #'+tabCount; |
| 158 | 159 | const btn = D.addClass(D.append(D.span(), lbl), 'tab-button'); |
| 159 | 160 | D.append(this.e.tabBar,btn); |
| 160 | 161 |
| --- 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 @@ | ||
| 1931 | 1931 | @ Also attachment "%h(zFilename)" to |
| 1932 | 1932 | }else{ |
| 1933 | 1933 | @ Attachment "%h(zFilename)" to |
| 1934 | 1934 | } |
| 1935 | 1935 | objType |= OBJTYPE_ATTACHMENT; |
| 1936 | - switch( attachment_target_type(zTarget) ){ | |
| 1936 | + switch( attachment_target_type(zTarget, 1) ){ | |
| 1937 | 1937 | case CFTYPE_FORUM: |
| 1938 | 1938 | if( g.perm.Hyperlink && g.anon.RdForum ){ |
| 1939 | 1939 | @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>] |
| 1940 | 1940 | }else{ |
| 1941 | 1941 | @ forum post [%S(zTarget)] |
| @@ -1954,16 +1954,20 @@ | ||
| 1954 | 1954 | }else{ |
| 1955 | 1955 | @ tech note [%S(zTarget)] |
| 1956 | 1956 | } |
| 1957 | 1957 | break; |
| 1958 | 1958 | case CFTYPE_WIKI: |
| 1959 | - default /* historical behavior - assume wiki */: | |
| 1960 | 1959 | if( g.perm.Hyperlink && g.anon.RdWiki ){ |
| 1961 | 1960 | @ wiki page [%z(href("%R/wiki?name=%t",zTarget))%h(zTarget)</a>] |
| 1962 | 1961 | }else{ |
| 1963 | 1962 | @ wiki page [%h(zTarget)] |
| 1964 | 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) | |
| 1965 | 1969 | } |
| 1966 | 1970 | @ added by |
| 1967 | 1971 | hyperlink_to_user(zUser,zDate," on"); |
| 1968 | 1972 | hyperlink_to_date(zDate,"."); |
| 1969 | 1973 | cnt++; |
| 1970 | 1974 |
| --- 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 |
+2
| --- src/main.mk | ||
| +++ src/main.mk | ||
| @@ -225,10 +225,11 @@ | ||
| 225 | 225 | $(SRCDIR)/copybtn.js \ |
| 226 | 226 | $(SRCDIR)/default.css \ |
| 227 | 227 | $(SRCDIR)/diff.js \ |
| 228 | 228 | $(SRCDIR)/diff.tcl \ |
| 229 | 229 | $(SRCDIR)/forum.js \ |
| 230 | + $(SRCDIR)/fossil.attach.js \ | |
| 230 | 231 | $(SRCDIR)/fossil.bootstrap.js \ |
| 231 | 232 | $(SRCDIR)/fossil.confirmer.js \ |
| 232 | 233 | $(SRCDIR)/fossil.copybutton.js \ |
| 233 | 234 | $(SRCDIR)/fossil.diff.js \ |
| 234 | 235 | $(SRCDIR)/fossil.dom.js \ |
| @@ -275,10 +276,11 @@ | ||
| 275 | 276 | $(SRCDIR)/sounds/e.wav \ |
| 276 | 277 | $(SRCDIR)/sounds/f.wav \ |
| 277 | 278 | $(SRCDIR)/style.admin_log.css \ |
| 278 | 279 | $(SRCDIR)/style.chat.css \ |
| 279 | 280 | $(SRCDIR)/style.fileedit.css \ |
| 281 | + $(SRCDIR)/style.forum.css \ | |
| 280 | 282 | $(SRCDIR)/style.pikchrshow.css \ |
| 281 | 283 | $(SRCDIR)/style.uvlist.css \ |
| 282 | 284 | $(SRCDIR)/style.wikiedit.css \ |
| 283 | 285 | $(SRCDIR)/tree.js \ |
| 284 | 286 | $(SRCDIR)/useredit.js \ |
| 285 | 287 |
| --- 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 @@ | ||
| 2646 | 2646 | " WHERE target=%Q AND filename=%Q))" |
| 2647 | 2647 | " WHERE target=%Q AND filename=%Q", |
| 2648 | 2648 | p->zAttachTarget, p->zAttachName, |
| 2649 | 2649 | p->zAttachTarget, p->zAttachName |
| 2650 | 2650 | ); |
| 2651 | - switch( attachment_target_type(p->zAttachTarget) ){ | |
| 2651 | + switch( attachment_target_type(p->zAttachTarget, 1) ){ | |
| 2652 | 2652 | case 0: |
| 2653 | 2653 | /* It is possible that p->zAttachTarget is not yet in this |
| 2654 | 2654 | ** copy of the repository. If we cannot identify it yet, |
| 2655 | 2655 | ** generate a generic /artifact link to it instead of a |
| 2656 | 2656 | ** type-specific link or an error message. */ |
| 2657 | 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) ){ |
| 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 @@ | ||
| 384 | 384 | |
| 385 | 385 | /* Use this for the $current_page variable if it is not NULL. If it |
| 386 | 386 | ** is NULL then use g.zPath. |
| 387 | 387 | */ |
| 388 | 388 | static char *local_zCurrentPage = 0; |
| 389 | +static char *local_zCurrentFeature = 0; | |
| 389 | 390 | |
| 390 | 391 | /* |
| 391 | 392 | ** Set the desired $current_page to something other than g.zPath |
| 392 | 393 | */ |
| 393 | 394 | void style_set_current_page(const char *zFormat, ...){ |
| @@ -419,12 +420,13 @@ | ||
| 419 | 420 | |
| 420 | 421 | /* Initialize the URL to its baseline */ |
| 421 | 422 | url = empty_blob; |
| 422 | 423 | blob_appendf(&url, "%R/style.css"); |
| 423 | 424 | |
| 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 | |
| 426 | 428 | ** |
| 427 | 429 | ** /style.css |
| 428 | 430 | ** |
| 429 | 431 | ** But for the "/wikiedit" page (to name but one example), we |
| 430 | 432 | ** append a path as follows: |
| @@ -432,14 +434,25 @@ | ||
| 432 | 434 | ** /style.css/wikiedit |
| 433 | 435 | ** |
| 434 | 436 | ** The /style.css page (implemented below) will detect this extra "wikiedit" |
| 435 | 437 | ** path information and include the page-specific CSS along with the |
| 436 | 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. | |
| 437 | 444 | */ |
| 438 | 445 | zBuiltin = mprintf("style.%s.css", zPage); |
| 439 | 446 | 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 | + } | |
| 441 | 454 | } |
| 442 | 455 | fossil_free(zBuiltin); |
| 443 | 456 | |
| 444 | 457 | /* Add query parameters that will change whenever the skin changes |
| 445 | 458 | ** or after any updates to the CSS files |
| @@ -727,10 +740,12 @@ | ||
| 727 | 740 | ** style_init_th1_vars() because that uses Th_MaybeStore() instead to |
| 728 | 741 | ** allow webpage implementations to call this before style_header() |
| 729 | 742 | ** to override that "maybe" default with something better. |
| 730 | 743 | */ |
| 731 | 744 | void style_set_current_feature(const char* zFeature){ |
| 745 | + fossil_free( local_zCurrentFeature ); | |
| 746 | + local_zCurrentFeature = fossil_strdup(zFeature); | |
| 732 | 747 | Th_Store("current_feature", zFeature); |
| 733 | 748 | } |
| 734 | 749 | |
| 735 | 750 | /* |
| 736 | 751 | ** Returns the current mainmenu value from either the --mainmenu flag |
| @@ -1258,10 +1273,11 @@ | ||
| 1258 | 1273 | "** Page-specific CSS for \"%s\"\n" |
| 1259 | 1274 | "***********************************************************/\n", |
| 1260 | 1275 | zPage); |
| 1261 | 1276 | blob_append(pOut, zBuiltin, nFile); |
| 1262 | 1277 | fossil_free(zFile); |
| 1278 | + zFile = 0; | |
| 1263 | 1279 | return; |
| 1264 | 1280 | } |
| 1265 | 1281 | /* Potential TODO: check for aliases/page groups. e.g. group all |
| 1266 | 1282 | ** /forumXYZ CSS into one file, all /setupXYZ into another, etc. As |
| 1267 | 1283 | ** of this writing, doing so would only shave a few kb from |
| @@ -1418,10 +1434,13 @@ | ||
| 1418 | 1434 | ** For administators, or if the test_env_enable setting is true, then |
| 1419 | 1435 | ** details of the request environment are displayed. Otherwise, just |
| 1420 | 1436 | ** the error message is shown. |
| 1421 | 1437 | ** |
| 1422 | 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. | |
| 1423 | 1442 | */ |
| 1424 | 1443 | void webpage_error(const char *zFormat, ...){ |
| 1425 | 1444 | int showAll = 0; |
| 1426 | 1445 | char *zErr = 0; |
| 1427 | 1446 | int isAuth = 0; |
| 1428 | 1447 | |
| 1429 | 1448 | 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 |
+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; |
| --- 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 @@ | ||
| 749 | 749 | } |
| 750 | 750 | } |
| 751 | 751 | if( g.anon.NewTkt ){ |
| 752 | 752 | style_submenu_element("New Ticket", "%R/tktnew"); |
| 753 | 753 | } |
| 754 | + zFullName = db_text(0, | |
| 755 | + "SELECT tkt_uuid FROM ticket" | |
| 756 | + " WHERE tkt_uuid GLOB '%q*'", zUuid); | |
| 754 | 757 | 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); | |
| 757 | 760 | } |
| 758 | 761 | if( P("plaintext") ){ |
| 759 | 762 | style_submenu_element("Formatted", "%R/tktview/%s", zUuid); |
| 760 | 763 | }else{ |
| 761 | 764 | style_submenu_element("Plaintext", "%R/tktview/%s?plaintext", zUuid); |
| @@ -773,13 +776,10 @@ | ||
| 773 | 776 | } |
| 774 | 777 | } |
| 775 | 778 | if( !showTimeline && g.perm.Hyperlink ){ |
| 776 | 779 | style_submenu_element("Timeline", "%R/info/%T", zUuid); |
| 777 | 780 | } |
| 778 | - zFullName = db_text(0, | |
| 779 | - "SELECT tkt_uuid FROM ticket" | |
| 780 | - " WHERE tkt_uuid GLOB '%q*'", zUuid); | |
| 781 | 781 | if( g.thTrace ) Th_Trace("BEGIN_TKTVIEW<br>\n", -1); |
| 782 | 782 | ticket_init(); |
| 783 | 783 | initializeVariablesFromCGI(); |
| 784 | 784 | getAllTicketFields(); |
| 785 | 785 | initializeVariablesFromDb(); |
| @@ -789,11 +789,16 @@ | ||
| 789 | 789 | safe_html_context(DOCSRC_TICKET); |
| 790 | 790 | Th_Render(zScript); |
| 791 | 791 | if( g.thTrace ) Th_Trace("END_TKTVIEW<br>\n", -1); |
| 792 | 792 | |
| 793 | 793 | 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); | |
| 795 | 800 | } |
| 796 | 801 | |
| 797 | 802 | builtin_fossil_js_bundle_or("dom", "storage", NULL); |
| 798 | 803 | builtin_request_js("fossil.page.ticket.js"); |
| 799 | 804 | builtin_fulfill_js_requests(); |
| 800 | 805 |
| --- 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 @@ | ||
| 626 | 626 | style_submenu_element("Edit", "%R/wikiappend?name=%T", zPageName); |
| 627 | 627 | } |
| 628 | 628 | if( g.perm.Hyperlink ){ |
| 629 | 629 | style_submenu_element("History", "%R/whistory?name=%T", zPageName); |
| 630 | 630 | } |
| 631 | + if( rid>0 && attach_user_may(rid, CFTYPE_WIKI) ){ | |
| 632 | + style_submenu_element("Attach", "%R/attachadd?target=%T", | |
| 633 | + zPageName); | |
| 634 | + } | |
| 631 | 635 | } |
| 632 | 636 | if( !isPopup ){ |
| 633 | 637 | style_set_current_page("%T?name=%T", g.zPath, zPageName); |
| 634 | 638 | wiki_page_header(WIKITYPE_UNKNOWN, zPageName, ""); |
| 635 | 639 | if( !noSubmenu ){ |
| @@ -826,49 +830,15 @@ | ||
| 826 | 830 | ** mtime order. |
| 827 | 831 | */ |
| 828 | 832 | static void wiki_ajax_emit_page_attachments(Manifest * pWiki, |
| 829 | 833 | int latestOnly, |
| 830 | 834 | 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); | |
| 870 | 840 | } |
| 871 | 841 | |
| 872 | 842 | /* |
| 873 | 843 | ** Proxy for wiki_ajax_emit_page_attachments() which attempts to load |
| 874 | 844 | ** the given wiki page artifact. Returns true if it can load the given |
| @@ -1139,10 +1109,12 @@ | ||
| 1139 | 1109 | ** |
| 1140 | 1110 | ** URL params: |
| 1141 | 1111 | ** |
| 1142 | 1112 | ** mimetype = the wiki page mimetype (determines rendering style) |
| 1143 | 1113 | ** content = the wiki page content |
| 1114 | +** | |
| 1115 | +** Responds with a partial HTML document. | |
| 1144 | 1116 | */ |
| 1145 | 1117 | static void wiki_ajax_route_preview(void){ |
| 1146 | 1118 | const char * zContent = P("content"); |
| 1147 | 1119 | |
| 1148 | 1120 | if( zContent==0 ){ |
| 1149 | 1121 |
| --- 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 |
+2
| --- win/Makefile.mingw | ||
| +++ win/Makefile.mingw | ||
| @@ -611,10 +611,11 @@ | ||
| 611 | 611 | $(SRCDIR)/copybtn.js \ |
| 612 | 612 | $(SRCDIR)/default.css \ |
| 613 | 613 | $(SRCDIR)/diff.js \ |
| 614 | 614 | $(SRCDIR)/diff.tcl \ |
| 615 | 615 | $(SRCDIR)/forum.js \ |
| 616 | + $(SRCDIR)/fossil.attach.js \ | |
| 616 | 617 | $(SRCDIR)/fossil.bootstrap.js \ |
| 617 | 618 | $(SRCDIR)/fossil.confirmer.js \ |
| 618 | 619 | $(SRCDIR)/fossil.copybutton.js \ |
| 619 | 620 | $(SRCDIR)/fossil.diff.js \ |
| 620 | 621 | $(SRCDIR)/fossil.dom.js \ |
| @@ -661,10 +662,11 @@ | ||
| 661 | 662 | $(SRCDIR)/sounds/e.wav \ |
| 662 | 663 | $(SRCDIR)/sounds/f.wav \ |
| 663 | 664 | $(SRCDIR)/style.admin_log.css \ |
| 664 | 665 | $(SRCDIR)/style.chat.css \ |
| 665 | 666 | $(SRCDIR)/style.fileedit.css \ |
| 667 | + $(SRCDIR)/style.forum.css \ | |
| 666 | 668 | $(SRCDIR)/style.pikchrshow.css \ |
| 667 | 669 | $(SRCDIR)/style.uvlist.css \ |
| 668 | 670 | $(SRCDIR)/style.wikiedit.css \ |
| 669 | 671 | $(SRCDIR)/tree.js \ |
| 670 | 672 | $(SRCDIR)/useredit.js \ |
| 671 | 673 |
| --- 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 |
+4
| --- win/Makefile.msc | ||
| +++ win/Makefile.msc | ||
| @@ -576,10 +576,11 @@ | ||
| 576 | 576 | "$(SRCDIR)\copybtn.js" \ |
| 577 | 577 | "$(SRCDIR)\default.css" \ |
| 578 | 578 | "$(SRCDIR)\diff.js" \ |
| 579 | 579 | "$(SRCDIR)\diff.tcl" \ |
| 580 | 580 | "$(SRCDIR)\forum.js" \ |
| 581 | + "$(SRCDIR)\fossil.attach.js" \ | |
| 581 | 582 | "$(SRCDIR)\fossil.bootstrap.js" \ |
| 582 | 583 | "$(SRCDIR)\fossil.confirmer.js" \ |
| 583 | 584 | "$(SRCDIR)\fossil.copybutton.js" \ |
| 584 | 585 | "$(SRCDIR)\fossil.diff.js" \ |
| 585 | 586 | "$(SRCDIR)\fossil.dom.js" \ |
| @@ -626,10 +627,11 @@ | ||
| 626 | 627 | "$(SRCDIR)\sounds\e.wav" \ |
| 627 | 628 | "$(SRCDIR)\sounds\f.wav" \ |
| 628 | 629 | "$(SRCDIR)\style.admin_log.css" \ |
| 629 | 630 | "$(SRCDIR)\style.chat.css" \ |
| 630 | 631 | "$(SRCDIR)\style.fileedit.css" \ |
| 632 | + "$(SRCDIR)\style.forum.css" \ | |
| 631 | 633 | "$(SRCDIR)\style.pikchrshow.css" \ |
| 632 | 634 | "$(SRCDIR)\style.uvlist.css" \ |
| 633 | 635 | "$(SRCDIR)\style.wikiedit.css" \ |
| 634 | 636 | "$(SRCDIR)\tree.js" \ |
| 635 | 637 | "$(SRCDIR)\useredit.js" \ |
| @@ -1214,10 +1216,11 @@ | ||
| 1214 | 1216 | echo "$(SRCDIR)\copybtn.js" >> $@ |
| 1215 | 1217 | echo "$(SRCDIR)\default.css" >> $@ |
| 1216 | 1218 | echo "$(SRCDIR)\diff.js" >> $@ |
| 1217 | 1219 | echo "$(SRCDIR)\diff.tcl" >> $@ |
| 1218 | 1220 | echo "$(SRCDIR)\forum.js" >> $@ |
| 1221 | + echo "$(SRCDIR)\fossil.attach.js" >> $@ | |
| 1219 | 1222 | echo "$(SRCDIR)\fossil.bootstrap.js" >> $@ |
| 1220 | 1223 | echo "$(SRCDIR)\fossil.confirmer.js" >> $@ |
| 1221 | 1224 | echo "$(SRCDIR)\fossil.copybutton.js" >> $@ |
| 1222 | 1225 | echo "$(SRCDIR)\fossil.diff.js" >> $@ |
| 1223 | 1226 | echo "$(SRCDIR)\fossil.dom.js" >> $@ |
| @@ -1264,10 +1267,11 @@ | ||
| 1264 | 1267 | echo "$(SRCDIR)\sounds/e.wav" >> $@ |
| 1265 | 1268 | echo "$(SRCDIR)\sounds/f.wav" >> $@ |
| 1266 | 1269 | echo "$(SRCDIR)\style.admin_log.css" >> $@ |
| 1267 | 1270 | echo "$(SRCDIR)\style.chat.css" >> $@ |
| 1268 | 1271 | echo "$(SRCDIR)\style.fileedit.css" >> $@ |
| 1272 | + echo "$(SRCDIR)\style.forum.css" >> $@ | |
| 1269 | 1273 | echo "$(SRCDIR)\style.pikchrshow.css" >> $@ |
| 1270 | 1274 | echo "$(SRCDIR)\style.uvlist.css" >> $@ |
| 1271 | 1275 | echo "$(SRCDIR)\style.wikiedit.css" >> $@ |
| 1272 | 1276 | echo "$(SRCDIR)\tree.js" >> $@ |
| 1273 | 1277 | echo "$(SRCDIR)\useredit.js" >> $@ |
| 1274 | 1278 |
| --- 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 |
+3
-1
| --- www/changes.wiki | ||
| +++ www/changes.wiki | ||
| @@ -14,12 +14,14 @@ | ||
| 14 | 14 | the unmanaged file, even if that unmanaged file is read-only. |
| 15 | 15 | <li> Improve the default prompts used by the |
| 16 | 16 | "[/help/sqlite3|fossil sql]" command. |
| 17 | 17 | <li> The captcha now uses light-gray boxes as the background, instead of |
| 18 | 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. | |
| 19 | 21 | <li> Forum posts may now have attachments if their poster has the new "B" |
| 20 | - capability.</li> | |
| 22 | + capability. | |
| 21 | 23 | <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting |
| 22 | 24 | to limit the size of file attachments to wiki pages, tech notes, |
| 23 | 25 | tickets, and forum posts. |
| 24 | 26 | </ol> |
| 25 | 27 | |
| 26 | 28 |
| --- 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 @@ | ||
| 474 | 474 | rendering a newer version. |
| 475 | 475 | |
| 476 | 476 | Caveat: a "closed" status is not recommended because it's easy to confuse with |
| 477 | 477 | the <a href='#close-post'>"closed" tag feature</a>, which behaves considerably |
| 478 | 478 | 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. | |
| 480 | 481 | |
| 481 | 482 | <h2 name="attachments">Attachments</h2> |
| 482 | 483 | |
| 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 | |
| 487 | 488 | moderation exactly like forum posts do. When a moderator accepts a |
| 488 | 489 | pending-moderation posts, all files attached to it which are also |
| 489 | 490 | 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 | |
| 491 | 492 | also pending approval are rejected. |
| 492 | 493 | |
| 493 | 494 | Developer notes regarding the save-before-attach limitation: |
| 494 | 495 | |
| 495 | 496 | * We cannot add the attachment form to the current post editor |
| @@ -513,5 +514,90 @@ | ||
| 513 | 514 | notified, admin reads post, user attaches a file at that time, |
| 514 | 515 | admin approves post. |
| 515 | 516 | |
| 516 | 517 | TBD is whether to accept that case or to remove automatic approval of |
| 517 | 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>. | |
| 518 | 604 |
| --- 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 |