Schedule posts to Bluesky with Cloudflare workers. skyscheduler.work
cf tool bsky-tool cloudflare bluesky schedule bsky service social-media cloudflare-workers

Show repost information

closes #79, hopefully doesn't explode DB.

+1032 -39
+4
assets/css/stylesheet.css
··· 234 234 text-transform: capitalize; 235 235 } 236 236 237 + [data-tooltip]:before { 238 + white-space: preserve-breaks !important; 239 + } 240 + 237 241 /* Lightly adapted from https://github.com/n3r4zzurr0/svg-spinners */ 238 242 @keyframes refreshPostSpinner { 239 243 100% {
+10
assets/js/main.js
··· 29 29 el.textContent = formatDate(el.innerText); 30 30 el.setAttribute("corrected", true); 31 31 }); 32 + document.querySelectorAll(".repostTimesLeft").forEach(el => { 33 + if (el.hasAttribute("data-tooltip")) 34 + return; 35 + 36 + if (el.firstElementChild.className == "repostInfoData") { 37 + const repostInformation = el.firstElementChild.innerText; 38 + if (repostInformation.length > 0) 39 + el.setAttribute("data-tooltip", repostInformation); 40 + } 41 + }); 32 42 } 33 43 34 44 function refreshPosts() {
+3
migrations/0022_pale_tomas.sql
··· 1 + ALTER TABLE `posts` ADD `repostInfo` text;--> statement-breakpoint 2 + ALTER TABLE `reposts` ADD `schedule_guid` text;--> statement-breakpoint 3 + CREATE INDEX `repost_scheduleGuid_idx` ON `reposts` (`schedule_guid`);
+875
migrations/meta/0022_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "23f20d63-dbd9-4882-8bbb-004ee3481647", 5 + "prevId": "26ffdf5c-1f45-42ff-a2d7-2e7e76d51acb", 6 + "tables": { 7 + "accounts": { 8 + "name": "accounts", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "account_id": { 18 + "name": "account_id", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "provider_id": { 25 + "name": "provider_id", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "user_id": { 32 + "name": "user_id", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "access_token": { 39 + "name": "access_token", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "refresh_token": { 46 + "name": "refresh_token", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "id_token": { 53 + "name": "id_token", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false 58 + }, 59 + "access_token_expires_at": { 60 + "name": "access_token_expires_at", 61 + "type": "integer", 62 + "primaryKey": false, 63 + "notNull": false, 64 + "autoincrement": false 65 + }, 66 + "refresh_token_expires_at": { 67 + "name": "refresh_token_expires_at", 68 + "type": "integer", 69 + "primaryKey": false, 70 + "notNull": false, 71 + "autoincrement": false 72 + }, 73 + "scope": { 74 + "name": "scope", 75 + "type": "text", 76 + "primaryKey": false, 77 + "notNull": false, 78 + "autoincrement": false 79 + }, 80 + "password": { 81 + "name": "password", 82 + "type": "text", 83 + "primaryKey": false, 84 + "notNull": false, 85 + "autoincrement": false 86 + }, 87 + "created_at": { 88 + "name": "created_at", 89 + "type": "integer", 90 + "primaryKey": false, 91 + "notNull": true, 92 + "autoincrement": false, 93 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 94 + }, 95 + "updated_at": { 96 + "name": "updated_at", 97 + "type": "integer", 98 + "primaryKey": false, 99 + "notNull": true, 100 + "autoincrement": false 101 + } 102 + }, 103 + "indexes": { 104 + "accounts_userId_idx": { 105 + "name": "accounts_userId_idx", 106 + "columns": [ 107 + "user_id" 108 + ], 109 + "isUnique": false 110 + } 111 + }, 112 + "foreignKeys": { 113 + "accounts_user_id_users_id_fk": { 114 + "name": "accounts_user_id_users_id_fk", 115 + "tableFrom": "accounts", 116 + "tableTo": "users", 117 + "columnsFrom": [ 118 + "user_id" 119 + ], 120 + "columnsTo": [ 121 + "id" 122 + ], 123 + "onDelete": "cascade", 124 + "onUpdate": "no action" 125 + } 126 + }, 127 + "compositePrimaryKeys": {}, 128 + "uniqueConstraints": {}, 129 + "checkConstraints": {} 130 + }, 131 + "sessions": { 132 + "name": "sessions", 133 + "columns": { 134 + "id": { 135 + "name": "id", 136 + "type": "text", 137 + "primaryKey": true, 138 + "notNull": true, 139 + "autoincrement": false 140 + }, 141 + "expires_at": { 142 + "name": "expires_at", 143 + "type": "integer", 144 + "primaryKey": false, 145 + "notNull": true, 146 + "autoincrement": false 147 + }, 148 + "token": { 149 + "name": "token", 150 + "type": "text", 151 + "primaryKey": false, 152 + "notNull": true, 153 + "autoincrement": false 154 + }, 155 + "created_at": { 156 + "name": "created_at", 157 + "type": "integer", 158 + "primaryKey": false, 159 + "notNull": true, 160 + "autoincrement": false, 161 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 162 + }, 163 + "updated_at": { 164 + "name": "updated_at", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": true, 168 + "autoincrement": false 169 + }, 170 + "ip_address": { 171 + "name": "ip_address", 172 + "type": "text", 173 + "primaryKey": false, 174 + "notNull": false, 175 + "autoincrement": false 176 + }, 177 + "user_agent": { 178 + "name": "user_agent", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false, 182 + "autoincrement": false 183 + }, 184 + "user_id": { 185 + "name": "user_id", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true, 189 + "autoincrement": false 190 + } 191 + }, 192 + "indexes": { 193 + "sessions_token_unique": { 194 + "name": "sessions_token_unique", 195 + "columns": [ 196 + "token" 197 + ], 198 + "isUnique": true 199 + }, 200 + "sessions_userId_idx": { 201 + "name": "sessions_userId_idx", 202 + "columns": [ 203 + "user_id" 204 + ], 205 + "isUnique": false 206 + } 207 + }, 208 + "foreignKeys": { 209 + "sessions_user_id_users_id_fk": { 210 + "name": "sessions_user_id_users_id_fk", 211 + "tableFrom": "sessions", 212 + "tableTo": "users", 213 + "columnsFrom": [ 214 + "user_id" 215 + ], 216 + "columnsTo": [ 217 + "id" 218 + ], 219 + "onDelete": "cascade", 220 + "onUpdate": "no action" 221 + } 222 + }, 223 + "compositePrimaryKeys": {}, 224 + "uniqueConstraints": {}, 225 + "checkConstraints": {} 226 + }, 227 + "users": { 228 + "name": "users", 229 + "columns": { 230 + "id": { 231 + "name": "id", 232 + "type": "text", 233 + "primaryKey": true, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "name": { 238 + "name": "name", 239 + "type": "text", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "email": { 245 + "name": "email", 246 + "type": "text", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + }, 251 + "email_verified": { 252 + "name": "email_verified", 253 + "type": "integer", 254 + "primaryKey": false, 255 + "notNull": true, 256 + "autoincrement": false 257 + }, 258 + "image": { 259 + "name": "image", 260 + "type": "text", 261 + "primaryKey": false, 262 + "notNull": false, 263 + "autoincrement": false 264 + }, 265 + "created_at": { 266 + "name": "created_at", 267 + "type": "integer", 268 + "primaryKey": false, 269 + "notNull": true, 270 + "autoincrement": false 271 + }, 272 + "updated_at": { 273 + "name": "updated_at", 274 + "type": "integer", 275 + "primaryKey": false, 276 + "notNull": true, 277 + "autoincrement": false 278 + }, 279 + "username": { 280 + "name": "username", 281 + "type": "text", 282 + "primaryKey": false, 283 + "notNull": false, 284 + "autoincrement": false 285 + }, 286 + "display_username": { 287 + "name": "display_username", 288 + "type": "text", 289 + "primaryKey": false, 290 + "notNull": false, 291 + "autoincrement": false 292 + }, 293 + "bsky_app_pass": { 294 + "name": "bsky_app_pass", 295 + "type": "text", 296 + "primaryKey": false, 297 + "notNull": true, 298 + "autoincrement": false 299 + }, 300 + "pds": { 301 + "name": "pds", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true, 305 + "autoincrement": false, 306 + "default": "'https://bsky.social'" 307 + } 308 + }, 309 + "indexes": { 310 + "users_email_unique": { 311 + "name": "users_email_unique", 312 + "columns": [ 313 + "email" 314 + ], 315 + "isUnique": true 316 + }, 317 + "users_username_unique": { 318 + "name": "users_username_unique", 319 + "columns": [ 320 + "username" 321 + ], 322 + "isUnique": true 323 + } 324 + }, 325 + "foreignKeys": {}, 326 + "compositePrimaryKeys": {}, 327 + "uniqueConstraints": {}, 328 + "checkConstraints": {} 329 + }, 330 + "verifications": { 331 + "name": "verifications", 332 + "columns": { 333 + "id": { 334 + "name": "id", 335 + "type": "text", 336 + "primaryKey": true, 337 + "notNull": true, 338 + "autoincrement": false 339 + }, 340 + "identifier": { 341 + "name": "identifier", 342 + "type": "text", 343 + "primaryKey": false, 344 + "notNull": true, 345 + "autoincrement": false 346 + }, 347 + "value": { 348 + "name": "value", 349 + "type": "text", 350 + "primaryKey": false, 351 + "notNull": true, 352 + "autoincrement": false 353 + }, 354 + "expires_at": { 355 + "name": "expires_at", 356 + "type": "integer", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "created_at": { 362 + "name": "created_at", 363 + "type": "integer", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false, 367 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 368 + }, 369 + "updated_at": { 370 + "name": "updated_at", 371 + "type": "integer", 372 + "primaryKey": false, 373 + "notNull": true, 374 + "autoincrement": false, 375 + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" 376 + } 377 + }, 378 + "indexes": { 379 + "verifications_identifier_idx": { 380 + "name": "verifications_identifier_idx", 381 + "columns": [ 382 + "identifier" 383 + ], 384 + "isUnique": false 385 + } 386 + }, 387 + "foreignKeys": {}, 388 + "compositePrimaryKeys": {}, 389 + "uniqueConstraints": {}, 390 + "checkConstraints": {} 391 + }, 392 + "media": { 393 + "name": "media", 394 + "columns": { 395 + "file": { 396 + "name": "file", 397 + "type": "text", 398 + "primaryKey": true, 399 + "notNull": true, 400 + "autoincrement": false 401 + }, 402 + "hasPost": { 403 + "name": "hasPost", 404 + "type": "integer", 405 + "primaryKey": false, 406 + "notNull": false, 407 + "autoincrement": false, 408 + "default": false 409 + }, 410 + "user": { 411 + "name": "user", 412 + "type": "text", 413 + "primaryKey": false, 414 + "notNull": false, 415 + "autoincrement": false 416 + }, 417 + "created_at": { 418 + "name": "created_at", 419 + "type": "integer", 420 + "primaryKey": false, 421 + "notNull": true, 422 + "autoincrement": false 423 + } 424 + }, 425 + "indexes": { 426 + "media_oldWithNoPost_idx": { 427 + "name": "media_oldWithNoPost_idx", 428 + "columns": [ 429 + "hasPost", 430 + "created_at" 431 + ], 432 + "isUnique": false, 433 + "where": "hasPost = 0" 434 + }, 435 + "media_userid_idx": { 436 + "name": "media_userid_idx", 437 + "columns": [ 438 + "user" 439 + ], 440 + "isUnique": false 441 + } 442 + }, 443 + "foreignKeys": { 444 + "media_user_users_id_fk": { 445 + "name": "media_user_users_id_fk", 446 + "tableFrom": "media", 447 + "tableTo": "users", 448 + "columnsFrom": [ 449 + "user" 450 + ], 451 + "columnsTo": [ 452 + "id" 453 + ], 454 + "onDelete": "cascade", 455 + "onUpdate": "no action" 456 + } 457 + }, 458 + "compositePrimaryKeys": {}, 459 + "uniqueConstraints": {}, 460 + "checkConstraints": {} 461 + }, 462 + "posts": { 463 + "name": "posts", 464 + "columns": { 465 + "uuid": { 466 + "name": "uuid", 467 + "type": "text", 468 + "primaryKey": true, 469 + "notNull": true, 470 + "autoincrement": false 471 + }, 472 + "content": { 473 + "name": "content", 474 + "type": "text", 475 + "primaryKey": false, 476 + "notNull": true, 477 + "autoincrement": false 478 + }, 479 + "scheduled_date": { 480 + "name": "scheduled_date", 481 + "type": "integer", 482 + "primaryKey": false, 483 + "notNull": true, 484 + "autoincrement": false 485 + }, 486 + "posted": { 487 + "name": "posted", 488 + "type": "integer", 489 + "primaryKey": false, 490 + "notNull": false, 491 + "autoincrement": false, 492 + "default": false 493 + }, 494 + "postNow": { 495 + "name": "postNow", 496 + "type": "integer", 497 + "primaryKey": false, 498 + "notNull": false, 499 + "autoincrement": false, 500 + "default": false 501 + }, 502 + "embedContent": { 503 + "name": "embedContent", 504 + "type": "text", 505 + "primaryKey": false, 506 + "notNull": true, 507 + "autoincrement": false, 508 + "default": "(json_array())" 509 + }, 510 + "repostInfo": { 511 + "name": "repostInfo", 512 + "type": "text", 513 + "primaryKey": false, 514 + "notNull": false, 515 + "autoincrement": false 516 + }, 517 + "uri": { 518 + "name": "uri", 519 + "type": "text", 520 + "primaryKey": false, 521 + "notNull": false, 522 + "autoincrement": false 523 + }, 524 + "cid": { 525 + "name": "cid", 526 + "type": "text", 527 + "primaryKey": false, 528 + "notNull": false, 529 + "autoincrement": false 530 + }, 531 + "isRepost": { 532 + "name": "isRepost", 533 + "type": "integer", 534 + "primaryKey": false, 535 + "notNull": false, 536 + "autoincrement": false, 537 + "default": false 538 + }, 539 + "contentLabel": { 540 + "name": "contentLabel", 541 + "type": "text", 542 + "primaryKey": false, 543 + "notNull": true, 544 + "autoincrement": false, 545 + "default": "'None'" 546 + }, 547 + "created_at": { 548 + "name": "created_at", 549 + "type": "integer", 550 + "primaryKey": false, 551 + "notNull": true, 552 + "autoincrement": false, 553 + "default": "CURRENT_TIMESTAMP" 554 + }, 555 + "updated_at": { 556 + "name": "updated_at", 557 + "type": "integer", 558 + "primaryKey": false, 559 + "notNull": false, 560 + "autoincrement": false 561 + }, 562 + "user": { 563 + "name": "user", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": true, 567 + "autoincrement": false 568 + } 569 + }, 570 + "indexes": { 571 + "user_idx": { 572 + "name": "user_idx", 573 + "columns": [ 574 + "user" 575 + ], 576 + "isUnique": false 577 + }, 578 + "postedUpdate_idx": { 579 + "name": "postedUpdate_idx", 580 + "columns": [ 581 + "updated_at", 582 + "posted" 583 + ], 584 + "isUnique": false, 585 + "where": "posted = 1" 586 + }, 587 + "repostOnlyUser_idx": { 588 + "name": "repostOnlyUser_idx", 589 + "columns": [ 590 + "user", 591 + "isRepost" 592 + ], 593 + "isUnique": false, 594 + "where": "isRepost = 1" 595 + }, 596 + "postedUUID_idx": { 597 + "name": "postedUUID_idx", 598 + "columns": [ 599 + "uuid", 600 + "posted" 601 + ], 602 + "isUnique": false 603 + }, 604 + "postNowScheduledDatePosted_idx": { 605 + "name": "postNowScheduledDatePosted_idx", 606 + "columns": [ 607 + "posted", 608 + "scheduled_date", 609 + "postNow" 610 + ], 611 + "isUnique": false, 612 + "where": "posted = 0 and postNow <> 1" 613 + }, 614 + "repostAddOn_idx": { 615 + "name": "repostAddOn_idx", 616 + "columns": [ 617 + "user", 618 + "cid" 619 + ], 620 + "isUnique": false 621 + } 622 + }, 623 + "foreignKeys": { 624 + "posts_user_users_id_fk": { 625 + "name": "posts_user_users_id_fk", 626 + "tableFrom": "posts", 627 + "tableTo": "users", 628 + "columnsFrom": [ 629 + "user" 630 + ], 631 + "columnsTo": [ 632 + "id" 633 + ], 634 + "onDelete": "cascade", 635 + "onUpdate": "no action" 636 + } 637 + }, 638 + "compositePrimaryKeys": {}, 639 + "uniqueConstraints": {}, 640 + "checkConstraints": {} 641 + }, 642 + "repostCounts": { 643 + "name": "repostCounts", 644 + "columns": { 645 + "post_uuid": { 646 + "name": "post_uuid", 647 + "type": "text", 648 + "primaryKey": true, 649 + "notNull": true, 650 + "autoincrement": false 651 + }, 652 + "count": { 653 + "name": "count", 654 + "type": "integer", 655 + "primaryKey": false, 656 + "notNull": true, 657 + "autoincrement": false, 658 + "default": 0 659 + } 660 + }, 661 + "indexes": {}, 662 + "foreignKeys": { 663 + "repostCounts_post_uuid_posts_uuid_fk": { 664 + "name": "repostCounts_post_uuid_posts_uuid_fk", 665 + "tableFrom": "repostCounts", 666 + "tableTo": "posts", 667 + "columnsFrom": [ 668 + "post_uuid" 669 + ], 670 + "columnsTo": [ 671 + "uuid" 672 + ], 673 + "onDelete": "cascade", 674 + "onUpdate": "no action" 675 + } 676 + }, 677 + "compositePrimaryKeys": {}, 678 + "uniqueConstraints": {}, 679 + "checkConstraints": {} 680 + }, 681 + "reposts": { 682 + "name": "reposts", 683 + "columns": { 684 + "id": { 685 + "name": "id", 686 + "type": "integer", 687 + "primaryKey": true, 688 + "notNull": true, 689 + "autoincrement": true 690 + }, 691 + "post_uuid": { 692 + "name": "post_uuid", 693 + "type": "text", 694 + "primaryKey": false, 695 + "notNull": true, 696 + "autoincrement": false 697 + }, 698 + "scheduled_date": { 699 + "name": "scheduled_date", 700 + "type": "integer", 701 + "primaryKey": false, 702 + "notNull": true, 703 + "autoincrement": false 704 + }, 705 + "schedule_guid": { 706 + "name": "schedule_guid", 707 + "type": "text", 708 + "primaryKey": false, 709 + "notNull": false, 710 + "autoincrement": false 711 + } 712 + }, 713 + "indexes": { 714 + "repost_scheduledDate_idx": { 715 + "name": "repost_scheduledDate_idx", 716 + "columns": [ 717 + "scheduled_date" 718 + ], 719 + "isUnique": false 720 + }, 721 + "repost_postid_idx": { 722 + "name": "repost_postid_idx", 723 + "columns": [ 724 + "post_uuid" 725 + ], 726 + "isUnique": false 727 + }, 728 + "repost_scheduleGuid_idx": { 729 + "name": "repost_scheduleGuid_idx", 730 + "columns": [ 731 + "schedule_guid" 732 + ], 733 + "isUnique": false 734 + }, 735 + "repost_noduplicates_idx": { 736 + "name": "repost_noduplicates_idx", 737 + "columns": [ 738 + "post_uuid", 739 + "scheduled_date" 740 + ], 741 + "isUnique": true 742 + } 743 + }, 744 + "foreignKeys": { 745 + "reposts_post_uuid_posts_uuid_fk": { 746 + "name": "reposts_post_uuid_posts_uuid_fk", 747 + "tableFrom": "reposts", 748 + "tableTo": "posts", 749 + "columnsFrom": [ 750 + "post_uuid" 751 + ], 752 + "columnsTo": [ 753 + "uuid" 754 + ], 755 + "onDelete": "cascade", 756 + "onUpdate": "no action" 757 + } 758 + }, 759 + "compositePrimaryKeys": {}, 760 + "uniqueConstraints": {}, 761 + "checkConstraints": {} 762 + }, 763 + "violations": { 764 + "name": "violations", 765 + "columns": { 766 + "id": { 767 + "name": "id", 768 + "type": "integer", 769 + "primaryKey": true, 770 + "notNull": true, 771 + "autoincrement": true 772 + }, 773 + "user": { 774 + "name": "user", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": true, 778 + "autoincrement": false 779 + }, 780 + "tosViolation": { 781 + "name": "tosViolation", 782 + "type": "integer", 783 + "primaryKey": false, 784 + "notNull": false, 785 + "autoincrement": false, 786 + "default": false 787 + }, 788 + "userPassInvalid": { 789 + "name": "userPassInvalid", 790 + "type": "integer", 791 + "primaryKey": false, 792 + "notNull": false, 793 + "autoincrement": false, 794 + "default": false 795 + }, 796 + "accountSuspended": { 797 + "name": "accountSuspended", 798 + "type": "integer", 799 + "primaryKey": false, 800 + "notNull": false, 801 + "autoincrement": false, 802 + "default": false 803 + }, 804 + "accountGone": { 805 + "name": "accountGone", 806 + "type": "integer", 807 + "primaryKey": false, 808 + "notNull": false, 809 + "autoincrement": false, 810 + "default": false 811 + }, 812 + "mediaTooBig": { 813 + "name": "mediaTooBig", 814 + "type": "integer", 815 + "primaryKey": false, 816 + "notNull": false, 817 + "autoincrement": false, 818 + "default": false 819 + }, 820 + "created_at": { 821 + "name": "created_at", 822 + "type": "integer", 823 + "primaryKey": false, 824 + "notNull": true, 825 + "autoincrement": false, 826 + "default": "CURRENT_TIMESTAMP" 827 + } 828 + }, 829 + "indexes": { 830 + "violations_user_unique": { 831 + "name": "violations_user_unique", 832 + "columns": [ 833 + "user" 834 + ], 835 + "isUnique": true 836 + }, 837 + "violations_user_idx": { 838 + "name": "violations_user_idx", 839 + "columns": [ 840 + "user" 841 + ], 842 + "isUnique": false 843 + } 844 + }, 845 + "foreignKeys": { 846 + "violations_user_users_id_fk": { 847 + "name": "violations_user_users_id_fk", 848 + "tableFrom": "violations", 849 + "tableTo": "users", 850 + "columnsFrom": [ 851 + "user" 852 + ], 853 + "columnsTo": [ 854 + "id" 855 + ], 856 + "onDelete": "cascade", 857 + "onUpdate": "no action" 858 + } 859 + }, 860 + "compositePrimaryKeys": {}, 861 + "uniqueConstraints": {}, 862 + "checkConstraints": {} 863 + } 864 + }, 865 + "views": {}, 866 + "enums": {}, 867 + "_meta": { 868 + "schemas": {}, 869 + "tables": {}, 870 + "columns": {} 871 + }, 872 + "internal": { 873 + "indexes": {} 874 + } 875 + }
+7
migrations/meta/_journal.json
··· 155 155 "when": 1770168969451, 156 156 "tag": "0021_milky_marten_broadcloak", 157 157 "breakpoints": true 158 + }, 159 + { 160 + "idx": 22, 161 + "version": "6", 162 + "when": 1770503555129, 163 + "tag": "0022_pale_tomas", 164 + "breakpoints": true 158 165 } 159 166 ] 160 167 }
+5 -1
src/db/app.schema.ts
··· 1 1 import { sql } from "drizzle-orm"; 2 2 import { index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; 3 - import { EmbedData, PostLabel } from '../types.d'; 3 + import { EmbedData, PostLabel, RepostInfo } from '../types.d'; 4 4 import { users } from "./auth.schema"; 5 5 6 6 export const posts = sqliteTable('posts', { ··· 11 11 // This is a flag to help beat any race conditions with our cron jobs 12 12 postNow: integer('postNow', { mode: 'boolean' }).default(false), 13 13 embedContent: text('embedContent', {mode: 'json'}).notNull().$type<EmbedData[]>().default(sql`(json_array())`), 14 + repostInfo: text('repostInfo', {mode: 'json'}).$type<RepostInfo[]>(), 14 15 uri: text('uri'), 15 16 cid: text('cid'), 16 17 isRepost: integer('isRepost', { mode: 'boolean' }).default(false), ··· 50 51 .notNull() 51 52 .references(() => posts.uuid, {onDelete: "cascade"}), 52 53 scheduledDate: integer('scheduled_date', { mode: 'timestamp_ms' }).notNull(), 54 + scheduleGuid: text('schedule_guid') 53 55 }, (table) => [ 54 56 // cron queries 55 57 index("repost_scheduledDate_idx").on(table.scheduledDate), 56 58 // used for left joining and matching with posts field 57 59 index("repost_postid_idx").on(table.uuid), 60 + // used for checking if a schedule still has types left 61 + index("repost_scheduleGuid_idx").on(table.scheduleGuid), 58 62 unique("repost_noduplicates_idx").on(table.uuid, table.scheduledDate), 59 63 ]); 60 64
+19 -3
src/layout/postList.tsx
··· 1 1 import { Context } from "hono"; 2 2 import { html, raw } from "hono/html"; 3 - import { getPostsForUser, getUsernameForUser } from "../utils/dbQuery"; 4 - import { Post } from "../types.d"; 5 3 import isEmpty from "just-is-empty"; 4 + import { Post } from "../types.d"; 5 + import { getPostsForUser, getUsernameForUser } from "../utils/dbQuery"; 6 6 7 7 type PostContentObjectProps = { 8 8 text: string; ··· 40 40 data-tooltip="Edit this post" data-placement="right" ${editAttributes}> 41 41 <img src="/icons/edit.svg" alt="edit icon" width="20px" height="20px" /> 42 42 </button>`); 43 + 44 + let repostInfoStr:string = ""; 45 + if (!isEmpty(content.repostInfo)) { 46 + for (const repostItem of content.repostInfo!) { 47 + const repostWrapper = `<span class="timestamp">${repostItem.time}</span>`; 48 + if (repostItem.count == 0) { 49 + repostInfoStr += `* Repost at ${repostWrapper}`; 50 + } else { 51 + repostInfoStr += `* Every ${repostItem.hours} hours, ${repostItem.count} times from ${repostWrapper}`; 52 + } 53 + repostInfoStr += "\n"; 54 + } 55 + } 56 + const repostCountElement = content.repostCount ? 57 + (<> | <span class="repostTimesLeft" tabindex={0} data-placement="left"> 58 + <span class="repostInfoData" hidden={true}>{raw(repostInfoStr)}</span>Reposts Left: {content.repostCount}</span></>) : ""; 43 59 44 60 return html` 45 61 <article id="postBase${content.postid}" ${oobSwapStr}> ··· 58 74 'Scheduled for:' } 59 75 <span class="timestamp">${content.scheduledDate}</span> 60 76 ${!isEmpty(content.embeds) ? ' | Embeds: ' + content.embeds?.length : ''} 61 - ${content.repostCount! ? ' | Reposts Left: ' + content.repostCount : ''} 77 + ${repostCountElement} 62 78 </small> 63 79 </footer> 64 80 </article>`;
+3
src/limits.ts
··· 34 34 // because some people went incredibly overboard. 35 35 export const MAX_REPOST_POSTS: number = 40; 36 36 37 + // a limit for the maximum number of repost rules a single post can have 38 + export const MAX_REPOST_RULES_PER_POST: number = 5; 39 + 37 40 /** INTERNAL LIMITS, DO NOT CHANGE **/ 38 41 // Maximums used internally, do not change these directly. 39 42 export const MAX_REPOST_INTERVAL_LIMIT: number = MAX_REPOST_INTERVAL + 1;
+12 -2
src/types.d.ts
··· 1 - import { ExecutionContext } from "hono"; 2 1 import { BatchItem } from "drizzle-orm/batch"; 2 + import { ExecutionContext } from "hono"; 3 3 4 4 /*** Settings config wrappers for bindings ***/ 5 5 type ImageConfigSettings = { ··· 62 62 export enum EmbedDataType { 63 63 None = 0, 64 64 Image = 1, 65 - WebLink, 65 + WebLink = 2, 66 66 Video = 3, 67 67 Record = 4, 68 68 }; ··· 79 79 duration?: number; 80 80 }; 81 81 82 + // Contains the repost info for a post 83 + export type RepostInfo = { 84 + guid: string, 85 + time: Date, 86 + hours: number, 87 + count: number 88 + }; 89 + 82 90 export enum PostLabel { 83 91 None = "None", 84 92 Suggestive = "Suggestive", ··· 100 108 postNow: boolean; 101 109 posted?: boolean; 102 110 isRepost?: boolean; 111 + repostInfo?: RepostInfo[]; 103 112 scheduledDate?: string; 104 113 repostCount?: number; 105 114 cid?: string; ··· 111 120 uri: string; 112 121 cid: string; 113 122 userId: string; 123 + scheduleGuid?: string; 114 124 }; 115 125 116 126 export enum QueueTaskType {
+1 -1
src/utils/appScripts.ts
··· 1 1 // Change this value to break out of any caching that might be happening 2 2 // for the runtime scripts (ex: main.js & postHelper.js) 3 - export const CURRENT_SCRIPT_VERSION: string = "1.4.2"; 3 + export const CURRENT_SCRIPT_VERSION: string = "1.4.3"; 4 4 5 5 export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`; 6 6
+70 -19
src/utils/dbQuery.ts
··· 1 - import { addHours, isAfter } from "date-fns"; 1 + import { addHours, isAfter, isEqual } from "date-fns"; 2 2 import { 3 3 desc, eq, getTableColumns, gt, inArray, 4 - isNull, lte, ne, notInArray, sql, and 4 + and, isNotNull, 5 + isNull, lte, ne, notInArray, sql 5 6 } from "drizzle-orm"; 6 7 import { BatchItem } from "drizzle-orm/batch"; 7 8 import { drizzle, DrizzleD1Database } from "drizzle-orm/d1"; ··· 13 14 import { v4 as uuidv4, validate as uuidValid } from 'uuid'; 14 15 import { mediaFiles, posts, repostCounts, reposts, violations } from "../db/app.schema"; 15 16 import { accounts, users } from "../db/auth.schema"; 16 - import { MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH, MAX_REPOST_POSTS } from "../limits"; 17 + import { 18 + MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH, 19 + MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST 20 + } from "../limits"; 17 21 import { 18 22 BatchQuery, 19 23 Bindings, BskyAPILoginCreds, CreateObjectResponse, CreatePostQueryResponse, 20 24 EmbedDataType, GetAllPostedBatch, LooseObj, PlatformLoginResponse, 21 - Post, PostLabel, R2BucketObject, Repost, ScheduledContext, Violation 25 + Post, PostLabel, R2BucketObject, Repost, RepostInfo, ScheduledContext, Violation 22 26 } from "../types.d"; 23 27 import { PostSchema } from "../validation/postSchema"; 24 28 import { RepostSchema } from "../validation/repostSchema"; 25 29 import { addFileListing } from "./dbQueryFile"; 26 30 import { 27 - createLoginCredsObj, createPostObject, createRepostObject, 31 + createLoginCredsObj, createPostObject, createRepostInfo, createRepostObject, 28 32 floorCurrentTime, floorGivenTime 29 33 } from "./helpers"; 30 34 import { deleteEmbedsFromR2, getAllFilesList } from "./r2Query"; ··· 172 176 } 173 177 } 174 178 179 + // Create repost metadata 180 + const scheduleGUID = uuidv4(); 181 + const repostInfo: RepostInfo = createRepostInfo(scheduleGUID, scheduleDate, repostData); 182 + 175 183 // Create the posts 176 184 const postUUID = uuidv4(); 177 185 let dbOperations: BatchItem<"sqlite">[] = [ ··· 180 188 uuid: postUUID, 181 189 postNow: makePostNow, 182 190 scheduledDate: scheduleDate, 191 + repostInfo: [repostInfo], 183 192 embedContent: embeds, 184 193 contentLabel: label || PostLabel.None, 185 194 userId: userId ··· 201 210 for (var i = 1; i <= repostData.times; ++i) { 202 211 dbOperations.push(db.insert(reposts).values({ 203 212 uuid: postUUID, 213 + scheduleGuid: scheduleGUID, 204 214 scheduledDate: addHours(scheduleDate, i*repostData.hours) 205 215 })); 206 216 } ··· 249 259 } 250 260 let postUUID; 251 261 let dbOperations: BatchItem<"sqlite">[] = []; 262 + const scheduleGUID = uuidv4(); 263 + const repostInfo: RepostInfo = createRepostInfo(scheduleGUID, scheduleDate, repostData); 252 264 253 265 // Check to see if the post already exists 254 266 // (check also against the userId here as well to avoid cross account data collisions) 255 - const existingPost = await db.select({id: posts.uuid, date: posts.scheduledDate}).from(posts).where(and( 267 + const existingPost = await db.select({id: posts.uuid, date: posts.scheduledDate, curRepostInfo: posts.repostInfo}).from(posts).where(and( 256 268 eq(posts.userId, userId), eq(posts.cid, cid))).limit(1).all(); 257 269 258 - const hasExistingPost = existingPost.length >= 1; 270 + const hasExistingPost:boolean = existingPost.length >= 1; 259 271 if (hasExistingPost) { 260 272 postUUID = existingPost[0].id; 273 + const existingPostDate = existingPost[0].date; 261 274 // Ensure the date asked for is after what the post's schedule date is 262 - if (!isAfter(scheduleDate, existingPost[0].date)) { 275 + if (!isAfter(scheduleDate, existingPostDate) && !isEqual(scheduledDate, existingPostDate)) { 263 276 return { ok: false, msg: "Scheduled date must be after the initial post's date" }; 264 277 } 278 + // Add repost info object to existing array 279 + let newRepostInfo:RepostInfo[] = isEmpty(existingPost[0].curRepostInfo) ? [] : existingPost[0].curRepostInfo!; 280 + if (newRepostInfo.length >= MAX_REPOST_RULES_PER_POST) { 281 + return {ok: false, msg: `Num of reposts rules for this post has exceeded the limit of ${MAX_REPOST_RULES_PER_POST} rules`}; 282 + } 283 + 284 + newRepostInfo.push(repostInfo); 285 + // push record update to add to json array 286 + dbOperations.push(db.update(posts).set({repostInfo: newRepostInfo}).where(and( 287 + eq(posts.userId, userId), eq(posts.cid, cid)))); 265 288 } else { 266 289 // Limit of post reposts on the user's account. 267 290 const accountCurrentReposts = await db.$count(posts, and(eq(posts.userId, userId), eq(posts.isRepost, true))); ··· 279 302 uri: uri, 280 303 posted: true, 281 304 isRepost: true, 305 + repostInfo: [repostInfo], 282 306 scheduledDate: scheduleDate, 283 307 userId: userId 284 308 })); ··· 287 311 // Push initial repost 288 312 let totalRepostCount = 1; 289 313 dbOperations.push(db.insert(reposts).values({ 290 - uuid: postUUID, 291 - scheduledDate: scheduleDate 292 - }) 293 - .onConflictDoNothing()); 314 + uuid: postUUID, 315 + scheduleGuid: scheduleGUID, 316 + scheduledDate: scheduleDate 317 + }).onConflictDoNothing()); 294 318 295 319 // Push other repost times if we have them 296 320 if (repostData) { 297 321 for (var i = 1; i <= repostData.times; ++i) { 298 322 dbOperations.push(db.insert(reposts).values({ 299 - uuid: postUUID, 300 - scheduledDate: addHours(scheduleDate, i*repostData.hours) 301 - }) 302 - .onConflictDoNothing()); 323 + uuid: postUUID, 324 + scheduleGuid: scheduleGUID, 325 + scheduledDate: addHours(scheduleDate, i*repostData.hours) 326 + }).onConflictDoNothing()); 303 327 } 304 328 totalRepostCount += repostData.times; 305 329 } ··· 360 384 export const deleteAllRepostsBeforeCurrentTime = async (env: Bindings) => { 361 385 const db: DrizzleD1Database = drizzle(env.DB); 362 386 const currentTime = floorCurrentTime(); 363 - const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime)).returning({id: reposts.uuid}); 387 + const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime)) 388 + .returning({id: reposts.uuid, scheduleGuid: reposts.scheduleGuid}); 364 389 365 390 // This is really stupid and I hate it, but someone has to update repost counts once posted 366 391 if (deletedPosts.length > 0) { 367 392 let batchedQueries:BatchItem<"sqlite">[] = []; 368 393 for (const deleted of deletedPosts) { 394 + // Update counts 369 395 const newCount = db.$count(reposts, eq(reposts.uuid, deleted.id)); 370 396 batchedQueries.push(db.update(repostCounts) 371 - .set({count: newCount}) 372 - .where(eq(repostCounts.uuid, deleted.id))) 397 + .set({count: newCount}) 398 + .where(eq(repostCounts.uuid, deleted.id))); 399 + 400 + // check if the repost data needs to be killed 401 + if (!isEmpty(deleted.scheduleGuid)) { 402 + // do a search to find if there are any reposts with the same scheduleguid. 403 + // if there are none, this schedule should get removed from the repostInfo array 404 + const stillHasSchedule = await db.select().from(reposts) 405 + .where(and(isNotNull(reposts.scheduleGuid), eq(reposts.scheduleGuid, deleted.scheduleGuid!))) 406 + .limit(1).all(); 407 + 408 + // if this is empty, then we need to update the repost info. 409 + if (isEmpty(stillHasSchedule)) { 410 + // get the existing repost info to filter out this old data 411 + const existingRepostInfoArr = (await db.select({repostInfo: posts.repostInfo}).from(posts) 412 + .where(eq(posts.uuid, reposts.uuid)).limit(1).all())[0]; 413 + // check to see if there is anything in the repostInfo array 414 + if (!isEmpty(existingRepostInfoArr)) { 415 + // create a new array with the deleted out object 416 + const newRepostInfoArr = existingRepostInfoArr.repostInfo!.filter((obj) => { 417 + return obj.guid !== deleted.scheduleGuid!; 418 + }); 419 + // push the new repost info array 420 + batchedQueries.push(db.update(posts).set({repostInfo: newRepostInfoArr}).where(eq(posts.uuid, deleted.id))); 421 + } 422 + } 423 + } 373 424 } 374 425 await db.batch(batchedQueries as BatchQuery); 375 426 }
+21 -1
src/utils/helpers.ts
··· 1 1 import { startOfHour, subDays } from "date-fns"; 2 + import has from "just-has"; 2 3 import isEmpty from "just-is-empty"; 3 - import { BskyAPILoginCreds, Post, Repost } from "../types.d"; 4 + import { BskyAPILoginCreds, Post, Repost, RepostInfo } from "../types.d"; 4 5 5 6 export function createPostObject(data: any) { 6 7 const postData: Post = (new Object() as Post); ··· 20 21 21 22 if (data.isRepost) 22 23 postData.isRepost = data.isRepost; 24 + 25 + if (data.repostInfo) 26 + postData.repostInfo = data.repostInfo; 23 27 24 28 // ATProto data 25 29 if (data.uri) ··· 36 40 repostObj.cid = data.cid; 37 41 repostObj.uri = data.uri; 38 42 repostObj.userId = data.userId; 43 + if (data.scheduleGuid) 44 + repostObj.scheduleGuid = data.scheduleGuid; 45 + return repostObj; 46 + } 47 + 48 + export function createRepostInfo(id: string, time: Date, repostData: any) { 49 + const repostObj: RepostInfo = (new Object() as RepostInfo); 50 + repostObj.time = time; 51 + repostObj.guid = id; 52 + if (has(repostData, "hours") && has(repostData, "times")) { 53 + repostObj.hours = repostData.hours; 54 + repostObj.count = repostData.times; 55 + } 56 + else { 57 + repostObj.count = repostObj.hours = 0; 58 + } 39 59 return repostObj; 40 60 } 41 61
+2 -12
src/utils/scheduler.ts
··· 1 1 import isEmpty from 'just-is-empty'; 2 - import { Bindings, LooseObj, Post, Repost, ScheduledContext } from '../types.d'; 2 + import { Bindings, Post, Repost, ScheduledContext } from '../types.d'; 3 3 import { makePost, makeRepost } from './bskyApi'; 4 4 import { pruneBskyPosts } from './bskyPrune'; 5 5 import { ··· 7 7 deletePosts, 8 8 getAllPostsForCurrentTime, 9 9 getAllRepostsForCurrentTime, 10 - purgePostedPosts, 11 - updatePostForGivenUser, 10 + purgePostedPosts 12 11 } from './dbQuery'; 13 12 import { getAllAbandonedMedia } from './dbQueryFile'; 14 13 import { enqueuePost, enqueueRepost, isQueueEnabled } from './queuePublisher'; ··· 27 26 const madeRepost = await makeRepost(runtime, postData); 28 27 if (madeRepost) { 29 28 console.log(`Reposted ${postData.uri} successfully!`); 30 - try 31 - { 32 - // Force update the payload of the db when posted so that it updates the main post record 33 - const payload: LooseObj = { posted: true }; 34 - await updatePostForGivenUser(runtime, postData.userId, postData.postid, payload); 35 - } catch(err) { 36 - console.error(`Failed to update the timestamp of the repost with error ${err}`); 37 - } 38 - 39 29 } else { 40 30 console.warn(`Failed to repost ${postData.uri}`); 41 31 }