mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-16 19:57:52 +01:00
web: wrapped 2025
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> web: refined wrapped ui web: make wrapped work automatically for future years web: fix emoji for colors web: format word count
This commit is contained in:
1446
apps/web/package-lock.json
generated
1446
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"hash-wasm": "4.12.0",
|
||||
"hotkeys-js": "^3.8.3",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"katex": "0.16.11",
|
||||
"mac-scrollbar": "0.13.6",
|
||||
"mutative": "^1.1.0",
|
||||
|
||||
@@ -48,6 +48,7 @@ import { getFontSizes } from "@notesnook/theme/theme/font/fontsize.js";
|
||||
import { useWindowControls } from "./hooks/use-window-controls";
|
||||
import { STATUS_BAR_HEIGHT } from "./common/constants";
|
||||
import { NavigationEvents } from "./navigation";
|
||||
import { db } from "./common/db";
|
||||
|
||||
new WebExtensionRelay();
|
||||
|
||||
@@ -143,6 +144,10 @@ function DesktopAppContents() {
|
||||
const isTablet = useTablet();
|
||||
const navPane = useRef<SplitPaneImperativeHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTablet) navPane.current?.collapse(0);
|
||||
else if (navPane.current?.isCollapsed(0)) navPane.current?.expand(0);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
isFeatureSupported
|
||||
} from "./utils/feature-check";
|
||||
import { initializeLogger } from "./utils/logger";
|
||||
import { shouldShowWrapped } from "./utils/should-show-wrapped";
|
||||
|
||||
type Route<TProps = null> = {
|
||||
component: () => Promise<{
|
||||
@@ -55,6 +56,9 @@ const routes = {
|
||||
"/plans": {
|
||||
component: () => import("./views/plans")
|
||||
},
|
||||
"/wrapped": {
|
||||
component: () => import("./views/wrapped")
|
||||
},
|
||||
"/checkout": {
|
||||
component: () => import("./views/checkout")
|
||||
},
|
||||
@@ -122,6 +126,12 @@ function getRoute(): RouteWithPath<AuthProps> | RouteWithPath {
|
||||
routes[path] ? { route: routes[path], path } : null
|
||||
) as RouteWithPath<AuthProps> | null;
|
||||
|
||||
if (route?.path === "/wrapped" && !shouldShowWrapped())
|
||||
return {
|
||||
route: routes.default,
|
||||
path: "default"
|
||||
};
|
||||
|
||||
return signup || sessionExpired || route || fallback;
|
||||
}
|
||||
|
||||
|
||||
143
apps/web/src/components/monthly-activity-heatmap/index.tsx
Normal file
143
apps/web/src/components/monthly-activity-heatmap/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Flex, Text } from "@theme-ui/components";
|
||||
import { SxProp } from "@theme-ui/core";
|
||||
|
||||
type MonthlyActivityHeatmapProps = {
|
||||
monthlyStats: Record<string, number>;
|
||||
year?: number;
|
||||
} & SxProp;
|
||||
|
||||
const MONTHS = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
];
|
||||
|
||||
const MONTHS_SHORT = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
];
|
||||
|
||||
export function MonthlyActivityHeatmap({
|
||||
monthlyStats
|
||||
}: MonthlyActivityHeatmapProps) {
|
||||
// Find max count for bar height calculation
|
||||
const maxCount = Math.max(...Object.values(monthlyStats), 1);
|
||||
|
||||
// Create month data with counts
|
||||
const monthData = MONTHS.map((month) => ({
|
||||
month,
|
||||
count: monthlyStats[month] || 0
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bar chart */}
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
pb: 2
|
||||
}}
|
||||
>
|
||||
{monthData.map((data, index) => {
|
||||
const barHeight = maxCount > 0 ? (data.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={data.month}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
cursor: "pointer",
|
||||
"&:hover .tooltip": { opacity: 1 },
|
||||
"&:hover .bar": { bg: "accent" },
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
className="tooltip"
|
||||
sx={{ opacity: 0, bg: "background", zIndex: 1000 }}
|
||||
>
|
||||
<Text variant="body" sx={{ fontWeight: "bold" }}>
|
||||
{data.count}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Box
|
||||
className="bar"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: `${barHeight}%`,
|
||||
minHeight: data.count > 0 ? "10px" : "5px",
|
||||
bg: maxCount === data.count ? "accent" : "paragraph",
|
||||
borderRadius: "4px 4px 0 0",
|
||||
transition: "all 0.3s ease",
|
||||
position: "relative",
|
||||
"&:hover": {
|
||||
transform: "scaleY(1.05)",
|
||||
transformOrigin: "bottom"
|
||||
}
|
||||
}}
|
||||
></Box>
|
||||
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "11px",
|
||||
color: "paragraph-secondary",
|
||||
fontWeight: "normal",
|
||||
transition: "all 0.3s ease"
|
||||
}}
|
||||
>
|
||||
{MONTHS_SHORT[index]}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +97,6 @@ import { strings } from "@notesnook/intl";
|
||||
import Tags from "../../views/tags";
|
||||
import { Notebooks } from "../../views/notebooks";
|
||||
import { UserProfile } from "../../dialogs/settings/components/user-profile";
|
||||
import { SUBSCRIPTION_STATUS } from "../../common/constants";
|
||||
import {
|
||||
checkFeature,
|
||||
createSetDefaultHomepageMenuItem,
|
||||
@@ -105,7 +104,6 @@ import {
|
||||
withFeatureCheck
|
||||
} from "../../common";
|
||||
import { TabItem } from "./tab-item";
|
||||
import Notice from "../notice";
|
||||
import { Freeze } from "react-freeze";
|
||||
import { CREATE_BUTTON_MAP } from "../../common";
|
||||
import { useStore as useNotebookStore } from "../../stores/notebook-store";
|
||||
@@ -118,6 +116,7 @@ import {
|
||||
useIsFeatureAvailable
|
||||
} from "@notesnook/common";
|
||||
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
|
||||
import { shouldShowWrapped } from "../../utils/should-show-wrapped";
|
||||
|
||||
type Route = {
|
||||
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
|
||||
@@ -449,7 +448,17 @@ function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
|
||||
</FlexScrollContainer>
|
||||
</Freeze>
|
||||
</Flex>
|
||||
{currentTab.id === "home" && !isCollapsed ? <Notice /> : null}
|
||||
{currentTab.id === "home" && !isCollapsed && shouldShowWrapped() ? (
|
||||
<Button
|
||||
variant="accent"
|
||||
sx={{ m: 2 }}
|
||||
onClick={() => {
|
||||
hardNavigate("/wrapped");
|
||||
}}
|
||||
>
|
||||
🎉 Wrapped {new Date().getFullYear()}
|
||||
</Button>
|
||||
) : null}
|
||||
</ScopedThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
23
apps/web/src/utils/should-show-wrapped.ts
Normal file
23
apps/web/src/utils/should-show-wrapped.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const shouldShowWrapped = () => {
|
||||
const now = new Date();
|
||||
return now.getMonth() === 11;
|
||||
};
|
||||
776
apps/web/src/views/wrapped.tsx
Normal file
776
apps/web/src/views/wrapped.tsx
Normal file
@@ -0,0 +1,776 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Flex, Text, Box, FlexProps } from "@theme-ui/components";
|
||||
import { db } from "../common/db";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { NoteStats, WrappedStats } from "@notesnook/core";
|
||||
import { formatBytes } from "@notesnook/common";
|
||||
import { ArrowDown, ArrowLeft, Loading } from "../components/icons";
|
||||
import { hardNavigate } from "../navigation";
|
||||
import { MonthlyActivityHeatmap } from "../components/monthly-activity-heatmap";
|
||||
|
||||
function formatNumber(num: number) {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
function formatCount(num: number) {
|
||||
return num > 1000 ? `${(num / 1000).toFixed(0)}k` : num.toString();
|
||||
}
|
||||
|
||||
interface SlideProps {
|
||||
children: React.ReactNode;
|
||||
pattern?: "dots" | "grid" | "diagonal" | "none";
|
||||
}
|
||||
|
||||
function Slide({
|
||||
children,
|
||||
pattern = "none",
|
||||
sx,
|
||||
...flexProps
|
||||
}: SlideProps & FlexProps) {
|
||||
const slideRef = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (slideRef.current) {
|
||||
observer.observe(slideRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (slideRef.current) {
|
||||
observer.unobserve(slideRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getBackgroundPattern = () => {
|
||||
switch (pattern) {
|
||||
case "dots":
|
||||
return "radial-gradient(circle, var(--border) 1px, transparent 1px)";
|
||||
case "grid":
|
||||
return "linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)";
|
||||
case "diagonal":
|
||||
return "repeating-linear-gradient(-45deg, transparent, transparent 20px, var(--border) 20px, var(--border) 21px)";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
};
|
||||
|
||||
const getBackgroundSize = () => {
|
||||
switch (pattern) {
|
||||
case "dots":
|
||||
return "20px 20px";
|
||||
case "grid":
|
||||
return "30px 30px";
|
||||
case "diagonal":
|
||||
default:
|
||||
return "auto";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={slideRef}
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
minWidth: "100%",
|
||||
scrollSnapAlign: "start",
|
||||
scrollSnapStop: "always",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
p: 4,
|
||||
position: "relative",
|
||||
backgroundImage: getBackgroundPattern(),
|
||||
backgroundSize: getBackgroundSize(),
|
||||
backgroundPosition: "center"
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
{...flexProps}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(50px)",
|
||||
transition: "opacity 0.8s ease-out, transform 0.8s ease-out",
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeSlide({ loading }: { loading: boolean }) {
|
||||
return (
|
||||
<Slide pattern="dots">
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "6rem",
|
||||
fontWeight: "bold",
|
||||
mb: 3,
|
||||
textAlign: "center",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
animation: "slideUp 1s ease-out"
|
||||
}}
|
||||
>
|
||||
🎉
|
||||
</Text>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: ["2rem", "3rem", "4rem"],
|
||||
fontWeight: "bold",
|
||||
mb: 2,
|
||||
textAlign: "center",
|
||||
animation: "slideUp 1s ease-out 0.2s both"
|
||||
}}
|
||||
>
|
||||
Your {new Date().getFullYear()} Wrapped
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<Loading
|
||||
sx={{
|
||||
animation: "fadeIn 1s ease-out 0.4s both"
|
||||
}}
|
||||
size={30}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: ["1rem", "1.2rem"],
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 1s ease-out 0.4s both"
|
||||
}}
|
||||
>
|
||||
Let's look back at your year in Notesnook
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
mt: 4,
|
||||
fontSize: "title",
|
||||
color: "paragraph-secondary",
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 1s ease-out 0.4s both"
|
||||
}}
|
||||
>
|
||||
Scroll down to explore
|
||||
</Text>
|
||||
<ArrowDown sx={{ mt: 2 }} color="paragraph-secondary" />
|
||||
</>
|
||||
)}
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalNotesSlide({ count }: { count: number }) {
|
||||
return (
|
||||
<Slide pattern="dots">
|
||||
<Flex>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.5rem"],
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 0.8s ease-out"
|
||||
}}
|
||||
>
|
||||
You created
|
||||
</Text>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: ["5rem", "7rem", "8rem"],
|
||||
textAlign: "center",
|
||||
color: "accent",
|
||||
animation: "scaleIn 0.8s ease-out 0.2s both"
|
||||
}}
|
||||
>
|
||||
{formatNumber(count)}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.5rem"],
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 0.8s ease-out 0.4s both"
|
||||
}}
|
||||
>
|
||||
notes this year
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
ml: [0, 5],
|
||||
mt: [4, 0],
|
||||
borderLeft: ["none", "1px solid var(--border)"],
|
||||
pl: [0, 5],
|
||||
borderTop: ["1px solid var(--border)", "none"]
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color="paragraph-secondary"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.2rem"],
|
||||
animation: "fadeIn 0.8s ease-out",
|
||||
lineHeight: 1.8
|
||||
}}
|
||||
>
|
||||
That's <strong>{formatNumber(count)}</strong>
|
||||
<br />
|
||||
ideas
|
||||
<br />
|
||||
thoughts
|
||||
<br />
|
||||
memories.
|
||||
<br />
|
||||
100% encrypted.
|
||||
<br />
|
||||
100% yours.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
|
||||
const TOTAL_WORDS_TAGLINES = {
|
||||
1000: "That's longer than the average blog post on Medium!",
|
||||
10000: "That's real commitment to your mindspace.",
|
||||
25000: "That's almost the length of a short novel!",
|
||||
50000:
|
||||
"That's the length of The Great Gatsby if you were F. Scott Fitzgerald.",
|
||||
100000: "You wrote more than many published authors this year.",
|
||||
250000: "Woah! Your notes could fill a small library.",
|
||||
500000: "Your vault is becoming a chronicle.",
|
||||
1000000: "That's longer than the entire Harry Potter series!"
|
||||
};
|
||||
function TotalWordsSlide({ count }: { count: number }) {
|
||||
const tagline = Object.entries(TOTAL_WORDS_TAGLINES)
|
||||
.sort((a, b) => Number(b[0]) - Number(a[0]))
|
||||
.find(([threshold]) => count >= Number(threshold))?.[1];
|
||||
return (
|
||||
<Slide pattern="dots">
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.5rem"],
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 0.8s ease-out"
|
||||
}}
|
||||
>
|
||||
You wrote a total of
|
||||
</Text>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: ["5rem", "7rem"],
|
||||
textAlign: "center",
|
||||
color: "accent",
|
||||
animation: "scaleIn 0.8s ease-out 0.2s both"
|
||||
}}
|
||||
>
|
||||
{formatNumber(count)}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.5rem"],
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 0.8s ease-out 0.4s both"
|
||||
}}
|
||||
>
|
||||
words this year
|
||||
</Text>
|
||||
{tagline ? (
|
||||
<Text
|
||||
variant="body"
|
||||
color="paragraph-secondary"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.2rem"],
|
||||
animation: "fadeIn 0.8s ease-out",
|
||||
borderTop: "1px solid var(--border)",
|
||||
mt: 3,
|
||||
pt: 3
|
||||
}}
|
||||
>
|
||||
{tagline}
|
||||
</Text>
|
||||
) : null}
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
|
||||
type ActivityStatsSlideProps = {
|
||||
mostNotesCreatedInMonth: NoteStats["mostNotesCreatedInMonth"];
|
||||
mostNotesCreatedInDay: NoteStats["mostNotesCreatedInDay"];
|
||||
};
|
||||
|
||||
function ActivityStatsSlide({
|
||||
mostNotesCreatedInMonth,
|
||||
mostNotesCreatedInDay
|
||||
}: ActivityStatsSlideProps) {
|
||||
if (!mostNotesCreatedInMonth && !mostNotesCreatedInDay) return null;
|
||||
|
||||
return (
|
||||
<Slide pattern="diagonal" sx={{ alignItems: "start" }}>
|
||||
{mostNotesCreatedInMonth && (
|
||||
<>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.5rem"]
|
||||
}}
|
||||
>
|
||||
Your most productive month was
|
||||
</Text>
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: ["5rem", "7rem", "3rem"]
|
||||
}}
|
||||
>
|
||||
{mostNotesCreatedInMonth.month}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{ fontSize: "1rem" }}
|
||||
color="paragraph-secondary"
|
||||
>
|
||||
{formatNumber(mostNotesCreatedInMonth.count)} notes
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
{mostNotesCreatedInDay && (
|
||||
<>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: ["1.2rem", "1.5rem"],
|
||||
mt: 5
|
||||
}}
|
||||
>
|
||||
Your favorite day to write was
|
||||
</Text>
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: ["5rem", "7rem", "3rem"]
|
||||
}}
|
||||
>
|
||||
{mostNotesCreatedInDay.day}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{ fontSize: "1rem" }}
|
||||
color="paragraph-secondary"
|
||||
>
|
||||
{formatNumber(mostNotesCreatedInDay.count)} notes
|
||||
</Text>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySlide({ stats }: { stats: WrappedStats }) {
|
||||
return (
|
||||
<Slide
|
||||
pattern="dots"
|
||||
sx={{
|
||||
bg: "background-secondary",
|
||||
border: "1px solid var(--accent)",
|
||||
borderRadius: "dialog",
|
||||
boxShadow: "-15px 15px 0px 0px var(--accent)",
|
||||
p: 5
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{
|
||||
height: 20,
|
||||
width: 20,
|
||||
position: "absolute",
|
||||
bottom: "20px",
|
||||
right: "20px"
|
||||
}}
|
||||
>
|
||||
<use href="#themed-logo" />
|
||||
</svg>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: "1.2rem",
|
||||
fontWeight: 800,
|
||||
textAlign: "center",
|
||||
transform: "skew(-10deg) scaleX(1.5)",
|
||||
letterSpacing: "-1px",
|
||||
mb: "25px",
|
||||
textDecorationLine: "underline",
|
||||
textDecorationColor: "border"
|
||||
}}
|
||||
>
|
||||
NOTESNOOK WRAPPED {new Date().getFullYear()}
|
||||
</Text>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: ["column", "row"],
|
||||
gap: "30px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gridTemplateRows: "1fr 1fr",
|
||||
gap: 4,
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
icon: "📝",
|
||||
count: stats.totalNotes,
|
||||
label: "Notes"
|
||||
},
|
||||
{
|
||||
icon: "🎨",
|
||||
count: stats.totalColors,
|
||||
label: "Colors"
|
||||
},
|
||||
{
|
||||
icon: "📚",
|
||||
count: stats.totalNotebooks,
|
||||
label: "Notebooks"
|
||||
},
|
||||
{
|
||||
icon: "🏷️",
|
||||
count: stats.totalTags,
|
||||
label: "Tags"
|
||||
},
|
||||
{
|
||||
icon: "📂",
|
||||
count: stats.totalAttachments,
|
||||
label: "Files"
|
||||
},
|
||||
{
|
||||
icon: "☁️",
|
||||
count: stats.totalMonographs,
|
||||
label: "Monographs"
|
||||
}
|
||||
].map(({ icon, count, label }) => (
|
||||
<Flex
|
||||
key={label}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Text sx={{ fontSize: "1.5rem" }}>{icon}</Text>
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "2rem",
|
||||
fontWeight: "bold"
|
||||
}}
|
||||
>
|
||||
{formatCount(count)}
|
||||
</Text>
|
||||
<Text sx={{ fontSize: "0.85rem", color: "fontTertiary" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
borderTop: "1px solid var(--border)",
|
||||
mt: 6,
|
||||
pt: 3,
|
||||
"& strong": {
|
||||
color: "accent"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text sx={{ fontSize: "1rem", fontWeight: "bold" }}>
|
||||
Fun facts of the year
|
||||
</Text>
|
||||
{stats.mostNotesCreatedInMonth && (
|
||||
<Text sx={{ fontSize: "0.9rem", color: "fontTertiary" }}>
|
||||
📅 Your most productive month was{" "}
|
||||
<Text as="strong">{stats.mostNotesCreatedInMonth.month}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{stats.mostNotesCreatedInDay && (
|
||||
<Text sx={{ fontSize: "0.9rem", color: "fontTertiary" }}>
|
||||
🗓️ Your favorite day to write was{" "}
|
||||
<Text as="strong">{stats.mostNotesCreatedInDay.day}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{stats.largestNote && (
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "0.9rem",
|
||||
color: "fontTertiary",
|
||||
maxWidth: 340
|
||||
}}
|
||||
>
|
||||
📝 Your longest note was{" "}
|
||||
<Text as="strong">
|
||||
{formatNumber(stats.largestNote.length)}
|
||||
{" words"}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{stats.largestAttachment && (
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "0.9rem",
|
||||
color: "fontTertiary",
|
||||
maxWidth: 340
|
||||
}}
|
||||
>
|
||||
🔗 Your largest attachment was{" "}
|
||||
<strong>{formatBytes(stats.largestAttachment.size)}</strong>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
justifyContent: "stretch",
|
||||
height: "100%",
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Text sx={{ fontSize: "2.5rem" }}>✍️</Text>
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "3rem",
|
||||
fontWeight: "bold",
|
||||
color: "accent"
|
||||
}}
|
||||
>
|
||||
{formatNumber(stats.totalWords)}
|
||||
</Text>
|
||||
<Text sx={{ fontSize: "1rem", color: "fontTertiary" }}>
|
||||
Words Written
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
borderTop: "1px solid var(--border)",
|
||||
mt: 6,
|
||||
pt: 2,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<MonthlyActivityHeatmap monthlyStats={stats.monthlyStats} />
|
||||
<Text
|
||||
sx={{
|
||||
fontSize: "1rem",
|
||||
color: "fontTertiary",
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
Notes per month
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Text
|
||||
variant="body"
|
||||
color="paragraph-secondary"
|
||||
sx={{ mt: 2, borderTop: "1px solid var(--border)", pt: 2 }}
|
||||
>
|
||||
Generated 100% locally on your device.
|
||||
</Text>
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Wrapped() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<WrappedStats>();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadWrapped() {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.time("wrapped - getting from core");
|
||||
const wrapped = await db.wrapped.get();
|
||||
console.timeEnd("wrapped - getting from core");
|
||||
setStats(wrapped);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadWrapped();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
`}
|
||||
</style>
|
||||
<Flex
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: "column",
|
||||
overflowY: "scroll",
|
||||
overflowX: "hidden",
|
||||
scrollSnapType: "y mandatory",
|
||||
scrollBehavior: "smooth",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none"
|
||||
},
|
||||
msOverflowStyle: "none",
|
||||
scrollbarWidth: "none"
|
||||
}}
|
||||
>
|
||||
<WelcomeSlide loading={loading} />
|
||||
{stats ? (
|
||||
<>
|
||||
<TotalNotesSlide count={stats.totalNotes} />
|
||||
{stats.totalWords > 0 && (
|
||||
<TotalWordsSlide count={stats.totalWords} />
|
||||
)}
|
||||
{(stats.mostNotesCreatedInMonth || stats.mostNotesCreatedInDay) && (
|
||||
<ActivityStatsSlide
|
||||
mostNotesCreatedInMonth={stats.mostNotesCreatedInMonth}
|
||||
mostNotesCreatedInDay={stats.mostNotesCreatedInDay}
|
||||
/>
|
||||
)}
|
||||
<SummarySlide stats={stats} />
|
||||
</>
|
||||
) : null}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
onClick={() => hardNavigate("/")}
|
||||
variant="secondary"
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: 3,
|
||||
left: 3,
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ alignItems: "center", gap: 1, justifyContent: "center" }}>
|
||||
<ArrowLeft size={16} />
|
||||
<Text variant="body">Go back to app</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
packages/core/package-lock.json
generated
7
packages/core/package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@streetwriters/kysely": "^0.27.4",
|
||||
"@streetwriters/showdown": "^3.0.9-alpha",
|
||||
"@types/mime-db": "^1.43.5",
|
||||
"alfaaz": "^1.1.0",
|
||||
"async-mutex": "0.5.0",
|
||||
"dayjs": "1.11.13",
|
||||
"dom-serializer": "^2.0.0",
|
||||
@@ -1391,6 +1392,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alfaaz": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/alfaaz/-/alfaaz-1.1.0.tgz",
|
||||
"integrity": "sha512-J/P07R41APslK7NmD5303bwStN8jpRA4DdvtLeAr1Jhfj6XWGrASUWI0G6jbWjJAZyw3Lu1Pb4J8rsM/cb+xDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@streetwriters/kysely": "^0.27.4",
|
||||
"@streetwriters/showdown": "^3.0.9-alpha",
|
||||
"@types/mime-db": "^1.43.5",
|
||||
"alfaaz": "^1.1.0",
|
||||
"async-mutex": "0.5.0",
|
||||
"dayjs": "1.11.13",
|
||||
"dom-serializer": "^2.0.0",
|
||||
|
||||
@@ -83,6 +83,7 @@ import { ConfigStorage } from "../database/config.js";
|
||||
import { LazyPromise } from "../utils/lazy-promise.js";
|
||||
import { InboxApiKeys } from "./inbox-api-keys.js";
|
||||
import { Circle } from "./circle.js";
|
||||
import { Wrapped } from "./wrapped.js";
|
||||
|
||||
type EventSourceConstructor = new (
|
||||
uri: string,
|
||||
@@ -224,6 +225,8 @@ class Database {
|
||||
|
||||
inboxApiKeys = new InboxApiKeys(this, this.tokenManager);
|
||||
|
||||
wrapped = new Wrapped(this);
|
||||
|
||||
/**
|
||||
* @deprecated only kept here for migration purposes
|
||||
*/
|
||||
|
||||
342
packages/core/src/api/wrapped.ts
Normal file
342
packages/core/src/api/wrapped.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Parser } from "htmlparser2";
|
||||
import Database from "./index.js";
|
||||
import { countWords } from "alfaaz";
|
||||
import { DatabaseSchema, isFalse } from "../database/index.js";
|
||||
import { SelectQueryBuilder } from "@streetwriters/kysely";
|
||||
|
||||
const dayNames = [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
];
|
||||
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
];
|
||||
|
||||
export type NoteStats = {
|
||||
totalNotes: number;
|
||||
totalWords: number;
|
||||
totalMonographs: number;
|
||||
mostNotesCreatedInMonth: { month: string; count: number } | null;
|
||||
mostNotesCreatedInDay: { day: string; count: number } | null;
|
||||
monthlyStats: Record<string, number>;
|
||||
dayOfWeekStats: Record<string, number>;
|
||||
largestNote: { title: string; length: number } | null;
|
||||
};
|
||||
|
||||
export type OrganizationStats = {
|
||||
totalNotebooks: number;
|
||||
totalTags: number;
|
||||
mostUsedTags: { id: string; title: string; noteCount: number }[];
|
||||
mostActiveNotebooks: { id: string; title: string; noteCount: number }[];
|
||||
totalColors: number;
|
||||
};
|
||||
|
||||
export type AttachmentStats = {
|
||||
totalAttachments: number;
|
||||
totalStorageUsed: number;
|
||||
largestAttachment: { id: string; filename: string; size: number } | null;
|
||||
mostCommonFileType: string | null;
|
||||
};
|
||||
|
||||
export type WrappedStats = NoteStats & OrganizationStats & AttachmentStats;
|
||||
|
||||
export class Wrapped {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async get(): Promise<WrappedStats> {
|
||||
const { startDate, endDate } = this.getYearRange();
|
||||
|
||||
const [noteStats, organizationStats, attachmentStats] = await Promise.all([
|
||||
this.getNoteStats(startDate, endDate),
|
||||
this.getOrganizationStats(startDate, endDate),
|
||||
this.getAttachmentStats(startDate, endDate)
|
||||
]);
|
||||
|
||||
return {
|
||||
...noteStats,
|
||||
...organizationStats,
|
||||
...attachmentStats
|
||||
};
|
||||
}
|
||||
|
||||
private getYearRange(): { startDate: number; endDate: number } {
|
||||
const year = new Date().getFullYear();
|
||||
const startDate = new Date(year, 0, 1, 0, 0, 0, 0).getTime();
|
||||
const endDate = new Date(year + 1, 0, 1, 0, 0, 0, 0).getTime();
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
private async getNoteStats(
|
||||
startDate: number,
|
||||
endDate: number
|
||||
): Promise<NoteStats> {
|
||||
const notesSelector = this.db
|
||||
.sql()
|
||||
.selectFrom("notes")
|
||||
.where((eb) => eb("dateCreated", ">=", startDate))
|
||||
.where((eb) => eb("dateCreated", "<", endDate))
|
||||
.where(isFalse("deleted"))
|
||||
.where(isFalse("dateDeleted"));
|
||||
const notes = await notesSelector.select(["notes.dateCreated"]).execute();
|
||||
|
||||
const monthlyStats: Map<string, number> = new Map();
|
||||
const dayOfWeekStats: Map<string, number> = new Map();
|
||||
let totalNotes = 0;
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.dateCreated) continue;
|
||||
|
||||
totalNotes++;
|
||||
const month = new Date(note.dateCreated).getMonth();
|
||||
const monthName = monthNames[month];
|
||||
monthlyStats.set(monthName, (monthlyStats.get(monthName) || 0) + 1);
|
||||
const dayOfWeek = new Date(note.dateCreated).getDay();
|
||||
const dayName = dayNames[dayOfWeek];
|
||||
dayOfWeekStats.set(dayName, (dayOfWeekStats.get(dayName) || 0) + 1);
|
||||
}
|
||||
|
||||
let mostNotesCreatedInMonth: NoteStats["mostNotesCreatedInMonth"] = null;
|
||||
for (const [month, count] of monthlyStats.entries()) {
|
||||
if (!mostNotesCreatedInMonth || count > mostNotesCreatedInMonth.count) {
|
||||
mostNotesCreatedInMonth = { month, count };
|
||||
}
|
||||
}
|
||||
|
||||
let mostNotesCreatedInDay: NoteStats["mostNotesCreatedInDay"] = null;
|
||||
let maxDayCount = 0;
|
||||
for (const [day, count] of dayOfWeekStats.entries()) {
|
||||
if (count > maxDayCount) {
|
||||
maxDayCount = count;
|
||||
mostNotesCreatedInDay = { day, count };
|
||||
}
|
||||
}
|
||||
|
||||
const totalMonographs = await this.db.monographs.all
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb("dateCreated", ">=", startDate),
|
||||
eb("dateCreated", "<", endDate)
|
||||
])
|
||||
)
|
||||
.count();
|
||||
|
||||
const { largestNote, totalWords } = await this.countTotalWords(
|
||||
notesSelector
|
||||
);
|
||||
|
||||
return {
|
||||
totalNotes,
|
||||
totalWords,
|
||||
totalMonographs,
|
||||
largestNote: largestNote
|
||||
? {
|
||||
title: (await this.db.notes.note(largestNote.id))?.title || "",
|
||||
length: largestNote.wordCount
|
||||
}
|
||||
: null,
|
||||
monthlyStats: Object.fromEntries(monthlyStats),
|
||||
dayOfWeekStats: Object.fromEntries(dayOfWeekStats),
|
||||
mostNotesCreatedInMonth,
|
||||
mostNotesCreatedInDay
|
||||
};
|
||||
}
|
||||
|
||||
private async countItemNotes<T extends { id: string; title: string }>(
|
||||
items: T[],
|
||||
itemType: "tag" | "notebook"
|
||||
): Promise<Array<T & { noteCount: number }>> {
|
||||
const allRelations = await this.db.relations
|
||||
.from({ ids: items.map((item) => item.id), type: itemType }, "note")
|
||||
.get();
|
||||
|
||||
const noteCounts: Map<string, number> = new Map();
|
||||
for (const relation of allRelations) {
|
||||
const itemId = relation.fromId;
|
||||
noteCounts.set(itemId, (noteCounts.get(itemId) || 0) + 1);
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
noteCount: noteCounts.get(item.id) || 0
|
||||
}))
|
||||
.filter((item) => item.noteCount > 0)
|
||||
.sort((a, b) => b.noteCount - a.noteCount);
|
||||
}
|
||||
|
||||
private async getOrganizationStats(
|
||||
startDate: number,
|
||||
endDate: number
|
||||
): Promise<OrganizationStats> {
|
||||
const notebookSelector = this.db.notebooks.all
|
||||
.where((eb) => eb("dateCreated", ">=", startDate))
|
||||
.where((eb) => eb("dateCreated", "<", endDate));
|
||||
const tagSelector = this.db.tags.all
|
||||
.where((eb) => eb("dateCreated", ">=", startDate))
|
||||
.where((eb) => eb("dateCreated", "<", endDate));
|
||||
|
||||
const [totalNotebooks, totalTags, tags, notebooks, totalColors] =
|
||||
await Promise.all([
|
||||
notebookSelector.count(),
|
||||
tagSelector.count(),
|
||||
tagSelector.fields(["tags.id", "tags.title"]).items(),
|
||||
notebookSelector.fields(["notebooks.id", "notebooks.title"]).items(),
|
||||
this.db.colors.all
|
||||
.where((eb) => eb("dateCreated", ">=", startDate))
|
||||
.where((eb) => eb("dateCreated", "<", endDate))
|
||||
.count()
|
||||
]);
|
||||
|
||||
const tagNotes = await this.countItemNotes(tags, "tag");
|
||||
const mostUsedTags = tagNotes.slice(0, 3);
|
||||
|
||||
const notebookNotes = await this.countItemNotes(notebooks, "notebook");
|
||||
const mostActiveNotebooks = notebookNotes.slice(0, 3);
|
||||
|
||||
return {
|
||||
totalNotebooks,
|
||||
totalTags,
|
||||
mostUsedTags:
|
||||
mostUsedTags.length > 0
|
||||
? mostUsedTags
|
||||
: tags.slice(0, 3).map((tag) => ({ ...tag, noteCount: 0 })),
|
||||
mostActiveNotebooks:
|
||||
mostActiveNotebooks.length > 0
|
||||
? mostActiveNotebooks
|
||||
: notebooks.slice(0, 3).map((n) => ({ ...n, noteCount: 0 })),
|
||||
totalColors
|
||||
};
|
||||
}
|
||||
|
||||
private async getAttachmentStats(
|
||||
startDate: number,
|
||||
endDate: number
|
||||
): Promise<AttachmentStats> {
|
||||
const attachmentsSelector = this.db.attachments.all
|
||||
.where((eb) => eb("dateCreated", ">=", startDate))
|
||||
.where((eb) => eb("dateCreated", "<", endDate));
|
||||
const totalAttachments = await attachmentsSelector.count();
|
||||
|
||||
if (totalAttachments === 0) {
|
||||
return {
|
||||
totalAttachments: 0,
|
||||
totalStorageUsed: 0,
|
||||
largestAttachment: null,
|
||||
mostCommonFileType: null
|
||||
};
|
||||
}
|
||||
|
||||
const totalStorageUsed =
|
||||
(await this.db.attachments.totalSize(attachmentsSelector)) || 0;
|
||||
const attachments = await attachmentsSelector.items();
|
||||
let largestAttachment: AttachmentStats["largestAttachment"] = null;
|
||||
const mimeTypeCounts: Map<string, number> = new Map();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!largestAttachment || attachment.size > largestAttachment.size) {
|
||||
largestAttachment = {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size
|
||||
};
|
||||
}
|
||||
|
||||
const mimeType = attachment.mimeType.split("/")[0] || attachment.mimeType;
|
||||
mimeTypeCounts.set(mimeType, (mimeTypeCounts.get(mimeType) || 0) + 1);
|
||||
}
|
||||
|
||||
let mostCommonFileType: string | null = null;
|
||||
let maxCount = 0;
|
||||
for (const [mimeType, count] of mimeTypeCounts.entries()) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mostCommonFileType = mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalAttachments,
|
||||
totalStorageUsed,
|
||||
largestAttachment,
|
||||
mostCommonFileType
|
||||
};
|
||||
}
|
||||
|
||||
private async countTotalWords(
|
||||
selector: SelectQueryBuilder<DatabaseSchema, "notes", unknown>
|
||||
) {
|
||||
let words = 0;
|
||||
let largestNote = { id: "", wordCount: 0 };
|
||||
const contents = await this.db
|
||||
.sql()
|
||||
.selectFrom("content")
|
||||
.where("noteId", "in", selector.select("id"))
|
||||
.where(isFalse("locked"))
|
||||
.where(isFalse("deleted"))
|
||||
.select(["content.data", "content.noteId"])
|
||||
.execute();
|
||||
|
||||
for (const content of contents) {
|
||||
if (typeof content?.data !== "string") continue;
|
||||
const counted = countWords(toTextContent(content.data));
|
||||
words += counted;
|
||||
if (content.noteId && counted > largestNote.wordCount) {
|
||||
largestNote = { id: content.noteId, wordCount: counted };
|
||||
}
|
||||
}
|
||||
|
||||
return { totalWords: words, largestNote };
|
||||
}
|
||||
}
|
||||
|
||||
function toTextContent(html: string) {
|
||||
let text = "";
|
||||
|
||||
const parser = new Parser({
|
||||
ontext: (data) => {
|
||||
text += data;
|
||||
},
|
||||
onclosetag() {
|
||||
text += " ";
|
||||
}
|
||||
});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
return text;
|
||||
}
|
||||
@@ -45,3 +45,4 @@ export type { SyncOptions } from "./api/sync/index.js";
|
||||
export { sanitizeTag } from "./collections/tags.js";
|
||||
export { default as DataURL } from "./utils/dataurl.js";
|
||||
export { type ResolveInternalLink } from "./content-types/tiptap.js";
|
||||
export type * from "./api/wrapped.js";
|
||||
|
||||
Reference in New Issue
Block a user