social-autoposter 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/server.js +372 -3
- package/package.json +1 -1
- package/scripts/_insert_post_020.py +145 -0
- package/scripts/add_nightowl_to_config.py +521 -0
- package/scripts/cleanup_harness_tabs.py +49 -0
- package/scripts/dm_short_links.py +59 -0
- package/scripts/export_kent_handoff.py +62 -0
- package/scripts/generation_trace.py +54 -5
- package/scripts/mint_external_pool.py +205 -0
- package/scripts/mint_kent_pool.py +267 -0
- package/scripts/post_reddit.py +5 -1
- package/scripts/reddit_browser.py +54 -0
- package/scripts/twitter_browser.py +33 -0
- package/skill/run-reddit-threads.sh +7 -1
- package/skill/run-twitter-cycle.sh +14 -9
package/bin/server.js
CHANGED
|
@@ -3571,6 +3571,185 @@ async function handleApi(req, res) {
|
|
|
3571
3571
|
})().catch(e => json(res, { error: e.message }, 500));
|
|
3572
3572
|
}
|
|
3573
3573
|
|
|
3574
|
+
// GET /api/activity/deletion-requests - list activity keys currently flagged
|
|
3575
|
+
// pending deletion. Dashboard pulls this on tab activation so the trash icon
|
|
3576
|
+
// renders in "pending" state for already-flagged rows. We expose only the
|
|
3577
|
+
// minimal fields the UI needs (key + status + requested_at) so this is
|
|
3578
|
+
// cheap to poll alongside /api/activity.
|
|
3579
|
+
if (p === '/api/activity/deletion-requests' && req.method === 'GET') {
|
|
3580
|
+
return (async () => {
|
|
3581
|
+
const rows = await pq(
|
|
3582
|
+
"SELECT activity_key, status, requested_at FROM deletion_requests " +
|
|
3583
|
+
"WHERE status = 'pending' ORDER BY requested_at DESC LIMIT 5000"
|
|
3584
|
+
);
|
|
3585
|
+
return json(res, { requests: rows || [] });
|
|
3586
|
+
})().catch(e => json(res, { error: e.message }, 500));
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
// POST /api/activity/mark-deletion - flag an activity row for manual review
|
|
3590
|
+
// and email i@m13v.com with the details. We do NOT actually delete the
|
|
3591
|
+
// underlying post/reply/mention; the user reviews the email and decides.
|
|
3592
|
+
// Idempotent on activity_key (UNIQUE constraint -> ON CONFLICT DO NOTHING),
|
|
3593
|
+
// so a double-click or a re-fire after a page reload won't send a duplicate
|
|
3594
|
+
// email. The email body is built from the payload the dashboard already
|
|
3595
|
+
// has in hand so the server doesn't need to re-query the source table.
|
|
3596
|
+
if (p === '/api/activity/mark-deletion' && req.method === 'POST') {
|
|
3597
|
+
return readBody(req).then(async body => {
|
|
3598
|
+
let payload;
|
|
3599
|
+
try { payload = JSON.parse(body || '{}'); }
|
|
3600
|
+
catch { return json(res, { error: 'invalid JSON' }, 400); }
|
|
3601
|
+
const key = String(payload.key || '').trim();
|
|
3602
|
+
if (!key || key.length > 64) return json(res, { error: 'missing key' }, 400);
|
|
3603
|
+
// Map prefix -> kind label + numeric record id. Keys come from the
|
|
3604
|
+
// /api/activity SQL UNION and follow the convention prefix||id.
|
|
3605
|
+
// Longer prefixes must be matched before shorter ones (ktp before k).
|
|
3606
|
+
const PREFIXES = [
|
|
3607
|
+
['ktp', 'seo_top_post'], ['kru', 'seo_roundup'], ['kt', 'seo_top_page'],
|
|
3608
|
+
['kr', 'seo_reddit_page'], ['k', 'seo_serp_page'],
|
|
3609
|
+
['rr', 'post_resurrected'], ['pi', 'seo_page_improvement'], ['xp', 'seo_expired_page'],
|
|
3610
|
+
['dr', 'dm_reply'], ['d', 'dm'], ['s', 'reply_skipped'], ['r', 'reply'],
|
|
3611
|
+
['m', 'mention'], ['p', 'post'], ['g', 'gsc_query'],
|
|
3612
|
+
];
|
|
3613
|
+
let kind = 'unknown';
|
|
3614
|
+
let recordId = null;
|
|
3615
|
+
for (const [pref, label] of PREFIXES) {
|
|
3616
|
+
if (key.startsWith(pref) && /^\d+$/.test(key.slice(pref.length))) {
|
|
3617
|
+
kind = label;
|
|
3618
|
+
recordId = parseInt(key.slice(pref.length), 10);
|
|
3619
|
+
break;
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
const platform = String(payload.platform || '').slice(0, 64);
|
|
3623
|
+
const project = String(payload.project || '').slice(0, 128);
|
|
3624
|
+
// Non-admin: only allow flagging rows on projects in their claim. Posts
|
|
3625
|
+
// tab is admin-only today, but the Activity tab is shared.
|
|
3626
|
+
if (!req.user.admin) {
|
|
3627
|
+
const allowed = new Set(req.user.projects || []);
|
|
3628
|
+
if (!project || !allowed.has(project)) {
|
|
3629
|
+
return json(res, { error: 'project not allowed' }, 403);
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
const link = String(payload.link || '').slice(0, 2000);
|
|
3633
|
+
const summary = String(payload.summary || '').slice(0, 2000);
|
|
3634
|
+
const bodyText = String(payload.body || '').slice(0, 8000);
|
|
3635
|
+
const detail = String(payload.detail || '').slice(0, 500);
|
|
3636
|
+
const eventType = String(payload.type || '').slice(0, 64);
|
|
3637
|
+
const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : null;
|
|
3638
|
+
const requestedBy = (req.user && (req.user.email || req.user.sub)) || 'dashboard';
|
|
3639
|
+
|
|
3640
|
+
// Insert (or fetch the existing row if already pending). Idempotent.
|
|
3641
|
+
const insRows = await pq(
|
|
3642
|
+
"INSERT INTO deletion_requests " +
|
|
3643
|
+
"(activity_key, kind, record_id, platform, project, link, summary, occurred_at, requested_by, status) " +
|
|
3644
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending') " +
|
|
3645
|
+
"ON CONFLICT (activity_key) DO NOTHING " +
|
|
3646
|
+
"RETURNING id, email_sent_at",
|
|
3647
|
+
[key, kind, recordId, platform, project, link, summary, occurredAt, requestedBy]
|
|
3648
|
+
);
|
|
3649
|
+
let alreadyExisted = false;
|
|
3650
|
+
let drId, emailSentAt;
|
|
3651
|
+
if (insRows && insRows.length) {
|
|
3652
|
+
drId = insRows[0].id;
|
|
3653
|
+
emailSentAt = insRows[0].email_sent_at;
|
|
3654
|
+
} else {
|
|
3655
|
+
alreadyExisted = true;
|
|
3656
|
+
const ex = await pq(
|
|
3657
|
+
"SELECT id, email_sent_at, requested_at FROM deletion_requests WHERE activity_key = $1",
|
|
3658
|
+
[key]
|
|
3659
|
+
);
|
|
3660
|
+
if (ex && ex.length) {
|
|
3661
|
+
drId = ex[0].id;
|
|
3662
|
+
emailSentAt = ex[0].email_sent_at;
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
// Only attempt to send the notification email when we don't already
|
|
3667
|
+
// have one on file for this row. This is the second idempotency gate
|
|
3668
|
+
// (race-safe under the UNIQUE constraint above).
|
|
3669
|
+
let emailStatus = alreadyExisted && emailSentAt ? 'already_sent' : 'pending';
|
|
3670
|
+
if (!emailSentAt) {
|
|
3671
|
+
try {
|
|
3672
|
+
const env = loadEnv();
|
|
3673
|
+
const resendKey = (env.RESEND_API_KEY || process.env.RESEND_API_KEY || '').trim();
|
|
3674
|
+
const notifyTo = (env.NOTIFICATION_EMAIL || process.env.NOTIFICATION_EMAIL || 'i@m13v.com').trim();
|
|
3675
|
+
if (!resendKey) {
|
|
3676
|
+
emailStatus = 'no_api_key';
|
|
3677
|
+
await pq(
|
|
3678
|
+
"UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
|
|
3679
|
+
['RESEND_API_KEY missing in .env', drId]
|
|
3680
|
+
);
|
|
3681
|
+
} else {
|
|
3682
|
+
const fromAddr = 'Social Autoposter <matt@mail.omi.me>';
|
|
3683
|
+
const subj = 'Deletion request: ' + (platform || 'unknown') + ' / ' +
|
|
3684
|
+
(project || 'unknown') + ' / ' + (kind || 'unknown') + ' #' + (recordId || '?');
|
|
3685
|
+
const occurredStr = occurredAt ? occurredAt.toISOString() : '(unknown)';
|
|
3686
|
+
// Plain text; no dashes (per global CLAUDE.md rule). Resend
|
|
3687
|
+
// recommends both html and text but text alone is fine.
|
|
3688
|
+
const textBody =
|
|
3689
|
+
'A row was flagged for deletion from the social autoposter dashboard.\n\n' +
|
|
3690
|
+
'Activity key: ' + key + '\n' +
|
|
3691
|
+
'Kind: ' + kind + '\n' +
|
|
3692
|
+
'Record id: ' + (recordId == null ? '(unknown)' : recordId) + '\n' +
|
|
3693
|
+
'Event type: ' + (eventType || '(unknown)') + '\n' +
|
|
3694
|
+
'Platform: ' + (platform || '(unknown)') + '\n' +
|
|
3695
|
+
'Project: ' + (project || '(unknown)') + '\n' +
|
|
3696
|
+
'Occurred at: ' + occurredStr + '\n' +
|
|
3697
|
+
'Link: ' + (link || '(none)') + '\n' +
|
|
3698
|
+
'Detail: ' + (detail || '(none)') + '\n' +
|
|
3699
|
+
'Requested by: ' + requestedBy + '\n\n' +
|
|
3700
|
+
'Summary:\n' + (summary || '(none)') + '\n\n' +
|
|
3701
|
+
'Body:\n' + (bodyText || '(none)') + '\n';
|
|
3702
|
+
const r = await fetch('https://api.resend.com/emails', {
|
|
3703
|
+
method: 'POST',
|
|
3704
|
+
headers: {
|
|
3705
|
+
'Authorization': 'Bearer ' + resendKey,
|
|
3706
|
+
'Content-Type': 'application/json',
|
|
3707
|
+
},
|
|
3708
|
+
body: JSON.stringify({
|
|
3709
|
+
from: fromAddr,
|
|
3710
|
+
to: [notifyTo],
|
|
3711
|
+
subject: subj,
|
|
3712
|
+
text: textBody,
|
|
3713
|
+
}),
|
|
3714
|
+
});
|
|
3715
|
+
const rj = await r.json().catch(() => ({}));
|
|
3716
|
+
if (r.ok && rj && rj.id) {
|
|
3717
|
+
await pq(
|
|
3718
|
+
"UPDATE deletion_requests SET email_sent_at = NOW(), email_id = $1 WHERE id = $2",
|
|
3719
|
+
[String(rj.id), drId]
|
|
3720
|
+
);
|
|
3721
|
+
emailStatus = 'sent';
|
|
3722
|
+
} else {
|
|
3723
|
+
const errMsg = (rj && (rj.message || rj.error)) || ('HTTP ' + r.status);
|
|
3724
|
+
await pq(
|
|
3725
|
+
"UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
|
|
3726
|
+
[String(errMsg).slice(0, 500), drId]
|
|
3727
|
+
);
|
|
3728
|
+
emailStatus = 'send_failed';
|
|
3729
|
+
console.error('[mark-deletion] resend failed:', errMsg);
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
} catch (e) {
|
|
3733
|
+
emailStatus = 'send_failed';
|
|
3734
|
+
await pq(
|
|
3735
|
+
"UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
|
|
3736
|
+
[String(e.message || e).slice(0, 500), drId]
|
|
3737
|
+
).catch(() => {});
|
|
3738
|
+
console.error('[mark-deletion] exception:', e.message);
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
return json(res, {
|
|
3743
|
+
status: 'pending',
|
|
3744
|
+
already_existed: alreadyExisted,
|
|
3745
|
+
email_status: emailStatus,
|
|
3746
|
+
key,
|
|
3747
|
+
kind,
|
|
3748
|
+
record_id: recordId,
|
|
3749
|
+
});
|
|
3750
|
+
}).catch(e => json(res, { error: e.message }, 500));
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3574
3753
|
// GET /api/style/stats - posts grouped by engagement_style over a trailing window (default 24h)
|
|
3575
3754
|
if (p === '/api/style/stats' && req.method === 'GET') {
|
|
3576
3755
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -5674,6 +5853,42 @@ const HTML = `<!DOCTYPE html>
|
|
|
5674
5853
|
.reply-text { color: var(--text); margin-top: 2px; }
|
|
5675
5854
|
.hidden { display: none; }
|
|
5676
5855
|
|
|
5856
|
+
/* Mark-for-deletion trash button (Activity + Top tabs).
|
|
5857
|
+
States:
|
|
5858
|
+
.sa-del-btn default trash icon (greyed out, low opacity)
|
|
5859
|
+
.sa-del-btn:hover red glow, full opacity
|
|
5860
|
+
.sa-del-btn.is-loading spinner overlay, button disabled
|
|
5861
|
+
.sa-del-btn.is-pending amber color, tooltip "pending deletion"
|
|
5862
|
+
The button text/label is intentionally absent; the icon shows on hover
|
|
5863
|
+
via title/data-tooltip. */
|
|
5864
|
+
.sa-del-btn {
|
|
5865
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
5866
|
+
width: 24px; height: 24px; padding: 0;
|
|
5867
|
+
background: transparent; border: 1px solid transparent; border-radius: 6px;
|
|
5868
|
+
color: var(--text-muted, #888); cursor: pointer; opacity: 0.5;
|
|
5869
|
+
transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
|
5870
|
+
line-height: 1; font-size: 14px;
|
|
5871
|
+
vertical-align: middle;
|
|
5872
|
+
}
|
|
5873
|
+
.sa-del-btn:hover { opacity: 1; color: #ef4444; border-color: rgba(239, 68, 68, 0.35); }
|
|
5874
|
+
.sa-del-btn:focus-visible { outline: 1px solid #ef4444; outline-offset: 1px; opacity: 1; }
|
|
5875
|
+
.sa-del-btn svg { width: 14px; height: 14px; display: block; }
|
|
5876
|
+
.sa-del-btn.is-loading { pointer-events: none; opacity: 1; color: var(--cyan, #06b6d4); }
|
|
5877
|
+
.sa-del-btn.is-loading svg { opacity: 0; }
|
|
5878
|
+
.sa-del-btn.is-loading::after {
|
|
5879
|
+
content: ''; position: absolute; width: 12px; height: 12px;
|
|
5880
|
+
border: 2px solid rgba(6, 182, 212, 0.25); border-top-color: var(--cyan, #06b6d4);
|
|
5881
|
+
border-radius: 50%; animation: saDelSpin 0.8s linear infinite;
|
|
5882
|
+
}
|
|
5883
|
+
.sa-del-btn { position: relative; }
|
|
5884
|
+
@keyframes saDelSpin { to { transform: rotate(360deg); } }
|
|
5885
|
+
.sa-del-btn.is-pending {
|
|
5886
|
+
opacity: 1; color: #f59e0b; border-color: rgba(245, 158, 11, 0.35);
|
|
5887
|
+
background: rgba(245, 158, 11, 0.08);
|
|
5888
|
+
}
|
|
5889
|
+
.sa-del-btn.is-pending:hover { color: #f59e0b; border-color: rgba(245, 158, 11, 0.55); }
|
|
5890
|
+
.sa-del-btn.is-failed { color: #ef4444; opacity: 1; border-color: rgba(239, 68, 68, 0.55); }
|
|
5891
|
+
|
|
5677
5892
|
/* Activity tab */
|
|
5678
5893
|
.activity-controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
|
5679
5894
|
.activity-filter-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
@@ -6573,10 +6788,13 @@ const HTML = `<!DOCTYPE html>
|
|
|
6573
6788
|
<th style="width:90px;text-align:right;" class="activity-sortable" data-sort="cost_usd">
|
|
6574
6789
|
<span class="activity-header-label">Cost <span class="activity-sort-arrow" data-sort-arrow="cost_usd"></span></span>
|
|
6575
6790
|
</th>
|
|
6791
|
+
<th style="width:40px;text-align:center;">
|
|
6792
|
+
<span class="activity-header-label" title="Mark for deletion"> </span>
|
|
6793
|
+
</th>
|
|
6576
6794
|
</tr>
|
|
6577
6795
|
</thead>
|
|
6578
6796
|
<tbody id="activity-body">
|
|
6579
|
-
<tr><td colspan="
|
|
6797
|
+
<tr><td colspan="6" style="text-align:center;color:var(--text);padding:40px;">Loading…</td></tr>
|
|
6580
6798
|
</tbody>
|
|
6581
6799
|
</table>
|
|
6582
6800
|
</div>
|
|
@@ -8848,6 +9066,126 @@ function escapeHtml(s) {
|
|
|
8848
9066
|
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
8849
9067
|
}
|
|
8850
9068
|
|
|
9069
|
+
// ===== Mark-for-deletion helpers (Activity tab + Top tab) =====
|
|
9070
|
+
// _saDelPending is a client-side Set of activity keys (e.g. 'p123', 'r456')
|
|
9071
|
+
// known to already be flagged for deletion. Populated on tab activation by
|
|
9072
|
+
// loadDeletionRequests() and updated optimistically when the user clicks the
|
|
9073
|
+
// trash icon. We never delete from this set; pending is a one-way state.
|
|
9074
|
+
const _saDelPending = new Set();
|
|
9075
|
+
const _saDelFailed = new Set();
|
|
9076
|
+
|
|
9077
|
+
function renderDeleteBtnHtml(e) {
|
|
9078
|
+
if (!e || !e.key) return '';
|
|
9079
|
+
const isPending = _saDelPending.has(e.key);
|
|
9080
|
+
const isFailed = !isPending && _saDelFailed.has(e.key);
|
|
9081
|
+
// Stash the minimal payload the POST endpoint wants on the button itself
|
|
9082
|
+
// so the click handler can read it without re-querying state. Encoded
|
|
9083
|
+
// as a single base64 blob to keep HTML clean and skip per-field escaping.
|
|
9084
|
+
const payload = {
|
|
9085
|
+
key: e.key,
|
|
9086
|
+
type: e.type || '',
|
|
9087
|
+
platform: e.platform || '',
|
|
9088
|
+
project: e.project || '',
|
|
9089
|
+
link: e.link || '',
|
|
9090
|
+
summary: e.summary || '',
|
|
9091
|
+
body: e.body || '',
|
|
9092
|
+
detail: e.detail || '',
|
|
9093
|
+
occurred_at: e.occurred_at || null,
|
|
9094
|
+
};
|
|
9095
|
+
let b64 = '';
|
|
9096
|
+
try { b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); }
|
|
9097
|
+
catch { b64 = ''; }
|
|
9098
|
+
const title = isPending
|
|
9099
|
+
? 'pending deletion'
|
|
9100
|
+
: (isFailed ? 'delete request failed, click to retry' : 'mark for deletion');
|
|
9101
|
+
const cls = 'sa-del-btn' + (isPending ? ' is-pending' : '') + (isFailed ? ' is-failed' : '');
|
|
9102
|
+
// Trash can SVG (Heroicons-style, 24x24 viewBox). currentColor for theming.
|
|
9103
|
+
const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
9104
|
+
'<path d="M3 6h18"/>' +
|
|
9105
|
+
'<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>' +
|
|
9106
|
+
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
|
|
9107
|
+
'<path d="M10 11v6"/>' +
|
|
9108
|
+
'<path d="M14 11v6"/>' +
|
|
9109
|
+
'</svg>';
|
|
9110
|
+
return '<button type="button" class="' + cls + '" ' +
|
|
9111
|
+
'data-sa-del="1" ' +
|
|
9112
|
+
'data-sa-del-payload="' + b64 + '" ' +
|
|
9113
|
+
'aria-label="' + escapeHtml(title) + '" ' +
|
|
9114
|
+
'title="' + escapeHtml(title) + '">' + icon + '</button>';
|
|
9115
|
+
}
|
|
9116
|
+
|
|
9117
|
+
async function handleDeleteBtnClick(btn) {
|
|
9118
|
+
if (!btn || btn.classList.contains('is-loading')) return;
|
|
9119
|
+
// No-op if already pending (just give the hover tooltip).
|
|
9120
|
+
if (btn.classList.contains('is-pending')) return;
|
|
9121
|
+
let payload = null;
|
|
9122
|
+
try {
|
|
9123
|
+
const b64 = btn.getAttribute('data-sa-del-payload') || '';
|
|
9124
|
+
if (b64) payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
|
|
9125
|
+
} catch (e) { /* ignored - bail below */ }
|
|
9126
|
+
if (!payload || !payload.key) return;
|
|
9127
|
+
btn.classList.remove('is-failed');
|
|
9128
|
+
_saDelFailed.delete(payload.key);
|
|
9129
|
+
btn.classList.add('is-loading');
|
|
9130
|
+
btn.setAttribute('title', 'sending...');
|
|
9131
|
+
try {
|
|
9132
|
+
const r = await fetch('/api/activity/mark-deletion', {
|
|
9133
|
+
method: 'POST',
|
|
9134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9135
|
+
body: JSON.stringify(payload),
|
|
9136
|
+
});
|
|
9137
|
+
const j = await r.json().catch(() => ({}));
|
|
9138
|
+
btn.classList.remove('is-loading');
|
|
9139
|
+
if (r.ok && (j.status === 'pending' || j.already_existed)) {
|
|
9140
|
+
_saDelPending.add(payload.key);
|
|
9141
|
+
btn.classList.add('is-pending');
|
|
9142
|
+
btn.setAttribute('title', 'pending deletion');
|
|
9143
|
+
btn.setAttribute('aria-label', 'pending deletion');
|
|
9144
|
+
} else {
|
|
9145
|
+
_saDelFailed.add(payload.key);
|
|
9146
|
+
btn.classList.add('is-failed');
|
|
9147
|
+
const msg = (j && j.error) ? String(j.error) : ('HTTP ' + r.status);
|
|
9148
|
+
btn.setAttribute('title', 'failed: ' + msg + ' (click to retry)');
|
|
9149
|
+
}
|
|
9150
|
+
} catch (e) {
|
|
9151
|
+
btn.classList.remove('is-loading');
|
|
9152
|
+
_saDelFailed.add(payload.key);
|
|
9153
|
+
btn.classList.add('is-failed');
|
|
9154
|
+
btn.setAttribute('title', 'failed: ' + (e.message || 'network error') + ' (click to retry)');
|
|
9155
|
+
}
|
|
9156
|
+
}
|
|
9157
|
+
|
|
9158
|
+
async function loadDeletionRequests() {
|
|
9159
|
+
try {
|
|
9160
|
+
const r = await fetch('/api/activity/deletion-requests');
|
|
9161
|
+
if (!r.ok) return;
|
|
9162
|
+
const j = await r.json();
|
|
9163
|
+
const list = (j && j.requests) || [];
|
|
9164
|
+
list.forEach(row => {
|
|
9165
|
+
if (row && row.activity_key) _saDelPending.add(row.activity_key);
|
|
9166
|
+
});
|
|
9167
|
+
} catch (e) {
|
|
9168
|
+
// Silent - the buttons just stay in the default state until the user
|
|
9169
|
+
// refreshes the tab. No UX regression vs not having the feature.
|
|
9170
|
+
}
|
|
9171
|
+
}
|
|
9172
|
+
|
|
9173
|
+
// Global delegated click handler so every trash button on every tab works
|
|
9174
|
+
// without per-row wiring. Idempotent: re-wiring is a no-op because we set a
|
|
9175
|
+
// flag on document.body.
|
|
9176
|
+
function _saInstallDeleteListener() {
|
|
9177
|
+
if (document.body && document.body.dataset.saDelInstalled === '1') return;
|
|
9178
|
+
document.addEventListener('click', ev => {
|
|
9179
|
+
const btn = ev.target.closest && ev.target.closest('button[data-sa-del]');
|
|
9180
|
+
if (!btn) return;
|
|
9181
|
+
ev.preventDefault();
|
|
9182
|
+
ev.stopPropagation();
|
|
9183
|
+
handleDeleteBtnClick(btn);
|
|
9184
|
+
});
|
|
9185
|
+
if (document.body) document.body.dataset.saDelInstalled = '1';
|
|
9186
|
+
}
|
|
9187
|
+
// ===== end mark-for-deletion helpers =====
|
|
9188
|
+
|
|
8851
9189
|
const PLATFORM_ICONS = {
|
|
8852
9190
|
reddit: '<svg viewBox="0 0 24 24" aria-label="reddit"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.436 13.158c.023.16.034.323.034.49 0 2.498-2.908 4.522-6.494 4.522-3.587 0-6.494-2.024-6.494-4.523 0-.167.011-.33.033-.489a1.44 1.44 0 01-.822-1.297 1.444 1.444 0 012.448-1.036 7.967 7.967 0 014.337-1.374l.82-3.865a.277.277 0 01.328-.215l2.69.57a1.004 1.004 0 011.813.068 1.005 1.005 0 01-1.813.875l-2.406-.51-.736 3.47a7.98 7.98 0 014.298 1.379 1.44 1.44 0 011.996.432c.35.56.2 1.29-.332 1.652-.02.013-.04.025-.06.037zM9.17 13.14a1.02 1.02 0 100-2.041 1.02 1.02 0 000 2.041zm6.69-1.02a1.02 1.02 0 10-2.04 0 1.02 1.02 0 002.04 0zm-1.01 3.32a.33.33 0 00-.467 0c-.56.56-1.63.605-1.944.605s-1.384-.046-1.944-.606a.33.33 0 00-.467.467c.887.887 2.587.957 2.411.957.176 0 1.524-.07 2.411-.957a.33.33 0 000-.466z"/></svg>',
|
|
8853
9191
|
twitter: '<svg viewBox="0 0 24 24" aria-label="twitter"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
|
|
@@ -11583,9 +11921,29 @@ function renderTopPosts(payload) {
|
|
|
11583
11921
|
filterMode: 'dropdown',
|
|
11584
11922
|
filterOptions: ageThresholdOptions(),
|
|
11585
11923
|
filterPredicate: filterPredicateAge },
|
|
11586
|
-
{ key: 'our_content', label: 'Content', type: 'text', align: 'left', widthPct:
|
|
11924
|
+
{ key: 'our_content', label: 'Content', type: 'text', align: 'left', widthPct: 46,
|
|
11587
11925
|
formatter: renderTopContentCell,
|
|
11588
11926
|
filterMode: 'none' },
|
|
11927
|
+
{ key: 'id', label: '', type: 'text', align: 'center', widthPct: 2,
|
|
11928
|
+
// Per-row "mark for deletion" trash button. The Top tab only shows
|
|
11929
|
+
// posts, so we synthesize an Activity-style event key with the 'p'
|
|
11930
|
+
// prefix and pass through the platform/project/content the email
|
|
11931
|
+
// would want to surface. The button itself + click handler live in
|
|
11932
|
+
// the shared sa-del-btn helpers.
|
|
11933
|
+
formatter: (_v, r) => {
|
|
11934
|
+
const ev = {
|
|
11935
|
+
key: 'p' + r.id,
|
|
11936
|
+
type: r.is_thread ? 'posted_thread' : 'posted_comment',
|
|
11937
|
+
platform: r.platform || '',
|
|
11938
|
+
project: r.project_name || '',
|
|
11939
|
+
link: r.our_url || '',
|
|
11940
|
+
summary: r.our_content || '',
|
|
11941
|
+
body: r.our_content || '',
|
|
11942
|
+
occurred_at: r.posted_at || null,
|
|
11943
|
+
};
|
|
11944
|
+
return renderDeleteBtnHtml(ev);
|
|
11945
|
+
},
|
|
11946
|
+
filterMode: 'none' },
|
|
11589
11947
|
],
|
|
11590
11948
|
});
|
|
11591
11949
|
}
|
|
@@ -13449,7 +13807,7 @@ function renderActivity(events) {
|
|
|
13449
13807
|
sorted.length + ' of ' + events.length + ' events';
|
|
13450
13808
|
renderPagination(sorted.length);
|
|
13451
13809
|
if (!page.length) {
|
|
13452
|
-
body.innerHTML = '<tr><td colspan="
|
|
13810
|
+
body.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text);padding:40px;">No matching events</td></tr>';
|
|
13453
13811
|
return;
|
|
13454
13812
|
}
|
|
13455
13813
|
const rows = page.map(e => {
|
|
@@ -13527,6 +13885,7 @@ function renderActivity(events) {
|
|
|
13527
13885
|
'</td>' +
|
|
13528
13886
|
'<td class="activity-summary">' + summaryHtml + '</td>' +
|
|
13529
13887
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
|
|
13888
|
+
'<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
|
|
13530
13889
|
'</tr>';
|
|
13531
13890
|
}).join('');
|
|
13532
13891
|
body.innerHTML = rows;
|
|
@@ -13582,6 +13941,9 @@ function activateTab(name) {
|
|
|
13582
13941
|
if (name === 'activity') {
|
|
13583
13942
|
buildActivityFilters();
|
|
13584
13943
|
if (!_tabLoaded.activity) _tabLoaded.activity = true;
|
|
13944
|
+
// Refresh the pending-deletion Set so the trash icon renders in
|
|
13945
|
+
// "pending" state for already-flagged rows on tab activation.
|
|
13946
|
+
loadDeletionRequests();
|
|
13585
13947
|
startActivityAutoRefresh();
|
|
13586
13948
|
} else {
|
|
13587
13949
|
stopActivityAutoRefresh();
|
|
@@ -13597,6 +13959,9 @@ function activateTab(name) {
|
|
|
13597
13959
|
}
|
|
13598
13960
|
if (name === 'top') {
|
|
13599
13961
|
initTopFilters();
|
|
13962
|
+
// Same pending-deletion refresh as Activity: makes the trash icon
|
|
13963
|
+
// show the right state immediately when the user lands on Top.
|
|
13964
|
+
loadDeletionRequests();
|
|
13600
13965
|
if (_topSubtab === 'pages') {
|
|
13601
13966
|
loadTopPages();
|
|
13602
13967
|
} else if (_topSubtab === 'dms') {
|
|
@@ -13623,6 +13988,10 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|
|
13623
13988
|
try { window.posthog && window.posthog.capture('tab_view', { tab: name }); } catch (e) {}
|
|
13624
13989
|
});
|
|
13625
13990
|
});
|
|
13991
|
+
// Install the global delegated click handler for "mark for deletion" trash
|
|
13992
|
+
// buttons. Idempotent (early-exits if already installed). Runs once the body
|
|
13993
|
+
// is parsed so the listener is in place before any tab render fires.
|
|
13994
|
+
_saInstallDeleteListener();
|
|
13626
13995
|
// On first paint, restore the persisted main tab. The HTML defaults to
|
|
13627
13996
|
// "stats" — only override if the saved value is a known tab and is currently
|
|
13628
13997
|
// visible to the user (some tabs are admin-only).
|
package/package.json
CHANGED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3.11
|
|
2
|
+
"""One-shot insert/update for post-020 (TLH-lesson-13, organic type)."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import psycopg2
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
load_dotenv(Path.home() / "social-autoposter" / ".env")
|
|
11
|
+
|
|
12
|
+
POST_NUMBER = 20
|
|
13
|
+
VARIANT_ID = "lesson-13"
|
|
14
|
+
COMPOSITION_ID = "TLH-lesson-13"
|
|
15
|
+
VIDEO_PATH = str(Path.home() / "social-autoposter/mixer/remotion/out/post-020.mp4")
|
|
16
|
+
CAPTION_PATH = Path.home() / "social-autoposter/mixer/remotion/out/post-020.caption.txt"
|
|
17
|
+
AUDIO_SOURCE = "local:~/social-autoposter/mixer/audio/track-020_demo-bgm.m4a"
|
|
18
|
+
THEME_ANGLE = "ai-cofounder"
|
|
19
|
+
UNPROVEN_CLIP_BASENAME = "91CCEBC8-DCFC-4F0F-8B2B-3B064647ABCF.MP4"
|
|
20
|
+
UNPROVEN_CLIP_PATH = (
|
|
21
|
+
"/Users/matthewdi/social-autoposter/unproven new content/"
|
|
22
|
+
+ UNPROVEN_CLIP_BASENAME
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
clips = [
|
|
26
|
+
{
|
|
27
|
+
"src": "mixer/tlh-13-1.mp4",
|
|
28
|
+
"raw_src": (
|
|
29
|
+
"/Users/matthewdi/social-autoposter/mixer/unproven new content/"
|
|
30
|
+
+ UNPROVEN_CLIP_BASENAME
|
|
31
|
+
),
|
|
32
|
+
"target_dur_sec": 2.0,
|
|
33
|
+
"src_dur_sec": 8.0,
|
|
34
|
+
"speedup": 4.0,
|
|
35
|
+
},
|
|
36
|
+
{"src": "mixer/tlh-4-3.mp4", "target_dur_sec": 2.0, "src_dur_sec": 2.0, "speedup": 1.0},
|
|
37
|
+
{"src": "mixer/tlh-6-4.mp4", "target_dur_sec": 2.0, "src_dur_sec": 2.0, "speedup": 1.0},
|
|
38
|
+
{"src": "mixer/tlh-7-1.mp4", "target_dur_sec": 2.0, "src_dur_sec": 2.0, "speedup": 1.0},
|
|
39
|
+
]
|
|
40
|
+
source_clips = []
|
|
41
|
+
t = 0.0
|
|
42
|
+
for i, c in enumerate(clips, 1):
|
|
43
|
+
entry = {
|
|
44
|
+
"order": i,
|
|
45
|
+
"src": c["src"],
|
|
46
|
+
"src_dur_sec": c["src_dur_sec"],
|
|
47
|
+
"target_dur_sec": c["target_dur_sec"],
|
|
48
|
+
"speedup": c["speedup"],
|
|
49
|
+
"start_sec": round(t, 3),
|
|
50
|
+
"end_sec": round(t + c["target_dur_sec"], 3),
|
|
51
|
+
}
|
|
52
|
+
if "raw_src" in c:
|
|
53
|
+
entry["raw_src"] = c["raw_src"]
|
|
54
|
+
source_clips.append(entry)
|
|
55
|
+
t += c["target_dur_sec"]
|
|
56
|
+
total_sec = round(t, 3)
|
|
57
|
+
|
|
58
|
+
overlay_lines = [
|
|
59
|
+
"i looked for a cofounder for 6 years.",
|
|
60
|
+
"claude argued back better than humans.",
|
|
61
|
+
"i shipped 3 features in one weekend.",
|
|
62
|
+
"the cofounder was never the moat.",
|
|
63
|
+
]
|
|
64
|
+
overlays = []
|
|
65
|
+
for i, text in enumerate(overlay_lines):
|
|
66
|
+
s = round(i * 2.0, 3)
|
|
67
|
+
overlays.append({
|
|
68
|
+
"order": i + 1,
|
|
69
|
+
"text": text,
|
|
70
|
+
"start_sec": s,
|
|
71
|
+
"end_sec": round(s + 2.0, 3),
|
|
72
|
+
"dur_sec": 2.0,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
metadata = {
|
|
76
|
+
"theme": "ai",
|
|
77
|
+
"format": "tlh",
|
|
78
|
+
"clip_count": len(source_clips),
|
|
79
|
+
"source_repo": "social-autoposter",
|
|
80
|
+
"theme_angle": THEME_ANGLE,
|
|
81
|
+
"theme_label": "ai-cofounder",
|
|
82
|
+
"caption_style": "v1-here-is-a-story",
|
|
83
|
+
"overlay_count": len(overlays),
|
|
84
|
+
"composition_id": COMPOSITION_ID,
|
|
85
|
+
"description_style": "narrative-story-arc",
|
|
86
|
+
"unproven_clip_basename": UNPROVEN_CLIP_BASENAME,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
caption_text = CAPTION_PATH.read_text(encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
conn = psycopg2.connect(os.environ["DATABASE_URL"])
|
|
92
|
+
cur = conn.cursor()
|
|
93
|
+
|
|
94
|
+
cur.execute("SELECT post_number FROM media_posts WHERE post_number=%s", (POST_NUMBER,))
|
|
95
|
+
exists = cur.fetchone() is not None
|
|
96
|
+
|
|
97
|
+
if exists:
|
|
98
|
+
cur.execute(
|
|
99
|
+
"""
|
|
100
|
+
UPDATE media_posts SET
|
|
101
|
+
variant_id=%s,
|
|
102
|
+
project_name=%s,
|
|
103
|
+
post_type=%s,
|
|
104
|
+
video_path=%s,
|
|
105
|
+
audio_source=%s,
|
|
106
|
+
caption_text=%s,
|
|
107
|
+
caption_version='v1-story',
|
|
108
|
+
duration_sec=%s,
|
|
109
|
+
width=1080,
|
|
110
|
+
height=1920,
|
|
111
|
+
status='draft',
|
|
112
|
+
source_clips=%s::jsonb,
|
|
113
|
+
overlays=%s::jsonb,
|
|
114
|
+
metadata=%s::jsonb
|
|
115
|
+
WHERE post_number=%s
|
|
116
|
+
""",
|
|
117
|
+
(
|
|
118
|
+
VARIANT_ID, "fazm", "organic", VIDEO_PATH, AUDIO_SOURCE, caption_text,
|
|
119
|
+
total_sec, json.dumps(source_clips), json.dumps(overlays),
|
|
120
|
+
json.dumps(metadata), POST_NUMBER,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
print(f"UPDATED row post_number={POST_NUMBER}")
|
|
124
|
+
else:
|
|
125
|
+
cur.execute(
|
|
126
|
+
"""
|
|
127
|
+
INSERT INTO media_posts
|
|
128
|
+
(post_number, variant_id, project_name, post_type, video_path, audio_source,
|
|
129
|
+
caption_text, caption_version, duration_sec, width, height, status,
|
|
130
|
+
source_clips, overlays, metadata)
|
|
131
|
+
VALUES
|
|
132
|
+
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb)
|
|
133
|
+
""",
|
|
134
|
+
(
|
|
135
|
+
POST_NUMBER, VARIANT_ID, "fazm", "organic", VIDEO_PATH, AUDIO_SOURCE,
|
|
136
|
+
caption_text, "v1-story", total_sec, 1080, 1920, "draft",
|
|
137
|
+
json.dumps(source_clips), json.dumps(overlays), json.dumps(metadata),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
print(f"INSERTED row post_number={POST_NUMBER}")
|
|
141
|
+
|
|
142
|
+
conn.commit()
|
|
143
|
+
cur.close()
|
|
144
|
+
conn.close()
|
|
145
|
+
print(f"done: variant={VARIANT_ID} angle={THEME_ANGLE} unproven={UNPROVEN_CLIP_BASENAME}")
|