aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/settings/stats/page.tsx90
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json4
-rw-r--r--packages/open-api/karakeep-openapi-spec.json32
-rw-r--r--packages/shared/types/users.ts8
-rw-r--r--packages/trpc/models/users.ts13
5 files changed, 146 insertions, 1 deletions
diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx
index 599e5362..944d1c59 100644
--- a/apps/web/app/settings/stats/page.tsx
+++ b/apps/web/app/settings/stats/page.tsx
@@ -11,18 +11,30 @@ import {
Archive,
BarChart3,
BookOpen,
+ Chrome,
Clock,
+ Code,
Database,
FileText,
Globe,
Hash,
Heart,
+ HelpCircle,
Highlighter,
Image,
Link,
List,
+ Rss,
+ Smartphone,
TrendingUp,
+ Upload,
+ Zap,
} from "lucide-react";
+import { z } from "zod";
+
+import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks";
+
+type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>;
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
@@ -47,6 +59,45 @@ const hourLabels = Array.from({ length: 24 }, (_, i) =>
i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`,
);
+function formatSourceName(source: BookmarkSource | null): string {
+ if (!source) return "Unknown";
+ const sourceMap: Record<BookmarkSource, string> = {
+ api: "API",
+ web: "Web",
+ extension: "Browser Extension",
+ cli: "CLI",
+ mobile: "Mobile App",
+ singlefile: "SingleFile",
+ rss: "RSS Feed",
+ import: "Import",
+ };
+ return sourceMap[source];
+}
+
+function getSourceIcon(source: BookmarkSource | null): React.ReactNode {
+ const iconProps = { className: "h-4 w-4 text-muted-foreground" };
+ switch (source) {
+ case "api":
+ return <Zap {...iconProps} />;
+ case "web":
+ return <Globe {...iconProps} />;
+ case "extension":
+ return <Chrome {...iconProps} />;
+ case "cli":
+ return <Code {...iconProps} />;
+ case "mobile":
+ return <Smartphone {...iconProps} />;
+ case "singlefile":
+ return <FileText {...iconProps} />;
+ case "rss":
+ return <Rss {...iconProps} />;
+ case "import":
+ return <Upload {...iconProps} />;
+ default:
+ return <HelpCircle {...iconProps} />;
+ }
+}
+
function SimpleBarChart({
data,
maxValue,
@@ -439,6 +490,45 @@ export default function StatsPage() {
)}
</CardContent>
</Card>
+
+ {/* Bookmark Sources */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Zap className="h-5 w-5" />
+ {t("settings.stats.bookmark_sources.title")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {stats.bookmarksBySource.length > 0 ? (
+ <div className="space-y-3">
+ {stats.bookmarksBySource.map(
+ (source: {
+ source: BookmarkSource | null;
+ count: number;
+ }) => (
+ <div
+ key={source.source || "unknown"}
+ className="flex items-center justify-between"
+ >
+ <div className="flex items-center gap-2">
+ {getSourceIcon(source.source)}
+ <span className="max-w-[200px] truncate text-sm">
+ {formatSourceName(source.source)}
+ </span>
+ </div>
+ <Badge variant="secondary">{source.count}</Badge>
+ </div>
+ ),
+ )}
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ {t("settings.stats.bookmark_sources.empty")}
+ </p>
+ )}
+ </CardContent>
+ </Card>
</div>
{/* Activity Patterns */}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index f582cd9b..4fc58be7 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -181,6 +181,10 @@
},
"storage_breakdown": {
"title": "Storage Breakdown"
+ },
+ "bookmark_sources": {
+ "title": "Bookmark Sources",
+ "empty": "No source data available"
}
},
"ai": {
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index 2e791fbf..4f846cef 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -3397,6 +3397,35 @@
]
},
"maxItems": 10
+ },
+ "bookmarksBySource": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "nullable": true,
+ "enum": [
+ "api",
+ "web",
+ "cli",
+ "mobile",
+ "extension",
+ "singlefile",
+ "rss",
+ "import"
+ ]
+ },
+ "count": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "source",
+ "count"
+ ]
+ }
}
},
"required": [
@@ -3411,7 +3440,8 @@
"totalAssetSize",
"assetsByType",
"bookmarkingActivity",
- "tagUsage"
+ "tagUsage",
+ "bookmarksBySource"
]
}
}
diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts
index 758b757d..830fe87b 100644
--- a/packages/shared/types/users.ts
+++ b/packages/shared/types/users.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
+import { zBookmarkSourceSchema } from "./bookmarks";
+
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 100;
@@ -91,6 +93,12 @@ export const zUserStatsResponseSchema = z.object({
}),
)
.max(10),
+ bookmarksBySource: z.array(
+ z.object({
+ source: zBookmarkSourceSchema.nullable(),
+ count: z.number(),
+ }),
+ ),
});
export const zUserSettingsSchema = z.object({
diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts
index a327e7db..7e6be7a5 100644
--- a/packages/trpc/models/users.ts
+++ b/packages/trpc/models/users.ts
@@ -506,6 +506,7 @@ export class User implements PrivacyAware {
[{ thisYear }],
bookmarkTimestamps,
tagUsage,
+ bookmarksBySource,
] = await Promise.all([
// Basic counts
this.ctx.db
@@ -677,6 +678,17 @@ export class User implements PrivacyAware {
.groupBy(bookmarkTags.name)
.orderBy(desc(count()))
.limit(10),
+
+ // Bookmarks by source
+ this.ctx.db
+ .select({
+ source: bookmarks.source,
+ count: count(),
+ })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, this.user.id))
+ .groupBy(bookmarks.source)
+ .orderBy(desc(count())),
]);
// Process bookmarks by type
@@ -735,6 +747,7 @@ export class User implements PrivacyAware {
byDayOfWeek: dailyActivity,
},
tagUsage,
+ bookmarksBySource,
};
}