···11import { sql } from "drizzle-orm";
22import { index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
33-import { EmbedData, PostLabel } from '../types.d';
33+import { EmbedData, PostLabel, RepostInfo } from '../types.d';
44import { users } from "./auth.schema";
5566export const posts = sqliteTable('posts', {
···1111 // This is a flag to help beat any race conditions with our cron jobs
1212 postNow: integer('postNow', { mode: 'boolean' }).default(false),
1313 embedContent: text('embedContent', {mode: 'json'}).notNull().$type<EmbedData[]>().default(sql`(json_array())`),
1414+ repostInfo: text('repostInfo', {mode: 'json'}).$type<RepostInfo[]>(),
1415 uri: text('uri'),
1516 cid: text('cid'),
1617 isRepost: integer('isRepost', { mode: 'boolean' }).default(false),
···5051 .notNull()
5152 .references(() => posts.uuid, {onDelete: "cascade"}),
5253 scheduledDate: integer('scheduled_date', { mode: 'timestamp_ms' }).notNull(),
5454+ scheduleGuid: text('schedule_guid')
5355}, (table) => [
5456 // cron queries
5557 index("repost_scheduledDate_idx").on(table.scheduledDate),
5658 // used for left joining and matching with posts field
5759 index("repost_postid_idx").on(table.uuid),
6060+ // used for checking if a schedule still has types left
6161+ index("repost_scheduleGuid_idx").on(table.scheduleGuid),
5862 unique("repost_noduplicates_idx").on(table.uuid, table.scheduledDate),
5963]);
6064
+19-3
src/layout/postList.tsx
···11import { Context } from "hono";
22import { html, raw } from "hono/html";
33-import { getPostsForUser, getUsernameForUser } from "../utils/dbQuery";
44-import { Post } from "../types.d";
53import isEmpty from "just-is-empty";
44+import { Post } from "../types.d";
55+import { getPostsForUser, getUsernameForUser } from "../utils/dbQuery";
6677type PostContentObjectProps = {
88 text: string;
···4040 data-tooltip="Edit this post" data-placement="right" ${editAttributes}>
4141 <img src="/icons/edit.svg" alt="edit icon" width="20px" height="20px" />
4242 </button>`);
4343+4444+ let repostInfoStr:string = "";
4545+ if (!isEmpty(content.repostInfo)) {
4646+ for (const repostItem of content.repostInfo!) {
4747+ const repostWrapper = `<span class="timestamp">${repostItem.time}</span>`;
4848+ if (repostItem.count == 0) {
4949+ repostInfoStr += `* Repost at ${repostWrapper}`;
5050+ } else {
5151+ repostInfoStr += `* Every ${repostItem.hours} hours, ${repostItem.count} times from ${repostWrapper}`;
5252+ }
5353+ repostInfoStr += "\n";
5454+ }
5555+ }
5656+ const repostCountElement = content.repostCount ?
5757+ (<> | <span class="repostTimesLeft" tabindex={0} data-placement="left">
5858+ <span class="repostInfoData" hidden={true}>{raw(repostInfoStr)}</span>Reposts Left: {content.repostCount}</span></>) : "";
43594460 return html`
4561 <article id="postBase${content.postid}" ${oobSwapStr}>
···5874 'Scheduled for:' }
5975 <span class="timestamp">${content.scheduledDate}</span>
6076 ${!isEmpty(content.embeds) ? ' | Embeds: ' + content.embeds?.length : ''}
6161- ${content.repostCount! ? ' | Reposts Left: ' + content.repostCount : ''}
7777+ ${repostCountElement}
6278 </small>
6379 </footer>
6480 </article>`;
+3
src/limits.ts
···3434// because some people went incredibly overboard.
3535export const MAX_REPOST_POSTS: number = 40;
36363737+// a limit for the maximum number of repost rules a single post can have
3838+export const MAX_REPOST_RULES_PER_POST: number = 5;
3939+3740/** INTERNAL LIMITS, DO NOT CHANGE **/
3841// Maximums used internally, do not change these directly.
3942export const MAX_REPOST_INTERVAL_LIMIT: number = MAX_REPOST_INTERVAL + 1;
···11// Change this value to break out of any caching that might be happening
22// for the runtime scripts (ex: main.js & postHelper.js)
33-export const CURRENT_SCRIPT_VERSION: string = "1.4.2";
33+export const CURRENT_SCRIPT_VERSION: string = "1.4.3";
4455export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`;
66
+70-19
src/utils/dbQuery.ts
···11-import { addHours, isAfter } from "date-fns";
11+import { addHours, isAfter, isEqual } from "date-fns";
22import {
33 desc, eq, getTableColumns, gt, inArray,
44- isNull, lte, ne, notInArray, sql, and
44+ and, isNotNull,
55+ isNull, lte, ne, notInArray, sql
56} from "drizzle-orm";
67import { BatchItem } from "drizzle-orm/batch";
78import { drizzle, DrizzleD1Database } from "drizzle-orm/d1";
···1314import { v4 as uuidv4, validate as uuidValid } from 'uuid';
1415import { mediaFiles, posts, repostCounts, reposts, violations } from "../db/app.schema";
1516import { accounts, users } from "../db/auth.schema";
1616-import { MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH, MAX_REPOST_POSTS } from "../limits";
1717+import {
1818+ MAX_HOLD_DAYS_BEFORE_PURGE, MAX_POSTED_LENGTH,
1919+ MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST
2020+} from "../limits";
1721import {
1822 BatchQuery,
1923 Bindings, BskyAPILoginCreds, CreateObjectResponse, CreatePostQueryResponse,
2024 EmbedDataType, GetAllPostedBatch, LooseObj, PlatformLoginResponse,
2121- Post, PostLabel, R2BucketObject, Repost, ScheduledContext, Violation
2525+ Post, PostLabel, R2BucketObject, Repost, RepostInfo, ScheduledContext, Violation
2226} from "../types.d";
2327import { PostSchema } from "../validation/postSchema";
2428import { RepostSchema } from "../validation/repostSchema";
2529import { addFileListing } from "./dbQueryFile";
2630import {
2727- createLoginCredsObj, createPostObject, createRepostObject,
3131+ createLoginCredsObj, createPostObject, createRepostInfo, createRepostObject,
2832 floorCurrentTime, floorGivenTime
2933} from "./helpers";
3034import { deleteEmbedsFromR2, getAllFilesList } from "./r2Query";
···172176 }
173177 }
174178179179+ // Create repost metadata
180180+ const scheduleGUID = uuidv4();
181181+ const repostInfo: RepostInfo = createRepostInfo(scheduleGUID, scheduleDate, repostData);
182182+175183 // Create the posts
176184 const postUUID = uuidv4();
177185 let dbOperations: BatchItem<"sqlite">[] = [
···180188 uuid: postUUID,
181189 postNow: makePostNow,
182190 scheduledDate: scheduleDate,
191191+ repostInfo: [repostInfo],
183192 embedContent: embeds,
184193 contentLabel: label || PostLabel.None,
185194 userId: userId
···201210 for (var i = 1; i <= repostData.times; ++i) {
202211 dbOperations.push(db.insert(reposts).values({
203212 uuid: postUUID,
213213+ scheduleGuid: scheduleGUID,
204214 scheduledDate: addHours(scheduleDate, i*repostData.hours)
205215 }));
206216 }
···249259 }
250260 let postUUID;
251261 let dbOperations: BatchItem<"sqlite">[] = [];
262262+ const scheduleGUID = uuidv4();
263263+ const repostInfo: RepostInfo = createRepostInfo(scheduleGUID, scheduleDate, repostData);
252264253265 // Check to see if the post already exists
254266 // (check also against the userId here as well to avoid cross account data collisions)
255255- const existingPost = await db.select({id: posts.uuid, date: posts.scheduledDate}).from(posts).where(and(
267267+ const existingPost = await db.select({id: posts.uuid, date: posts.scheduledDate, curRepostInfo: posts.repostInfo}).from(posts).where(and(
256268 eq(posts.userId, userId), eq(posts.cid, cid))).limit(1).all();
257269258258- const hasExistingPost = existingPost.length >= 1;
270270+ const hasExistingPost:boolean = existingPost.length >= 1;
259271 if (hasExistingPost) {
260272 postUUID = existingPost[0].id;
273273+ const existingPostDate = existingPost[0].date;
261274 // Ensure the date asked for is after what the post's schedule date is
262262- if (!isAfter(scheduleDate, existingPost[0].date)) {
275275+ if (!isAfter(scheduleDate, existingPostDate) && !isEqual(scheduledDate, existingPostDate)) {
263276 return { ok: false, msg: "Scheduled date must be after the initial post's date" };
264277 }
278278+ // Add repost info object to existing array
279279+ let newRepostInfo:RepostInfo[] = isEmpty(existingPost[0].curRepostInfo) ? [] : existingPost[0].curRepostInfo!;
280280+ if (newRepostInfo.length >= MAX_REPOST_RULES_PER_POST) {
281281+ return {ok: false, msg: `Num of reposts rules for this post has exceeded the limit of ${MAX_REPOST_RULES_PER_POST} rules`};
282282+ }
283283+284284+ newRepostInfo.push(repostInfo);
285285+ // push record update to add to json array
286286+ dbOperations.push(db.update(posts).set({repostInfo: newRepostInfo}).where(and(
287287+ eq(posts.userId, userId), eq(posts.cid, cid))));
265288 } else {
266289 // Limit of post reposts on the user's account.
267290 const accountCurrentReposts = await db.$count(posts, and(eq(posts.userId, userId), eq(posts.isRepost, true)));
···279302 uri: uri,
280303 posted: true,
281304 isRepost: true,
305305+ repostInfo: [repostInfo],
282306 scheduledDate: scheduleDate,
283307 userId: userId
284308 }));
···287311 // Push initial repost
288312 let totalRepostCount = 1;
289313 dbOperations.push(db.insert(reposts).values({
290290- uuid: postUUID,
291291- scheduledDate: scheduleDate
292292- })
293293- .onConflictDoNothing());
314314+ uuid: postUUID,
315315+ scheduleGuid: scheduleGUID,
316316+ scheduledDate: scheduleDate
317317+ }).onConflictDoNothing());
294318295319 // Push other repost times if we have them
296320 if (repostData) {
297321 for (var i = 1; i <= repostData.times; ++i) {
298322 dbOperations.push(db.insert(reposts).values({
299299- uuid: postUUID,
300300- scheduledDate: addHours(scheduleDate, i*repostData.hours)
301301- })
302302- .onConflictDoNothing());
323323+ uuid: postUUID,
324324+ scheduleGuid: scheduleGUID,
325325+ scheduledDate: addHours(scheduleDate, i*repostData.hours)
326326+ }).onConflictDoNothing());
303327 }
304328 totalRepostCount += repostData.times;
305329 }
···360384export const deleteAllRepostsBeforeCurrentTime = async (env: Bindings) => {
361385 const db: DrizzleD1Database = drizzle(env.DB);
362386 const currentTime = floorCurrentTime();
363363- const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime)).returning({id: reposts.uuid});
387387+ const deletedPosts = await db.delete(reposts).where(lte(reposts.scheduledDate, currentTime))
388388+ .returning({id: reposts.uuid, scheduleGuid: reposts.scheduleGuid});
364389365390 // This is really stupid and I hate it, but someone has to update repost counts once posted
366391 if (deletedPosts.length > 0) {
367392 let batchedQueries:BatchItem<"sqlite">[] = [];
368393 for (const deleted of deletedPosts) {
394394+ // Update counts
369395 const newCount = db.$count(reposts, eq(reposts.uuid, deleted.id));
370396 batchedQueries.push(db.update(repostCounts)
371371- .set({count: newCount})
372372- .where(eq(repostCounts.uuid, deleted.id)))
397397+ .set({count: newCount})
398398+ .where(eq(repostCounts.uuid, deleted.id)));
399399+400400+ // check if the repost data needs to be killed
401401+ if (!isEmpty(deleted.scheduleGuid)) {
402402+ // do a search to find if there are any reposts with the same scheduleguid.
403403+ // if there are none, this schedule should get removed from the repostInfo array
404404+ const stillHasSchedule = await db.select().from(reposts)
405405+ .where(and(isNotNull(reposts.scheduleGuid), eq(reposts.scheduleGuid, deleted.scheduleGuid!)))
406406+ .limit(1).all();
407407+408408+ // if this is empty, then we need to update the repost info.
409409+ if (isEmpty(stillHasSchedule)) {
410410+ // get the existing repost info to filter out this old data
411411+ const existingRepostInfoArr = (await db.select({repostInfo: posts.repostInfo}).from(posts)
412412+ .where(eq(posts.uuid, reposts.uuid)).limit(1).all())[0];
413413+ // check to see if there is anything in the repostInfo array
414414+ if (!isEmpty(existingRepostInfoArr)) {
415415+ // create a new array with the deleted out object
416416+ const newRepostInfoArr = existingRepostInfoArr.repostInfo!.filter((obj) => {
417417+ return obj.guid !== deleted.scheduleGuid!;
418418+ });
419419+ // push the new repost info array
420420+ batchedQueries.push(db.update(posts).set({repostInfo: newRepostInfoArr}).where(eq(posts.uuid, deleted.id)));
421421+ }
422422+ }
423423+ }
373424 }
374425 await db.batch(batchedQueries as BatchQuery);
375426 }
+21-1
src/utils/helpers.ts
···11import { startOfHour, subDays } from "date-fns";
22+import has from "just-has";
23import isEmpty from "just-is-empty";
33-import { BskyAPILoginCreds, Post, Repost } from "../types.d";
44+import { BskyAPILoginCreds, Post, Repost, RepostInfo } from "../types.d";
4556export function createPostObject(data: any) {
67 const postData: Post = (new Object() as Post);
···20212122 if (data.isRepost)
2223 postData.isRepost = data.isRepost;
2424+2525+ if (data.repostInfo)
2626+ postData.repostInfo = data.repostInfo;
23272428 // ATProto data
2529 if (data.uri)
···3640 repostObj.cid = data.cid;
3741 repostObj.uri = data.uri;
3842 repostObj.userId = data.userId;
4343+ if (data.scheduleGuid)
4444+ repostObj.scheduleGuid = data.scheduleGuid;
4545+ return repostObj;
4646+}
4747+4848+export function createRepostInfo(id: string, time: Date, repostData: any) {
4949+ const repostObj: RepostInfo = (new Object() as RepostInfo);
5050+ repostObj.time = time;
5151+ repostObj.guid = id;
5252+ if (has(repostData, "hours") && has(repostData, "times")) {
5353+ repostObj.hours = repostData.hours;
5454+ repostObj.count = repostData.times;
5555+ }
5656+ else {
5757+ repostObj.count = repostObj.hours = 0;
5858+ }
3959 return repostObj;
4060}
4161
+2-12
src/utils/scheduler.ts
···11import isEmpty from 'just-is-empty';
22-import { Bindings, LooseObj, Post, Repost, ScheduledContext } from '../types.d';
22+import { Bindings, Post, Repost, ScheduledContext } from '../types.d';
33import { makePost, makeRepost } from './bskyApi';
44import { pruneBskyPosts } from './bskyPrune';
55import {
···77 deletePosts,
88 getAllPostsForCurrentTime,
99 getAllRepostsForCurrentTime,
1010- purgePostedPosts,
1111- updatePostForGivenUser,
1010+ purgePostedPosts
1211} from './dbQuery';
1312import { getAllAbandonedMedia } from './dbQueryFile';
1413import { enqueuePost, enqueueRepost, isQueueEnabled } from './queuePublisher';
···2726 const madeRepost = await makeRepost(runtime, postData);
2827 if (madeRepost) {
2928 console.log(`Reposted ${postData.uri} successfully!`);
3030- try
3131- {
3232- // Force update the payload of the db when posted so that it updates the main post record
3333- const payload: LooseObj = { posted: true };
3434- await updatePostForGivenUser(runtime, postData.userId, postData.postid, payload);
3535- } catch(err) {
3636- console.error(`Failed to update the timestamp of the repost with error ${err}`);
3737- }
3838-3929 } else {
4030 console.warn(`Failed to repost ${postData.uri}`);
4131 }