diff --git a/.gitignore b/.gitignore index 54e12c40..5362fb92 100755 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ dashwise-sdk/node_modules .DS_Store *.pem +# local development data +**/.data/ + # debug npm-debug.log* yarn-debug.log* diff --git a/apps/backend/src/jobs/index.ts b/apps/backend/src/jobs/index.ts index 6b10d758..1bb8c1a1 100644 --- a/apps/backend/src/jobs/index.ts +++ b/apps/backend/src/jobs/index.ts @@ -188,10 +188,12 @@ export function registerJobsCron() { await runDefaultIntegrationsJob("scheduled run"); }); - void runNewsFeedBuilderJob("initial run"); - Bun.cron(config.FEED_BUILDING_SCHEDULE, async () => { - await runNewsFeedBuilderJob("scheduled run"); - }); + if (!config.DEV_DISABLE_NEWS_INDEXING) { + void runNewsFeedBuilderJob("initial run"); + Bun.cron(config.FEED_BUILDING_SCHEDULE, async () => { + await runNewsFeedBuilderJob("scheduled run"); + }); + } Bun.cron(config.NOTIFICATION_FORWARDER_SCHEDULE, async () => { await runNotificationForwarderJob("cron schedule"); diff --git a/apps/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index c17995d8..205cffe5 100644 --- a/apps/backend/src/jobs/news/feed-builder.ts +++ b/apps/backend/src/jobs/news/feed-builder.ts @@ -15,6 +15,7 @@ import { updateNewsSubscription, } from "../../lib/data/superuser"; import { + hasSubscriptionArticles, readSubscriptionArticles, type CachedArticle, writeMaterializedFeed, @@ -333,7 +334,11 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = const fetchResults = await Promise.all(allSubscriptions .filter((subscription) => targetSubscriptionIds.has(String(subscription.id))) - .map((subscription) => fetchAndCacheSubscription(subscription, result))); + .map((subscription) => { + const subscriptionId = String(subscription.id || ""); + if (config.DEV_DISABLE_NEWS_INDEXING && hasSubscriptionArticles(subscriptionId)) return Promise.resolve(false); + return fetchAndCacheSubscription(subscription, result); + })); result.processed = fetchResults.length; const affectedUsers = new Set(targetUserIds); diff --git a/apps/backend/src/jobs/shortcuts-indexer.ts b/apps/backend/src/jobs/shortcuts-indexer.ts index 8132041d..458b03ff 100644 --- a/apps/backend/src/jobs/shortcuts-indexer.ts +++ b/apps/backend/src/jobs/shortcuts-indexer.ts @@ -9,8 +9,9 @@ type ShortcutRow = { name: string; icon: string; secondary: string; - action: string; - app: string; + action: string; + actions?: Record; + app: string; tags: string[]; sourceId?: string; sourceUpdated?: string; @@ -29,8 +30,10 @@ type ShortcutDefaultsRow = { icon?: unknown; secondary?: unknown; secondaryInfo?: unknown; - action?: unknown; - tags?: unknown; + action?: unknown; + actions?: unknown; + secondaryActions?: unknown; + tags?: unknown; }; export async function runShortcutsIndexing() { @@ -55,7 +58,7 @@ export async function runShortcutsIndexing() { name, icon: String(link?.iconUrl || link?.folderIcon || "/icons/faGlobe.svg"), secondary: String(link?.collection || link?.folder || "Link"), - action: url.startsWith("url:") ? url : `url:${url}`, + action: "link", app: "", tags: [ name, @@ -153,8 +156,9 @@ function buildDefaultShortcutRows(): ShortcutRow[] { name, icon: String(shortcut?.icon ?? "/icons/faGlobe.svg"), secondary: String(shortcut?.secondaryInfo ?? shortcut?.secondary ?? "Dashwise"), - action, - app: "", + action, + actions: normalizeActions(shortcut.actions ?? shortcut.secondaryActions), + app: "", tags: [ name, ...(Array.isArray(shortcut?.tags) ? shortcut.tags.map((tag: unknown) => String(tag)) : []), @@ -194,6 +198,22 @@ function normalizeObject(raw: unknown): Record { return {}; } +function normalizeActions(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record) + .filter(([label, value]) => label.trim() && typeof value === "string" && value.trim()) + .map(([label, value]) => [label.trim(), String(value).trim()]), + ); +} + +function parseActions(raw: unknown): Record { + if (typeof raw === "string") { + try { return normalizeActions(JSON.parse(raw)); } catch { return {}; } + } + return normalizeActions(raw); +} + function normalizeKey(value: string) { return String(value || "") .trim() @@ -312,8 +332,9 @@ async function buildIntegrationShortcutRows( name: item.name, icon: item.icon || integrationIcon, secondary: item.secondaryInfo || integrationName, - action: serializeShortcutAction(item.action), - app: shortcutApp.id, + action: serializeShortcutAction(item.action), + actions: item.actions, + app: shortcutApp.id, tags: item.tags, sourceId: integration.id, sourceUpdated: (integration as any).updated as string, @@ -381,11 +402,12 @@ async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[] const existingRecords = existingBySource.get(sid) || []; // Determine if it's a link or integration - const isLink = newRows.length === 1 && newRows[0].app === "" && newRows[0].action.startsWith("url:"); + const isLink = newRows.length === 1 && newRows[0].app === "" && (newRows[0].action === "link" || newRows[0].action.startsWith("url:")); if (isLink) { const newRow = newRows[0]; const existingRecord = existingRecords[0]; + let preservedState: Record = {}; if (existingRecord) { // Keep the existing shortcut when its source has not changed. @@ -393,11 +415,15 @@ async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[] const itemUpdated = new Date(existingRecord.updated).getTime(); if (sourceUpdated <= itemUpdated) { + if (JSON.stringify(parseActions(existingRecord.secondaryActions)) !== JSON.stringify(newRow.actions ?? {})) { + await pb.collection("shortcuts").update(existingRecord.id, { secondaryActions: newRow.actions ?? {} }); + } // Discard (keep existing) continue; } - // Replace + // Replace while preserving user-owned state. + preservedState = preserveShortcutState(existingRecord, newRow.app); await pb.collection("shortcuts").delete(existingRecord.id).catch(() => {}); } @@ -406,11 +432,13 @@ async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[] name: newRow.name, icon: newRow.icon, secondary: newRow.secondary, - action: newRow.action, - app: newRow.app || null, + action: newRow.action, + secondaryActions: newRow.actions ?? {}, + app: newRow.app || null, tags: JSON.stringify(newRow.tags ?? []), sourceId: sid, sourceUpdated: newRow.sourceUpdated, + ...preservedState, }); } else { // Integration logic: "regenerate every time and check whether the output differs" @@ -428,8 +456,9 @@ async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[] name: r.name, icon: r.icon, secondary: r.secondary, - action, - app: r.app, + action, + actions: parseActions(r.secondaryActions), + app: r.app, tags: parseTags(r.tags), }; }).sort((a, b) => a.action.localeCompare(b.action)); @@ -438,8 +467,9 @@ async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[] name: r.name, icon: r.icon, secondary: r.secondary, - action: r.action, - app: r.app, + action: r.action, + actions: r.actions, + app: r.app, tags: r.tags, })).sort((a, b) => a.action.localeCompare(b.action)); @@ -450,25 +480,44 @@ async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[] continue; } - // Replace all for this source + // Replace all for this source, retaining user-owned state for matching rows. + const remainingRecords = [...existingRecords]; for (const r of existingRecords) await pb.collection("shortcuts").delete(r.id).catch(() => {}); for (const row of newRows) { + const matchingRecordIndex = remainingRecords.findIndex((record) => + String(record.name ?? "") === row.name || String(record.action ?? "") === row.action, + ); + const matchingRecord = matchingRecordIndex >= 0 ? remainingRecords.splice(matchingRecordIndex, 1)[0] : null; + const preservedState = matchingRecord + ? preserveShortcutState(matchingRecord, row.app) + : {}; await pb.collection("shortcuts").create({ user: userId, name: row.name, icon: row.icon, secondary: row.secondary, - action: row.action, - app: row.app || null, + action: row.action, + secondaryActions: row.actions ?? {}, + app: row.app || null, tags: JSON.stringify(row.tags ?? []), sourceId: sid, sourceUpdated: row.sourceUpdated, + ...preservedState, }); } } } } +function preserveShortcutState(record: Record, generatedApp: string) { + const hasRootOverride = Boolean(generatedApp) && !record.app; + return { + isPinned: Boolean(record.isPinned), + isDisabled: Boolean(record.isDisabled), + ...(hasRootOverride ? { app: null } : {}), + }; +} + function serializeShortcutAction(action: unknown): string { if (typeof action === "string") { return action.trim(); diff --git a/apps/backend/src/lib/cache/feed-items-dev.ts b/apps/backend/src/lib/cache/feed-items-dev.ts new file mode 100644 index 00000000..d5cdd7c1 --- /dev/null +++ b/apps/backend/src/lib/cache/feed-items-dev.ts @@ -0,0 +1,259 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export type CachedArticle = { + dedupeKey: string; + canonicalUrl?: string; + guid?: string; + title: string; + publishedAt: number; + json: Record; + sourceIds?: string[]; +}; + +export type MaterializedFeedItem = { + id: string; + score: number; + json: Record; +}; + +export type FeedCacheMetadata = { + builtAt: string; + revision: string; + itemCount: number; + sourceRevision: string; +}; + +type Row = Record; + +const database = (Bun.env.ENVIRONMENT === "dev" + ? (() => { + const databasePath = Bun.env.DASHWISE_DEV_NEWS_DB_PATH || ".data/news-dev.sqlite"; + mkdirSync(dirname(databasePath), { recursive: true }); + return new Database(databasePath); + })() + : null) as Database; + +if (database) database.exec(` + CREATE TABLE IF NOT EXISTS articles ( + dedupe_key TEXT PRIMARY KEY, + canonical_url TEXT, + guid TEXT, + title TEXT NOT NULL, + published_at INTEGER NOT NULL, + json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS article_sources ( + dedupe_key TEXT NOT NULL, + subscription_id TEXT NOT NULL, + PRIMARY KEY (dedupe_key, subscription_id) + ); + CREATE TABLE IF NOT EXISTS subscription_articles ( + subscription_id TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + published_at INTEGER NOT NULL, + PRIMARY KEY (subscription_id, dedupe_key) + ); + CREATE TABLE IF NOT EXISTS subscription_fetches ( + subscription_id TEXT PRIMARY KEY, + fetched_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS materialized_items ( + user_id TEXT NOT NULL, + feed_id TEXT NOT NULL, + item_id TEXT NOT NULL, + score REAL NOT NULL, + json TEXT NOT NULL, + PRIMARY KEY (user_id, feed_id, item_id) + ); + CREATE TABLE IF NOT EXISTS materialized_metadata ( + user_id TEXT NOT NULL, + feed_id TEXT NOT NULL, + built_at TEXT NOT NULL, + revision TEXT NOT NULL, + item_count INTEGER NOT NULL, + source_revision TEXT NOT NULL, + PRIMARY KEY (user_id, feed_id) + ); +`); + +const parseJson = (value: unknown, fallback: T): T => { + try { + return JSON.parse(String(value)) as T; + } catch { + return fallback; + } +}; + + + +export function hasSubscriptionArticles(subscriptionId: string) { + return Boolean(database.query("SELECT 1 FROM subscription_fetches WHERE subscription_id = ? LIMIT 1").get(subscriptionId)); +} + +export function readArticle(dedupeKey: string): CachedArticle | null { + const row = database.query("SELECT dedupe_key, canonical_url, guid, title, published_at, json FROM articles WHERE dedupe_key = ?").get(dedupeKey) as Row | null; + if (!row) return null; + return { + dedupeKey: String(row.dedupe_key), + canonicalUrl: row.canonical_url ? String(row.canonical_url) : undefined, + guid: row.guid ? String(row.guid) : undefined, + title: String(row.title || ""), + publishedAt: Number(row.published_at) || 0, + json: parseJson>(row.json, {}), + }; +} + +export function readArticleSources(dedupeKey: string) { + return (database.query("SELECT subscription_id FROM article_sources WHERE dedupe_key = ?").all(dedupeKey) as Row[]) + .map((row) => String(row.subscription_id)); +} + +export function readSubscriptionArticles(subscriptionId: string): CachedArticle[] { + const rows = database.query(` + SELECT a.dedupe_key, a.canonical_url, a.guid, a.title, a.published_at, a.json + FROM subscription_articles sa + JOIN articles a ON a.dedupe_key = sa.dedupe_key + WHERE sa.subscription_id = ? + ORDER BY sa.published_at DESC + `).all(subscriptionId) as Row[]; + + return rows.map((row) => ({ + dedupeKey: String(row.dedupe_key), + canonicalUrl: row.canonical_url ? String(row.canonical_url) : undefined, + guid: row.guid ? String(row.guid) : undefined, + title: String(row.title || ""), + publishedAt: Number(row.published_at) || 0, + json: { ...parseJson>(row.json, {}), subscription_id: subscriptionId }, + sourceIds: readArticleSources(String(row.dedupe_key)), + })); +} + +export function writeSubscriptionArticles(subscriptionId: string, articles: CachedArticle[]) { + const transaction = database.transaction(() => { + const current = (database.query("SELECT dedupe_key FROM subscription_articles WHERE subscription_id = ?").all(subscriptionId) as Row[]) + .map((row) => String(row.dedupe_key)); + const next = new Map(articles.filter((article) => article.dedupeKey).map((article) => [article.dedupeKey, article])); + + for (const dedupeKey of current) { + if (next.has(dedupeKey)) continue; + database.query("DELETE FROM subscription_articles WHERE subscription_id = ? AND dedupe_key = ?").run(subscriptionId, dedupeKey); + database.query("DELETE FROM article_sources WHERE subscription_id = ? AND dedupe_key = ?").run(subscriptionId, dedupeKey); + database.query("DELETE FROM articles WHERE dedupe_key = ? AND NOT EXISTS (SELECT 1 FROM article_sources WHERE dedupe_key = ?)").run(dedupeKey, dedupeKey); + } + + for (const article of next.values()) { + const existing = readArticle(article.dedupeKey); + const selected = existing && JSON.stringify(existing.json).length > JSON.stringify(article.json).length ? existing : article; + database.query(` + INSERT INTO articles (dedupe_key, canonical_url, guid, title, published_at, json) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(dedupe_key) DO UPDATE SET + canonical_url = excluded.canonical_url, + guid = excluded.guid, + title = excluded.title, + published_at = excluded.published_at, + json = excluded.json + `).run( + article.dedupeKey, + selected.canonicalUrl || "", + selected.guid || "", + selected.title || "", + selected.publishedAt || 0, + JSON.stringify(selected.json), + ); + database.query("INSERT OR REPLACE INTO subscription_articles (subscription_id, dedupe_key, published_at) VALUES (?, ?, ?)") + .run(subscriptionId, article.dedupeKey, article.publishedAt || 0); + database.query("INSERT OR IGNORE INTO article_sources (dedupe_key, subscription_id) VALUES (?, ?)") + .run(article.dedupeKey, subscriptionId); + } + database.query("INSERT OR REPLACE INTO subscription_fetches (subscription_id, fetched_at) VALUES (?, ?)") + .run(subscriptionId, new Date().toISOString()); + }); + transaction(); +} + +export function deleteSubscriptionArticleIndex(subscriptionId: string) { + const transaction = database.transaction(() => { + const rows = database.query("SELECT dedupe_key FROM subscription_articles WHERE subscription_id = ?").all(subscriptionId) as Row[]; + database.query("DELETE FROM subscription_articles WHERE subscription_id = ?").run(subscriptionId); + database.query("DELETE FROM article_sources WHERE subscription_id = ?").run(subscriptionId); + for (const row of rows) { + database.query("DELETE FROM articles WHERE dedupe_key = ? AND NOT EXISTS (SELECT 1 FROM article_sources WHERE dedupe_key = ?)") + .run(String(row.dedupe_key), String(row.dedupe_key)); + } + database.query("DELETE FROM subscription_fetches WHERE subscription_id = ?").run(subscriptionId); + }); + transaction(); +} + +export function readMaterializedFeedPage(userId: string, feedId: string, offset: number, limit: number) { + const rows = database.query(` + SELECT item_id, score, json FROM materialized_items + WHERE user_id = ? AND feed_id = ? + ORDER BY score DESC + LIMIT ? OFFSET ? + `).all(userId, feedId, limit, offset) as Row[]; + const countRow = database.query("SELECT COUNT(*) AS count FROM materialized_items WHERE user_id = ? AND feed_id = ?").get(userId, feedId) as Row | null; + const total = Number(countRow?.count || 0); + const exists = Boolean(database.query("SELECT 1 FROM materialized_metadata WHERE user_id = ? AND feed_id = ? LIMIT 1").get(userId, feedId)) || total > 0; + return { + items: rows.map((row) => parseJson>(row.json, {})), + total, + exists, + }; +} + +export function readFeedCacheMetadata(userId: string, feedId: string): FeedCacheMetadata | null { + const row = database.query("SELECT built_at, revision, item_count, source_revision FROM materialized_metadata WHERE user_id = ? AND feed_id = ?") + .get(userId, feedId) as Row | null; + return row ? { + builtAt: String(row.built_at || ""), + revision: String(row.revision || ""), + itemCount: Number(row.item_count) || 0, + sourceRevision: String(row.source_revision || ""), + } : null; +} + +export function writeMaterializedFeed(userId: string, feedId: string, items: MaterializedFeedItem[], sourceRevision = "") { + const revision = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const transaction = database.transaction(() => { + database.query("DELETE FROM materialized_items WHERE user_id = ? AND feed_id = ?").run(userId, feedId); + for (const item of items) { + database.query("INSERT INTO materialized_items (user_id, feed_id, item_id, score, json) VALUES (?, ?, ?, ?, ?)") + .run(userId, feedId, item.id, item.score || 0, JSON.stringify(item.json)); + } + database.query(` + INSERT INTO materialized_metadata (user_id, feed_id, built_at, revision, item_count, source_revision) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, feed_id) DO UPDATE SET + built_at = excluded.built_at, + revision = excluded.revision, + item_count = excluded.item_count, + source_revision = excluded.source_revision + `).run(userId, feedId, new Date().toISOString(), revision, items.length, sourceRevision); + }); + transaction(); +} + +export function deleteMaterializedFeed(userId: string, feedId: string) { + database.query("DELETE FROM materialized_items WHERE user_id = ? AND feed_id = ?").run(userId, feedId); + database.query("DELETE FROM materialized_metadata WHERE user_id = ? AND feed_id = ?").run(userId, feedId); +} + +export function readFeedItemsCache(feedId: string): unknown[] | null { + const articles = readSubscriptionArticles(feedId); + return articles.length ? articles.map((article) => article.json) : null; +} + +export function clearLocalFeedCache() { + database.exec(` + DELETE FROM articles; + DELETE FROM article_sources; + DELETE FROM subscription_articles; + DELETE FROM subscription_fetches; + DELETE FROM materialized_items; + DELETE FROM materialized_metadata; + `); +} diff --git a/apps/backend/src/lib/cache/feed-items.ts b/apps/backend/src/lib/cache/feed-items.ts index dee6be0d..b543b2b7 100644 --- a/apps/backend/src/lib/cache/feed-items.ts +++ b/apps/backend/src/lib/cache/feed-items.ts @@ -1,5 +1,6 @@ import { RedisClient } from "bun"; import { config } from "../config"; +import * as devCache from "./feed-items-dev"; export type CachedArticle = { dedupeKey: string; @@ -27,11 +28,6 @@ export type FeedCacheMetadata = { const redisUrl = Bun.env.REDIS_URL || Bun.env.VALKEY_URL || "redis://127.0.0.1:6379"; const client = config.USE_LOCAL_FEED_CACHE ? null : new RedisClient(redisUrl); -const localArticles = new Map(); -const localArticleSources = new Map>(); -const localSubscriptionIndexes = new Map>(); -const localViews = new Map; items: Map }>(); -const localMetadata = new Map(); const toStringValue = (value: unknown) => value == null ? "" : String(value); @@ -90,7 +86,7 @@ function subscriptionIndexEntries(value: unknown): string[] { } async function readArticle(dedupeKey: string): Promise { - if (config.USE_LOCAL_FEED_CACHE) return localArticles.get(dedupeKey) ?? null; + if (config.USE_LOCAL_FEED_CACHE) return devCache.readArticle(dedupeKey); const hash = parseRedisHash(await command("HGETALL", [articleDocumentKey(dedupeKey)])); if (!hash?.json) return null; @@ -105,27 +101,19 @@ async function readArticle(dedupeKey: string): Promise { } async function readArticleSources(dedupeKey: string): Promise { - if (config.USE_LOCAL_FEED_CACHE) return Array.from(localArticleSources.get(dedupeKey) ?? []); + if (config.USE_LOCAL_FEED_CACHE) return devCache.readArticleSources(dedupeKey); return subscriptionIndexEntries(await command("SMEMBERS", [articleSourcesKey(dedupeKey)])); } +export function hasSubscriptionArticles(subscriptionId: string) { + return config.USE_LOCAL_FEED_CACHE ? devCache.hasSubscriptionArticles(String(subscriptionId || "").trim()) : false; +} + export async function readSubscriptionArticles(subscriptionId: string): Promise { const id = String(subscriptionId || "").trim(); if (!id) return []; - if (config.USE_LOCAL_FEED_CACHE) { - const entries = Array.from(localSubscriptionIndexes.get(id)?.entries() ?? []) - .sort((left, right) => right[1] - left[1]); - const articles: CachedArticle[] = []; - for (const [dedupeKey] of entries) { - const article = localArticles.get(dedupeKey); - if (article) articles.push({ ...article, sourceIds: Array.from(localArticleSources.get(dedupeKey) ?? []) }); - } - return articles.map((article) => ({ - ...article, - json: { ...article.json, subscription_id: id }, - })); - } + if (config.USE_LOCAL_FEED_CACHE) return devCache.readSubscriptionArticles(id); const members = subscriptionIndexEntries(await command("ZREVRANGE", [subscriptionArticlesKey(id), "0", "-1"])); const loaded = await Promise.all(members.map(async (dedupeKey) => { @@ -147,26 +135,7 @@ export async function writeSubscriptionArticles(subscriptionId: string, articles const next = new Map(articles.filter((article) => article.dedupeKey).map((article) => [article.dedupeKey, article])); if (config.USE_LOCAL_FEED_CACHE) { - const current = localSubscriptionIndexes.get(id) ?? new Map(); - for (const dedupeKey of current.keys()) { - if (next.has(dedupeKey)) continue; - current.delete(dedupeKey); - const sources = localArticleSources.get(dedupeKey); - sources?.delete(id); - if (!sources?.size) { - localArticleSources.delete(dedupeKey); - localArticles.delete(dedupeKey); - } - } - for (const article of next.values()) { - const existing = localArticles.get(article.dedupeKey); - localArticles.set(article.dedupeKey, existing && JSON.stringify(existing.json).length > JSON.stringify(article.json).length ? existing : article); - current.set(article.dedupeKey, article.publishedAt); - const sources = localArticleSources.get(article.dedupeKey) ?? new Set(); - sources.add(id); - localArticleSources.set(article.dedupeKey, sources); - } - localSubscriptionIndexes.set(id, current); + devCache.writeSubscriptionArticles(id, articles); return; } @@ -200,16 +169,7 @@ export async function deleteSubscriptionArticleIndex(subscriptionId: string) { const id = String(subscriptionId || "").trim(); if (!id) return; if (config.USE_LOCAL_FEED_CACHE) { - const current = localSubscriptionIndexes.get(id) ?? new Map(); - for (const dedupeKey of current.keys()) { - const sources = localArticleSources.get(dedupeKey); - sources?.delete(id); - if (!sources?.size) { - localArticleSources.delete(dedupeKey); - localArticles.delete(dedupeKey); - } - } - localSubscriptionIndexes.delete(id); + devCache.deleteSubscriptionArticleIndex(id); return; } @@ -236,13 +196,7 @@ export async function readMaterializedFeedPage( const orderKey = materializedFeedOrderKey(userId, feedId); const itemsKey = materializedFeedItemsKey(userId, feedId); - if (config.USE_LOCAL_FEED_CACHE) { - const view = localViews.get(orderKey); - if (!view) return { items: [], total: 0, exists: false }; - const ids = Array.from(view.order.entries()).sort((left, right) => right[1] - left[1]).map(([id]) => id); - const page = ids.slice(offset, offset + limit).map((id) => parseJson>(view.items.get(id), {})); - return { items: page, total: ids.length, exists: true }; - } + if (config.USE_LOCAL_FEED_CACHE) return devCache.readMaterializedFeedPage(userId, feedId, offset, limit); const revisionBefore = toStringValue(await command("HGET", [materializedFeedMetaKey(userId, feedId), "revision"])); const total = Number(await command("ZCARD", [orderKey])) || 0; @@ -270,7 +224,7 @@ export async function readMaterializedFeedItems(userId: string, feedId: string) export async function readFeedCacheMetadata(userId: string, feedId: string): Promise { const key = materializedFeedMetaKey(userId, feedId); - if (config.USE_LOCAL_FEED_CACHE) return localMetadata.get(key) ?? null; + if (config.USE_LOCAL_FEED_CACHE) return devCache.readFeedCacheMetadata(userId, feedId); const hash = parseRedisHash(await command("HGETALL", [key])); if (!hash) return null; return { @@ -292,16 +246,7 @@ export async function writeMaterializedFeed( const revision = `${Date.now()}-${Math.random().toString(36).slice(2)}`; if (config.USE_LOCAL_FEED_CACHE) { - const order = new Map(); - const itemMap = new Map(); - for (const item of items) { - order.set(item.id, item.score); - itemMap.set(item.id, JSON.stringify(item.json)); - } - localViews.set(orderKey, { order, items: itemMap }); - localMetadata.set(materializedFeedMetaKey(userId, feedId), { - builtAt: new Date().toISOString(), revision, itemCount: items.length, sourceRevision, - }); + devCache.writeMaterializedFeed(userId, feedId, items, sourceRevision); return; } @@ -338,8 +283,7 @@ export async function deleteMaterializedFeed(userId: string, feedId: string) { const metaKey = materializedFeedMetaKey(userId, feedId); if (config.USE_LOCAL_FEED_CACHE) { - localViews.delete(orderKey); - localMetadata.delete(metaKey); + devCache.deleteMaterializedFeed(userId, feedId); return; } @@ -352,7 +296,7 @@ export async function readFeedItemsCache(feedId: string): Promise article.json); - if (config.USE_LOCAL_FEED_CACHE) return null; + if (config.USE_LOCAL_FEED_CACHE) return devCache.readFeedItemsCache(feedId); const raw = await command("HGET", [`feedItems:${feedId}`, "json"]); const parsed = parseJson(toStringValue(raw), null); return Array.isArray(parsed) ? parsed : null; @@ -364,9 +308,5 @@ export async function writeFeedItemsCache(feedId: string, items: unknown[], feed } export function clearLocalFeedCache() { - localArticles.clear(); - localArticleSources.clear(); - localSubscriptionIndexes.clear(); - localViews.clear(); - localMetadata.clear(); + if (config.USE_LOCAL_FEED_CACHE) devCache.clearLocalFeedCache(); } diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 10dbb914..06e91fb1 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -20,6 +20,12 @@ const normalizedNewsSubscriptionRetention = Number(env.NEWS_SUBSCRIPTION_RETENTI const resolvedNewsSubscriptionRetention = Number.isInteger(normalizedNewsSubscriptionRetention) && normalizedNewsSubscriptionRetention > 0 ? normalizedNewsSubscriptionRetention : 50; +const resolvedDeviceCodePending = Number.isInteger(Number(env.AUTH_DEVICE_CODE_MAX_PENDING)) && Number(env.AUTH_DEVICE_CODE_MAX_PENDING) > 0 + ? Number(env.AUTH_DEVICE_CODE_MAX_PENDING) : 100; +const resolvedDeviceCodeSockets = Number.isInteger(Number(env.AUTH_DEVICE_CODE_MAX_SOCKETS)) && Number(env.AUTH_DEVICE_CODE_MAX_SOCKETS) > 0 + ? Number(env.AUTH_DEVICE_CODE_MAX_SOCKETS) : 100; +const resolvedDeviceCodeTtl = Number.isInteger(Number(env.AUTH_DEVICE_CODE_TTL_MS)) && Number(env.AUTH_DEVICE_CODE_TTL_MS) >= 300_000 + ? Number(env.AUTH_DEVICE_CODE_TTL_MS) : 10 * 60 * 1000; const truthyEnv = (value?: string | null): boolean => { if (!value) return false; @@ -27,7 +33,8 @@ const truthyEnv = (value?: string | null): boolean => { }; const requestedLocalFeedCache = truthyEnv(env.USE_LOCAL_FEED_CACHE); -const useLocalFeedCache = requestedLocalFeedCache && processEnvironment === "dev"; +const devDisableNewsIndexing = processEnvironment === "dev" && truthyEnv(env.DEV_DISABLE_NEWS_INDEXING); +const useLocalFeedCache = (requestedLocalFeedCache || devDisableNewsIndexing) && processEnvironment === "dev"; if (requestedLocalFeedCache && !useLocalFeedCache) { console.warn("USE_LOCAL_FEED_CACHE is only supported in development and will be ignored"); @@ -49,6 +56,7 @@ const getLogLevel = (): string | undefined => { export const config = { ENVIRONMENT: processEnvironment === "dev" ? "dev" : "production", USE_LOCAL_FEED_CACHE: useLocalFeedCache, + DEV_DISABLE_NEWS_INDEXING: devDisableNewsIndexing, PORT: Number(env.PORT) || 3000, PB_URL: getEnv("PB_URL", "NEXT_PUBLIC_PB_URL") || "http://127.0.0.1:8090", PB_BINARY_PATH: env.PB_BINARY_PATH, @@ -66,6 +74,9 @@ export const config = { UPDATE_CHECK_SCHEDULE: env.UPDATE_CHECK_SCHEDULE || "0 2 * * *", FEED_BUILDING_SCHEDULE: env.FEED_BUILDING_SCHEDULE || "*/30 * * * *", NEWS_SUBSCRIPTION_RETENTION: resolvedNewsSubscriptionRetention, + AUTH_DEVICE_CODE_MAX_PENDING: resolvedDeviceCodePending, + AUTH_DEVICE_CODE_MAX_SOCKETS: resolvedDeviceCodeSockets, + AUTH_DEVICE_CODE_TTL_MS: resolvedDeviceCodeTtl, NOTIFICATION_FORWARDER_SCHEDULE: env.NOTIFICATION_FORWARDER_SCHEDULE || "* * * * *", DEFAULT_INTEGRATIONS_SCHEDULE: env.DEFAULT_INTEGRATIONS_SCHEDULE || "0 4 * * *", PAGECONFIG_CLEANUP_SCHEDULE: env.PAGECONFIG_CLEANUP_SCHEDULE || "0 5 * * *", diff --git a/apps/backend/src/lib/data/links.ts b/apps/backend/src/lib/data/links.ts index e9502fe8..9a6ccf33 100644 --- a/apps/backend/src/lib/data/links.ts +++ b/apps/backend/src/lib/data/links.ts @@ -45,11 +45,18 @@ export interface HomeLinkFolderPathItem { parentFolder?: string; } +export interface SecondaryUrl { + name: string; + url: string; + routingRule: string; +} + export interface LinkItem { collectionId: string; collectionName: "linkItems"; id: string; url: string; + secondaryUrls?: SecondaryUrl[]; title: string; iconUrl: string; description: string; @@ -185,6 +192,7 @@ export async function getLinksItems(userId: string, listId: string, folderId?: s return records.map((r) => ({ id: r.id, url: r.url, + secondaryUrls: normalizeSecondaryUrls((r as any).secondaryUrls), title: r.title, iconUrl: r.iconUrl, description: r.description, @@ -298,6 +306,15 @@ function buildFolderPathResolver( }; } +function normalizeSecondaryUrls(raw: unknown): SecondaryUrl[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry: any) => ({ + name: String(entry?.name ?? "").trim(), + url: String(entry?.url ?? "").trim(), + routingRule: String(entry?.routingRule ?? "").trim(), + })).filter((entry) => entry.url && entry.routingRule); +} + function getMonitorLinkId(monitor: { sourcelinkId?: string; linkId?: string }) { return String(monitor.sourcelinkId || monitor.linkId || "").trim(); } @@ -454,6 +471,7 @@ export async function getHomeLinks(userId: string) { return { id: r.id as string, url: r.url as string, + secondaryUrls: normalizeSecondaryUrls((r as any).secondaryUrls), title: r.title as string, iconUrl: r.iconUrl as string, description: r.description as string, @@ -608,11 +626,13 @@ export async function createHomeLinkItem( linkGroup?: string; folder?: string; statusCheck?: boolean; + secondaryUrls?: SecondaryUrl[]; }, ): Promise< { id: string; url: string; + secondaryUrls?: SecondaryUrl[]; title: string; iconUrl: string; description: string; @@ -663,6 +683,7 @@ export async function createHomeLinkItem( collection: homeCollection.id, folder: folderId ?? "", position: nextPosition, + secondaryUrls: normalizeSecondaryUrls(data.secondaryUrls), }); await syncHomeLinkMonitor(userId, record.id, record.url, data.statusCheck); @@ -670,6 +691,7 @@ export async function createHomeLinkItem( return { id: record.id, url: record.url, + secondaryUrls: normalizeSecondaryUrls((record as any).secondaryUrls), title: record.title, iconUrl: record.iconUrl, description: record.description, @@ -764,15 +786,18 @@ export async function updateHomeLinkItem( iconUrl?: string; description?: string; linkGroup?: string; + collection?: string; folder?: string; + tags?: string[]; statusCheck?: boolean; + secondaryUrls?: SecondaryUrl[]; }, ): Promise { const pb = getServerPB(); const item = await pb.collection("linkItems").getOne(linkId); const list = await pb.collection("linksLists").getOne(item.collection); - if (list.user !== userId || list.type !== "home") { + if (list.user !== userId) { throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); } @@ -781,8 +806,15 @@ export async function updateHomeLinkItem( if (data.title !== undefined) updateData.title = data.title; if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl; if (data.description !== undefined) updateData.description = data.description; + if (data.tags !== undefined) updateData.tags = data.tags; + if (list.type !== "home" && data.collection !== undefined && data.collection !== item.collection) { + const targetList = await pb.collection("linksLists").getOne(data.collection); + if (targetList.user !== userId) throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); + updateData.collection = data.collection; + } + if (data.secondaryUrls !== undefined) updateData.secondaryUrls = normalizeSecondaryUrls(data.secondaryUrls); - if (data.linkGroup !== undefined || data.folder !== undefined) { + if (list.type === "home" && (data.linkGroup !== undefined || data.folder !== undefined)) { const homeListId = await getHomeListId(userId); let folderId: string | undefined; const groupName = String(data.linkGroup || "").trim(); @@ -808,8 +840,12 @@ export async function updateHomeLinkItem( updateData.folder = folderId ?? ""; } + if (list.type !== "home" && data.folder !== undefined) updateData.folder = data.folder ?? ""; + await pb.collection("linkItems").update(linkId, updateData); - await syncHomeLinkMonitor(userId, linkId, String(updateData.url ?? item.url ?? ""), data.statusCheck); + if (list.type === "home") { + await syncHomeLinkMonitor(userId, linkId, String(updateData.url ?? item.url ?? ""), data.statusCheck); + } } export async function reorderLinks( @@ -954,9 +990,11 @@ export async function createLinkItem(data: { collection: string; folder?: string; tags?: string[]; + secondaryUrls?: SecondaryUrl[]; }): Promise<{ id: string; url: string; + secondaryUrls?: SecondaryUrl[]; title: string; iconUrl: string; description: string; @@ -975,11 +1013,13 @@ export async function createLinkItem(data: { collection: data.collection, folder: data.folder ?? "", tags: Array.isArray(data.tags) ? data.tags : [], + secondaryUrls: normalizeSecondaryUrls(data.secondaryUrls), }); return { id: record.id, url: record.url, + secondaryUrls: normalizeSecondaryUrls((record as any).secondaryUrls), title: record.title, iconUrl: record.iconUrl, description: record.description, @@ -1000,6 +1040,7 @@ export async function createCollectionLinkItem( collection: string; folder?: string; tags?: string[]; + secondaryUrls?: SecondaryUrl[]; }, ): Promise<{ id: string; diff --git a/apps/backend/src/lib/data/shortcuts.ts b/apps/backend/src/lib/data/shortcuts.ts index fcf67616..9d461ee7 100644 --- a/apps/backend/src/lib/data/shortcuts.ts +++ b/apps/backend/src/lib/data/shortcuts.ts @@ -35,6 +35,18 @@ export function escapeFilter(value: string) { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } +export function parseActions(value: unknown): Record { + if (typeof value === "string") { + try { value = JSON.parse(value); } catch { return {}; } + } + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([label, action]) => label.trim() && typeof action === "string" && action.trim()) + .map(([label, action]) => [label.trim(), String(action).trim()]), + ); +} + export function parseTags(value: unknown): string[] { if (Array.isArray(value)) { return value @@ -127,6 +139,7 @@ export async function getShortcuts(userId: string) { const actionString = typeof action === "string" ? action : ""; return { id: record.id, + sourceId: String((record as any).sourceId ?? ""), parentId: typeof record.app === "string" && record.app.trim().length > 0 ? record.app.trim() @@ -136,8 +149,10 @@ export async function getShortcuts(userId: string) { secondaryInfo: String(record.secondary ?? ""), type: actionString.startsWith("app:") ? "app" : "link", action, + actions: parseActions((record as any).secondaryActions), tags: parseTags(record.tags), isPinned: Boolean(record.isPinned), + isDisabled: Boolean((record as any).isDisabled), usageStats: record.usageStats, }; }); diff --git a/apps/backend/src/routes/integrations.route.ts b/apps/backend/src/routes/integrations.route.ts index 31b036d4..f95278c8 100644 --- a/apps/backend/src/routes/integrations.route.ts +++ b/apps/backend/src/routes/integrations.route.ts @@ -13,6 +13,7 @@ import { listIntegrations, testIntegrationEndpoint, updateIntegration, + buildResolvedEndpoints, } from "../lib/data/integrations"; import { ApiActionError } from "../lib/data/auth"; import { executeRoutedShortcut } from "../lib/data/shortcuts"; @@ -61,6 +62,9 @@ const previewJsonCache = new Map() const previewRedis = new RedisClient( Bun.env.REDIS_URL || Bun.env.VALKEY_URL || "redis://127.0.0.1:6379", ); +const integrationCacheRedis = new RedisClient( + Bun.env.REDIS_URL || Bun.env.VALKEY_URL || "redis://127.0.0.1:6379", +); const previewJsonMaxBytes = 1_048_576; const previewJsonTimeoutMs = 10_000; let previewRedisUnavailable = false; @@ -544,7 +548,8 @@ async function resolveWidgetConsumer( const mergedInput = mergeWidgetInput(resolvedEnv, opts.properties); const widgetJSON = applyWidgetInput(payload.widgetJSON, mergedInput ?? {}); - const cacheContext = createIntegrationCacheContext({ + const cacheContext = await createIntegrationCacheContext({ + integrationId: payload.integrationId, localData: envWithStatefulHiddenVars.localData, type: "widget", key: opts.key, @@ -639,7 +644,8 @@ async function resolveGlanceableConsumer( opts.properties, ); - const cacheContext = createIntegrationCacheContext({ + const cacheContext = await createIntegrationCacheContext({ + integrationId: payload.integrationId, localData: envWithStatefulHiddenVars.localData, type: "glanceable", key: opts.key, @@ -703,7 +709,7 @@ async function resolveFreshRuntime( { data: Record | null; env: Record } >, ctx: { - cacheContext: ReturnType; + cacheContext: Awaited>; isPreview: boolean; cacheConfig: IntegrationCacheConfig; }, @@ -742,7 +748,7 @@ async function resolveFreshRuntime( function buildCacheMeta( cacheConfig: IntegrationCacheConfig, - cacheContext: ReturnType, + cacheContext: Awaited>, fromCache = false, staleReturned = false, ) { @@ -1088,7 +1094,66 @@ function buildConsumerStateVars( return merged; } -function createIntegrationCacheContext(opts: { +function integrationCacheRedisKey(integrationId: string, namespace: string, recordKey: string) { + const encoded = Buffer.from(recordKey, "utf8").toString("base64url"); + return `integrations:cache:${integrationId}:${namespace}:${encoded}`; +} + +async function readIntegrationCacheRecords( + integrationId: string, + namespace: string, + recordKeys: string[], +): Promise> { + const entries = await Promise.all(recordKeys.map(async (recordKey) => { + try { + const raw = await integrationCacheRedis.send("GET", [ + integrationCacheRedisKey(integrationId, namespace, recordKey), + ]); + if (typeof raw !== "string") return null; + const parsed = JSON.parse(raw); + return isPlainObject(parsed) ? [recordKey, parsed as CacheRecord] as const : null; + } catch { + return null; + } + })); + return Object.fromEntries(entries.filter((entry): entry is readonly [string, CacheRecord] => entry !== null)); +} + +async function writeIntegrationCacheRecord( + integrationId: string, + namespace: string, + recordKey: string, + record: CacheRecord, + retentionSeconds: number | null, +) { + const invalidatesAt = Number(record.invalidatesAt); + const ttl = Number.isFinite(invalidatesAt) + ? Math.max(1, Math.ceil((invalidatesAt - Date.now()) / 1000)) + : Math.max(1, Number(retentionSeconds) || 1); + try { + await integrationCacheRedis.send("SET", [ + integrationCacheRedisKey(integrationId, namespace, recordKey), + JSON.stringify(record), + "EX", + String(ttl), + ]); + } catch { + // Redis is an optional cache; a failed write must not break integrations. + } +} + +async function deleteIntegrationCacheRecord(integrationId: string, namespace: string, recordKey: string) { + try { + await integrationCacheRedis.send("DEL", [ + integrationCacheRedisKey(integrationId, namespace, recordKey), + ]); + } catch { + // Redis is an optional cache. + } +} + +async function createIntegrationCacheContext(opts: { + integrationId: string; localData: unknown; type: ConsumerType; key: string; @@ -1098,12 +1163,13 @@ function createIntegrationCacheContext(opts: { initialChanged?: boolean; sharedEndpointCache?: Map; }) { - const root: Record = isPlainObject(opts.localData) - ? JSON.parse(JSON.stringify(opts.localData)) - : {}; - const cacheKV: Record = isPlainObject(root.cacheKV) - ? root.cacheKV - : (root.cacheKV = {}); + const sourceLocalData = isPlainObject(opts.localData) ? opts.localData : {}; + const hadPersistedCache = Object.prototype.hasOwnProperty.call(sourceLocalData, "cacheKV"); + const root: Record = JSON.parse(JSON.stringify(sourceLocalData)); + // cacheKV used to live in PocketBase localData. Keep it request-local and + // hydrate it from Redis so transient endpoint data is not persisted. + const cacheKV: Record = {}; + delete root.cacheKV; const statefulVars = buildConsumerStateVars(opts.integrationJSON, opts.input); const stateKey = Buffer.from( @@ -1121,7 +1187,11 @@ function createIntegrationCacheContext(opts: { const cacheNamespace = `${opts.type}:${opts.key}:${stateKey}`; const runtimeSnapshotKey = `${cacheNamespace}:runtime`; - let changed = Boolean(opts.initialChanged); + const endpointIds = buildResolvedEndpoints(opts.integrationJSON, {}).map((ep) => ep.id ?? ep.name ?? "").filter(Boolean); + const redisCacheKeys = [runtimeSnapshotKey, ...endpointIds.map((id) => `${cacheNamespace}:endpoint:${id}`)]; + const hydratedCache = await readIntegrationCacheRecords(opts.integrationId, cacheNamespace, redisCacheKeys); + Object.assign(cacheKV, hydratedCache); + const changed = Boolean(opts.initialChanged) || hadPersistedCache; const readRecord = (key: string): CacheRecord | null => { const record = cacheKV[key]; @@ -1129,7 +1199,7 @@ function createIntegrationCacheContext(opts: { const invalidatesAt = Number((record as CacheRecord).invalidatesAt); if (Number.isFinite(invalidatesAt) && invalidatesAt <= Date.now()) { delete cacheKV[key]; - changed = true; + void deleteIntegrationCacheRecord(opts.integrationId, cacheNamespace, key); return null; } return record as CacheRecord; @@ -1141,13 +1211,14 @@ function createIntegrationCacheContext(opts: { invalidatesAt: number | null, retentionSeconds: number | null, ) => { - cacheKV[key] = { + const record = { value, retentionSeconds, invalidatesAt, createdAt: Date.now(), } satisfies CacheRecord; - changed = true; + cacheKV[key] = record; + void writeIntegrationCacheRecord(opts.integrationId, cacheNamespace, key, record, retentionSeconds); }; const endpointKey = (id: string) => `${cacheNamespace}:endpoint:${id}`; diff --git a/apps/backend/src/routes/news.route.ts b/apps/backend/src/routes/news.route.ts index 36705e57..94679a2b 100644 --- a/apps/backend/src/routes/news.route.ts +++ b/apps/backend/src/routes/news.route.ts @@ -9,6 +9,7 @@ import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; import { createLogger } from "../lib/logger"; import { jobsApi } from "../jobs/index"; import { suggestCommonBlacklistWords } from "../lib/news/topic-suggestions"; +import { config } from "../lib/config"; const logger = createLogger("API"); @@ -17,6 +18,11 @@ const FEED_REQUEST_HEADERS = { "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8", }; +async function ensureDevNewsFeed(userId: string, feedId: string) { + if (!config.DEV_DISABLE_NEWS_INDEXING) return; + await jobsApi.runNewsFeedBuilderJob("dev local cache", feedId, userId); +} + async function refreshNewsFeed(userId: string, options: { feedIds: string[] }) { const { feedIds } = options; if (!feedIds.length) { @@ -220,6 +226,7 @@ newsRoute })) .get("/api/v1/news/feeds/:id", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); + await ensureDevNewsFeed(userId, String(c.req.param("id") ?? "")); const limit = Number(c.req.query("limit") ?? ""); const offset = Number(c.req.query("offset") ?? ""); return getNewsFeed(userId, c.req.param("id"), { @@ -229,6 +236,7 @@ newsRoute })) .get("/api/v1/news/feed", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); + await ensureDevNewsFeed(userId, c.req.query("feedId") ?? "all"); const limit = Number(c.req.query("limit") ?? ""); const offset = Number(c.req.query("offset") ?? ""); return getNewsFeed(userId, c.req.query("feedId") ?? "all", { diff --git a/apps/backend/src/routes/shortcuts.route.ts b/apps/backend/src/routes/shortcuts.route.ts index dbc511b9..512db166 100644 --- a/apps/backend/src/routes/shortcuts.route.ts +++ b/apps/backend/src/routes/shortcuts.route.ts @@ -35,6 +35,24 @@ shortcutsRoute return syncOnDemandShortcuts(userId, String(c.req.param("appId") ?? ""), body?.shortcuts); }), ) + .patch( + "/api/v1/shortcuts/:id", + withJson(async (c) => { + const body = await readJsonBody<{ isPinned?: unknown; isDisabled?: unknown; app?: unknown }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const pb = await getSuperuserPB(); + const record = await pb.collection("shortcuts").getOne(String(c.req.param("id") ?? "")); + if (String(record.user ?? "") !== userId) { + throw new ApiActionError("Unauthorized", 403, { error: "Unauthorized" }); + } + const updates: Record = {}; + if (typeof body?.isPinned === "boolean") updates.isPinned = body.isPinned; + if (typeof body?.isDisabled === "boolean") updates.isDisabled = body.isDisabled; + if (body?.app === null || typeof body?.app === "string") updates.app = body.app || null; + if (Object.keys(updates).length > 0) await pb.collection("shortcuts").update(record.id, updates); + return { success: true }; + }), + ) .post( "/api/v1/shortcuts/usageStats", withJson(async (c) => { diff --git a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx index af43d523..b3648b67 100644 --- a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useParams } from "react-router-dom"; -import { useMemo, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import { useEffect, useMemo, useState } from "react"; import useAuth from "@/context/useAuth"; import { getLinksCollectionsAction, getLinksFoldersAction, getLinksItemsAction, getLinksTagsAction } from '@/lib/apiClient'; import LinksDetailView, { type LinkFolderRecord, type LinkItemRecord, type LinkTagRecord } from "@/components/links/LinksDetailView"; @@ -20,6 +20,7 @@ type LinkCollection = { export default function LinksListDetailPage() { const { listId = "" } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { token } = useAuth(); const queryClient = useQueryClient(); const collectionsQuery = useApiQuery(queryKeys.links.collections, getLinksCollectionsAction); @@ -31,6 +32,23 @@ export default function LinksListDetailPage() { const items = (itemsQuery.data ?? []) as LinkItemRecord[]; const tags = (tagsQuery.data ?? []) as LinkTagRecord[]; const [createLinkOpen, setCreateLinkOpen] = useState(false); + const [linkToEdit, setLinkToEdit] = useState(null); + const [defaultFolderId, setDefaultFolderId] = useState(); + const [focusTags, setFocusTags] = useState(false); + + useEffect(() => { + if (searchParams.get("add") !== "1") return; + + setLinkToEdit(null); + setDefaultFolderId(undefined); + setFocusTags(false); + setCreateLinkOpen(true); + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.delete("add"); + return next; + }, { replace: true }); + }, [searchParams, setSearchParams]); const list = useMemo( () => collections.find((collection) => collection.id === listId) ?? null, @@ -65,7 +83,24 @@ export default function LinksListDetailPage() { folders={folders} items={items} tags={tags} - onAddLink={() => setCreateLinkOpen(true)} + onAddLink={(folderId) => { + setLinkToEdit(null); + setDefaultFolderId(folderId); + setFocusTags(false); + setCreateLinkOpen(true); + }} + onEditLink={(item) => { + setDefaultFolderId(undefined); + setLinkToEdit(item); + setFocusTags(false); + setCreateLinkOpen(true); + }} + onEditLinkTags={(item) => { + setDefaultFolderId(undefined); + setLinkToEdit(item); + setFocusTags(true); + setCreateLinkOpen(true); + }} onFolderCreated={(folder) => { queryClient.setQueryData(["api", token, ...queryKeys.links.folders(listId)], (current = []) => [folder, ...current.filter((existing) => existing.id !== folder.id)]); }} @@ -73,8 +108,18 @@ export default function LinksListDetailPage() { { + setCreateLinkOpen(open); + if (!open) { + setLinkToEdit(null); + setDefaultFolderId(undefined); + setFocusTags(false); + } + }} defaultCollectionId={listId} + defaultFolderId={defaultFolderId} + editItem={linkToEdit} + focusTags={focusTags} onCreated={(link) => { if (link.collection !== listId) return; diff --git a/apps/web/src/components/dashboard/Screensaver.tsx b/apps/web/src/components/dashboard/Screensaver.tsx index 90bf7bb5..73e5bbf2 100644 --- a/apps/web/src/components/dashboard/Screensaver.tsx +++ b/apps/web/src/components/dashboard/Screensaver.tsx @@ -284,7 +284,7 @@ export default function Screensaver( return (
void; defaultCollectionId?: string; + defaultFolderId?: string; defaultTagIds?: string[]; + editItem?: Partial & { id: string } | null; onCreated?: (item: CreatedLinkItem) => void; onFolderCreated?: (folder: FolderRecord) => void; + focusTags?: boolean; }; function IconPickerButton({ @@ -275,18 +282,31 @@ function TagMultiSelect({ selectedTagIds, onChange, disabled, + onCreateTag, + autoFocus, }: { tags: TagRecord[]; selectedTagIds: string[]; onChange: (ids: string[]) => void; + onCreateTag: (name: string) => void; disabled?: boolean; + autoFocus?: boolean; }) { const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const [search, setSearch] = useState(""); const selectedTags = useMemo( () => selectedTagIds.map((id) => tags.find((tag) => tag.id === id)).filter((tag): tag is TagRecord => Boolean(tag)), [selectedTagIds, tags], ); + useEffect(() => { + if (!autoFocus) return; + + const frame = requestAnimationFrame(() => triggerRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [autoFocus]); + const toggleTag = (tagId: string) => { onChange( selectedTagIds.includes(tagId) @@ -300,6 +320,7 @@ function TagMultiSelect({ + {tags.map((tag) => ( toggleTag(tag.id)}> @@ -352,9 +387,12 @@ export default function CreateLinksItemDialog({ open, onOpenChange, defaultCollectionId, +defaultFolderId, defaultTagIds = [], + editItem, onCreated, onFolderCreated, + focusTags = false, }: Props) { const { withAuth } = useAuth(); const [title, setTitle] = useState(""); @@ -380,6 +418,10 @@ export default function CreateLinksItemDialog({ const autoMetadataRef = useRef({ title: "", iconUrl: "", description: "" }); const defaultTagKey = defaultTagIds.join("|"); + const defaultTagIdsForEffect = useMemo( + () => defaultTagKey ? defaultTagKey.split("|") : [], + [defaultTagKey], + ); useEffect(() => { if (!open) { @@ -410,15 +452,20 @@ export default function CreateLinksItemDialog({ initializedTargetRef.current = false; metadataRequestRef.current += 1; autoMetadataRef.current = { title: "", iconUrl: "", description: "" }; - setTitle(""); - setUrl(""); - setIconUrl(""); - setDescription(""); + setTitle(editItem?.title ?? ""); + setUrl(editItem?.url ?? ""); + setIconUrl(editItem?.iconUrl ?? ""); + setDescription(editItem?.description ?? ""); + autoMetadataRef.current = editItem ? { + title: editItem.title ?? "", + iconUrl: editItem.iconUrl ?? "", + description: editItem.description ?? "", + } : { title: "", iconUrl: "", description: "" }; setCollections([]); setFolders([]); setTags([]); setSelectedTargetKey(""); - setSelectedTagIds(defaultTagIds); + setSelectedTagIds(editItem?.tags ?? defaultTagIdsForEffect); setActiveTab("link"); setFolderName(""); setFolderIconUrl(""); @@ -481,7 +528,7 @@ export default function CreateLinksItemDialog({ return () => { mounted = false; }; - }, [defaultTagKey, open, withAuth]); + }, [defaultTagIdsForEffect, defaultTagKey, editItem, open, withAuth]); useEffect(() => { if (!open) return; @@ -528,7 +575,6 @@ export default function CreateLinksItemDialog({ const targetGroups = useMemo(() => buildTargetGroups(collections, folders), [collections, folders]); const allTargetOptions = useMemo(() => targetGroups.flatMap((group) => group.options), [targetGroups]); - const tagsById = useMemo(() => new Map(tags.map((tag) => [tag.id, tag])), [tags]); const selectedTarget = useMemo( () => allTargetOptions.find((option) => option.key === selectedTargetKey) ?? null, [allTargetOptions, selectedTargetKey], @@ -537,39 +583,43 @@ export default function CreateLinksItemDialog({ useEffect(() => { if (!open || initializedTargetRef.current || allTargetOptions.length === 0) return; - const preferredTarget = defaultCollectionId - ? allTargetOptions.find((option) => option.collectionId === defaultCollectionId && !option.folderId) - ?? allTargetOptions.find((option) => option.collectionId === defaultCollectionId) - : allTargetOptions[0]; + const preferredTarget = editItem?.folder + ? allTargetOptions.find((option) => option.key === `folder:${editItem.folder}`) + : defaultFolderId + ? allTargetOptions.find((option) => option.key === `folder:${defaultFolderId}`) + : defaultCollectionId + ? allTargetOptions.find((option) => option.collectionId === defaultCollectionId && !option.folderId) + ?? allTargetOptions.find((option) => option.collectionId === defaultCollectionId) + : allTargetOptions[0]; if (preferredTarget) { setSelectedTargetKey(preferredTarget.key); initializedTargetRef.current = true; } - }, [allTargetOptions, defaultCollectionId, open]); + }, [allTargetOptions, defaultCollectionId, defaultFolderId, editItem, open]); const handleSave = async () => { if (!title.trim() || !url.trim() || !selectedTarget) return; - const saved = await withAuth((auth) => createLinkItemAction(auth, { + const payload = { title: title.trim(), url: url.trim(), iconUrl: iconUrl.trim() || undefined, description: description.trim() || undefined, collection: selectedTarget.collectionId, - folder: selectedTarget.folderId, + folder: selectedTarget.folderId ?? "", tags: selectedTagIds.length > 0 ? selectedTagIds : undefined, - })); - - const createdItem = { - ...(saved as CreatedLinkItem), - tags: selectedTagIds, }; + const saved = editItem?.id + ? await withAuth((auth) => updateHomeLinkItemAction(auth, editItem.id, payload)) + : await withAuth((auth) => createLinkItemAction(auth, payload)); + + const createdItem = { ...(editItem ?? {}), ...(saved as CreatedLinkItem), ...payload, tags: selectedTagIds } as CreatedLinkItem; setAlert({ open: true, - title: "Link created", - description: `Created link "${title.trim()}".`, + title: editItem ? "Link updated" : "Link created", + description: `${editItem ? "Updated" : "Created"} link "${title.trim()}"`, variant: "success", }); @@ -600,7 +650,7 @@ export default function CreateLinksItemDialog({ - Add to list + {editItem ? "Edit link" : "Add to list"} {alert.open && ( @@ -623,7 +673,7 @@ export default function CreateLinksItemDialog({ )} setActiveTab(value as "link" | "folder")} className="space-y-5"> - + {!editItem ? Add link @@ -632,7 +682,7 @@ export default function CreateLinksItemDialog({ Add folder - + : null}
{ + void withAuth((auth) => createLinksTagAction(auth, { name })).then((created) => { + const tag = created as TagRecord; + setTags((current) => [...current.filter((entry) => entry.id !== tag.id), tag]); + setSelectedTagIds((current) => [...current, tag.id]); + }); + }} + disabled={loadingData} />
diff --git a/apps/web/src/components/links/LinksDetailView.tsx b/apps/web/src/components/links/LinksDetailView.tsx index d11af2ac..135fd9d2 100644 --- a/apps/web/src/components/links/LinksDetailView.tsx +++ b/apps/web/src/components/links/LinksDetailView.tsx @@ -60,7 +60,9 @@ export interface LinksDetailViewProps { folders: LinkFolderRecord[]; items: LinkItemRecord[]; tags: LinkTagRecord[]; - onAddLink?: () => void; + onAddLink?: (folderId?: string) => void; + onEditLink?: (item: LinkItemRecord) => void; + onEditLinkTags?: (item: LinkItemRecord) => void; onFolderCreated?: (folder: LinkFolderRecord) => void; } @@ -79,9 +81,6 @@ function normalizeTagIds(value: unknown): string[] { .filter(Boolean); } -function hasAnyTag(value: unknown, tagId: string) { - return normalizeTagIds(value).includes(tagId); -} function formatClock(date: Date) { return date @@ -193,7 +192,7 @@ function TagBadges({ ); } -function LinkActions({ item, onShare }: { item: LinkItemRecord; onShare: () => void }) { +function LinkActions({ item, onShare, onEdit, onEditTags }: { item: LinkItemRecord; onShare: () => void; onEdit?: (item: LinkItemRecord) => void; onEditTags?: (item: LinkItemRecord) => void }) { const linkUrl = String(item.url ?? "").trim(); return ( @@ -225,6 +224,7 @@ function LinkActions({ item, onShare }: { item: LinkItemRecord; onShare: () => v className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/15 text-white/65 transition-colors hover:bg-white/10 hover:text-white" aria-label={`Edit tags for ${item.title}`} title="Tag" + onClick={() => onEditTags?.(item)} > @@ -234,6 +234,7 @@ function LinkActions({ item, onShare }: { item: LinkItemRecord; onShare: () => v className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/15 text-white/65 transition-colors hover:bg-white/10 hover:text-white" aria-label={`Edit ${item.title}`} title="Edit" + onClick={() => onEdit?.(item)} > @@ -275,11 +276,15 @@ function LinkRow({ tagsById, compact = false, onShare, + onEdit, + onEditTags, }: { item: LinkItemRecord; tagsById: Map; compact?: boolean; onShare: (item: LinkItemRecord) => void; + onEdit?: (item: LinkItemRecord) => void; + onEditTags?: (item: LinkItemRecord) => void; }) { const linkUrl = String(item.url ?? "").trim(); const titleElement = linkUrl ? ( @@ -331,7 +336,7 @@ function LinkRow({ ) : null} - onShare(item)} /> + onShare(item)} onEdit={onEdit} onEditTags={onEditTags} /> @@ -391,6 +396,8 @@ function FolderTreeNode({ collapsedFolders, setCollapsedFolders, onShare, + onEdit, + onEditTags, onCreateFolder, }: { node: FolderNode; @@ -398,6 +405,8 @@ function FolderTreeNode({ collapsedFolders: Record; setCollapsedFolders: Dispatch>>; onShare: (item: LinkItemRecord) => void; + onEdit?: (item: LinkItemRecord) => void; + onEditTags?: (item: LinkItemRecord) => void; onCreateFolder?: (parentFolderId: string) => void; }) { const isCollapsed = collapsedFolders[node.folder.id] ?? false; @@ -448,9 +457,9 @@ function FolderTreeNode({ @@ -467,6 +476,8 @@ function FolderTreeNode({ collapsedFolders={collapsedFolders} setCollapsedFolders={setCollapsedFolders} onShare={onShare} + onEdit={onEdit} + onEditTags={onEditTags} onCreateFolder={onCreateFolder} /> ))} @@ -478,6 +489,8 @@ function FolderTreeNode({ tagsById={tagsById} compact onShare={onShare} + onEdit={onEdit} + onEditTags={onEditTags} /> ))} @@ -494,6 +507,8 @@ export default function LinksDetailView({ items, tags, onAddLink, + onEditLink, + onEditLinkTags, onFolderCreated, }: LinksDetailViewProps) { const [sortField, setSortField] = useState("created"); @@ -610,28 +625,12 @@ export default function LinksDetailView({ -
-
-

Folders

- -
- - {roots.length === 0 && rootItems.length === 0 ? ( -
- No folders found. + {roots.length > 0 ? ( +
+
+

Folders

- ) : ( +
{roots.map((node) => ( setShareItem(item)} - onCreateFolder={(parentFolderId) => { - setCreateFolderParentId(parentFolderId); - setCreateFolderOpen(true); - }} + onEdit={onEditLink} + onEditTags={onEditLinkTags} + onCreateFolder={(parentFolderId) => onAddLink?.(parentFolderId)} /> ))}
- )} -
+
+ ) : null}
@@ -684,8 +682,15 @@ export default function LinksDetailView({
{sortedLinks.length === 0 ? ( -
- No links found. +
+ + + Add a link + {" "}to get started +
) : (
@@ -695,6 +700,8 @@ export default function LinksDetailView({ item={item} tagsById={tagsById} onShare={(nextItem) => setShareItem(nextItem)} + onEdit={onEditLink} + onEditTags={onEditLinkTags} /> ))}
@@ -704,7 +711,7 @@ export default function LinksDetailView({ {onAddLink ? ( + ) : ( + <> + +
+
+
+ + + + + + Route this link to another URL when opened from a matching origin or hostname. + +
+ +
+ +
+ {secondaryUrls.map((entry, index) => ( +
+
+ setSecondaryUrls((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, name: e.target.value } : item))} className="frosted" /> + +
+ setSecondaryUrls((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, url: e.target.value } : item))} className="frosted" /> + setSecondaryUrls((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, routingRule: e.target.value } : item))} className="frosted" /> +
+ ))} +
+
+ + + )} +
+