for(constrowofdb.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id)as{attachments_json: string}[]){try{used+=(JSON.parse(row.attachments_json)asany[]).filter((item)=>String(item.url||"").startsWith(`/uploads/cache/source-${source.id}/`)).reduce((sum,item)=>sum+Number(item.size||0),0);}catch{}}
constused=newSet<string>();for(constrowofdb.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id)as{attachments_json: string}[]){try{for(constattachmentofJSON.parse(row.attachments_json)asany[]){consturl=String(attachment.url||"");if(url.startsWith(`/uploads/cache/source-${source.id}/`))used.add(url.split("/").at(-1)!);}}catch{}}
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url,last_seen_sync_run) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,last_seen_sync_run=excluded.last_seen_sync_run,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id,source.user_id,memo.name,memo.content,memo.visibility,tags,attachments,"memos",memo.createTime||null,memo.updateTime||null,memoUrl(source.base_url,memo.name),syncRun);
if(source.integration_type==="rss"){constitems=awaitfetchRss(source.rss_feed_url||"");for(constitemofitems)db.prepare("INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,'[]','[]','rss',?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,remote_created_at=excluded.remote_created_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP").run(source.id,source.user_id,`rss:${item.id}`,item.content,"PUBLIC",item.publishedAt,item.publishedAt,item.link);constnames=items.map((item)=>`rss:${item.id}`);if(names.length)db.prepare(`UPDATE posts SET hidden=1 WHERE source_id=? AND remote_memo_name NOT IN (${names.map(()=>"?").join(",")})`).run(source.id,...names);db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id);returnfalse;}
if(remaining===0){db.prepare("UPDATE sources SET sync_cursor=NULL,sync_status='synced',last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);returnfalse;}
if(complete)db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND origin='memos' AND COALESCE(last_seen_sync_run,'')<>?").run(source.id,syncRun);
db.prepare("UPDATE posts SET remote_memo_name=?,remote_created_at=?,remote_updated_at=?,remote_url=?,sync_status='synced',updated_at=CURRENT_TIMESTAMP WHERE id=?").run(memo.name,memo.createTime||null,memo.updateTime||null,memoUrl(source.base_url,memo.name),post.id);
constjob=db.prepare("SELECT * FROM sync_jobs WHERE status='queued' AND run_after<=CURRENT_TIMESTAMP ORDER BY id LIMIT 1").get()asJob|undefined;
if(!job)return;
db.prepare("UPDATE sync_jobs SET status='running',attempts=attempts+1,started_at=CURRENT_TIMESTAMP WHERE id=?").run(job.id);
constsource=db.prepare("SELECT * FROM sources WHERE id=?").get(job.source_id)asSource|undefined;
if(!source||!source.is_enabled){db.prepare("UPDATE sync_jobs SET status='cancelled',last_error='Source is disabled or deleted',finished_at=CURRENT_TIMESTAMP WHERE id=?").run(job.id);return;}
if(more)db.prepare("INSERT INTO sync_jobs(source_id,kind,trigger) VALUES(?,'pull','batch')").run(source.id);elsedb.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
db.prepare("UPDATE sync_jobs SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now','+5 minutes') END WHERE id=?").run(exhausted?"failed":"queued",message,exhausted?1 : 0,exhausted?1 : 0,job.id);
db.prepare(`INSERT INTO sync_jobs(source_id,kind,trigger) SELECT id,'pull','scheduled' FROM sources WHERE is_enabled=1 AND COALESCE(last_synced_at,'1970-01-01') < datetime('now', ?) AND NOT EXISTS (SELECT 1 FROM sync_jobs j WHERE j.source_id=sources.id AND j.kind='pull' AND j.status IN ('queued','running'))`).run(`-${interval} minutes`);
db.prepare("DELETE FROM source_invites WHERE expires_at<CURRENT_TIMESTAMP AND (used_at IS NOT NULL OR expires_at<datetime('now','-30 days'))").run();
constcleanup=(table: string,column: string,value: number)=>{if(value)db.prepare(`DELETE FROM ${table} WHERE ${column}<datetime('now', ?)`).run(`-${value} days`);};
conststale=db.prepare("SELECT id,name,last_webhook_at FROM sources WHERE is_enabled=1 AND webhook_mode='signed' AND (last_webhook_at IS NULL OR last_webhook_at<datetime('now','-7 days'))").all()as{id: number;name: string;last_webhook_at: string|null}[];
for(constsourceofstale)voidsendAlert(`webhook:${source.id}`,"Mebbling Webhook 未收到事件",`來源「${source.name}」超過 7 天未收到 Webhook;目前仍會以定期 API 同步校正。`);