Accounts Phase 3: save articles, account history, device import
- API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST /api/history, POST /api/import — all FK-safe (skip ids that no longer exist). queries.saved/saved_ids/history reuse the feed article shape. - Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save control on cards for signed-in users; a "Saved" view (You sheet) with its own empty state; newly-seen items mirror to account history (cross-device); and a one-time import folds this device's anonymous history into the account on first sign-in. Anonymous browsing unchanged. 115 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,60 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
||||
}
|
||||
|
||||
|
||||
def saved(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[dict]:
|
||||
"""Articles a user has saved, newest first (same shape as the feed)."""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT {_ARTICLE_COLUMNS}
|
||||
FROM saved_articles sv
|
||||
JOIN articles a ON a.id = sv.article_id
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE sv.user_id = ?
|
||||
ORDER BY sv.saved_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def saved_ids(conn: sqlite3.Connection, user_id: int) -> list[int]:
|
||||
return [r[0] for r in conn.execute(
|
||||
"SELECT article_id FROM saved_articles WHERE user_id = ?", (user_id,)
|
||||
)]
|
||||
|
||||
|
||||
def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[dict]:
|
||||
"""Articles in a user's account history, most-recent first."""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT {_ARTICLE_COLUMNS}, MAX(h.at) AS seen_at
|
||||
FROM user_history h
|
||||
JOIN articles a ON a.id = h.article_id
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE h.user_id = ?
|
||||
GROUP BY a.id
|
||||
ORDER BY seen_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def existing_article_ids(conn: sqlite3.Connection, ids: list[int]) -> list[int]:
|
||||
"""Filter to ids that still exist (FK-safe inserts for save/history/import)."""
|
||||
clean = list({int(i) for i in ids})[:1000]
|
||||
if not clean:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(clean))
|
||||
return [r[0] for r in conn.execute(
|
||||
f"SELECT id FROM articles WHERE id IN ({placeholders})", clean
|
||||
)]
|
||||
|
||||
|
||||
def tag_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> dict:
|
||||
"""How many shown (accepted, non-duplicate) articles carry each grouping tag."""
|
||||
where = "WHERE a.duplicate_of IS NULL" + (" AND s.accepted = 1" if accepted_only else "")
|
||||
|
||||
Reference in New Issue
Block a user