mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-23 19:49:56 +01:00
global: add support for expiring notes (#9167)
* core: expiring notes * core: add tests * mobile: support setting not expiry date * web: support note expiry dates Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * core: add tests for expiring notes * core: fix tests * mobile: delete expiring notes at startup * core: create index on expiry date * core: minor refactor * core: remove `.only` sync test * web: refactors * mobile: set limit on expiring notes * web: improve expiry date menu option && note item ui Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: add premium check for setting expiry for notes Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: move note expiry date dialog into its own file && minor changes Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: delete expired notes on startup * web: minor refactors --------- Co-authored-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
66
apps/mobile/app/components/date-picker/index.tsx
Normal file
66
apps/mobile/app/components/date-picker/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { defaultBorderRadius } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import DatePicker from "react-native-date-picker";
|
||||
import dayjs from "dayjs";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export default function DatePickerComponent(props: {
|
||||
onConfirm: (date: Date) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const dateRef = useRef<Date>(dayjs().add(1, "day").toDate());
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.primary.background,
|
||||
borderRadius: defaultBorderRadius,
|
||||
padding: DefaultAppStyles.GAP,
|
||||
borderWidth: 0.5,
|
||||
borderColor: colors.primary.border,
|
||||
width: "80%",
|
||||
gap: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<DatePicker
|
||||
theme={isDark ? "dark" : "light"}
|
||||
mode="date"
|
||||
minimumDate={dayjs().add(1, "day").toDate()}
|
||||
onCancel={() => {
|
||||
close?.();
|
||||
}}
|
||||
date={dateRef.current}
|
||||
onDateChange={(date) => {
|
||||
dateRef.current = date;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={strings.setExpiry()}
|
||||
type="accent"
|
||||
style={{
|
||||
width: "100%"
|
||||
}}
|
||||
onPress={async () => {
|
||||
if (!dateRef.current) return;
|
||||
props.onConfirm(dateRef.current);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={strings.cancel()}
|
||||
type="secondary"
|
||||
style={{
|
||||
width: "100%"
|
||||
}}
|
||||
onPress={async () => {
|
||||
props.onCancel();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ 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 { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TextInput, View, ViewStyle } from "react-native";
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DateMeta } from "./date-meta";
|
||||
import { Items } from "./items";
|
||||
import Notebooks from "./notebooks";
|
||||
import { TagStrip, Tags } from "./tags";
|
||||
import { Dialog } from "../dialog";
|
||||
|
||||
const Line = ({ top = 6, bottom = 6 }) => {
|
||||
const { colors } = useThemeColors();
|
||||
@@ -203,6 +204,7 @@ export const Properties = ({ close = () => {}, item, buttons = [] }) => {
|
||||
/>
|
||||
) : null}
|
||||
<SheetProvider context="properties" />
|
||||
<Dialog context="properties" />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ import AppIcon from "../ui/AppIcon";
|
||||
import { Button } from "../ui/button";
|
||||
import { Pressable } from "../ui/pressable";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { Dialog } from "../dialog";
|
||||
|
||||
const TOP_BAR_ITEMS: ActionId[] = [
|
||||
"pin",
|
||||
@@ -58,6 +59,7 @@ const BOTTOM_BAR_ITEMS: ActionId[] = [
|
||||
"copy-link",
|
||||
"duplicate",
|
||||
"launcher-shortcut",
|
||||
"expiry-date",
|
||||
"trash"
|
||||
];
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useThemeColors } from "@notesnook/theme";
|
||||
import { DisplayedNotification } from "@notifee/react-native";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { InteractionManager, Platform } from "react-native";
|
||||
import { InteractionManager, Platform, View } from "react-native";
|
||||
import Share from "react-native-share";
|
||||
import { DatabaseLogger, db } from "../common/database";
|
||||
import { AttachmentDialog } from "../components/attachments";
|
||||
@@ -71,9 +71,10 @@ import { eUpdateNoteInEditor } from "../utils/events";
|
||||
import { deleteItems } from "../utils/functions";
|
||||
import { convertNoteToText } from "../utils/note-to-text";
|
||||
import { sleep } from "../utils/time";
|
||||
import { resetStoredState } from "./use-stored-state";
|
||||
import { NotesnookModule } from "../utils/notesnook-module";
|
||||
|
||||
import DatePickerComponent from "../components/date-picker";
|
||||
|
||||
export type ActionId =
|
||||
| "select"
|
||||
| "archive"
|
||||
@@ -118,7 +119,8 @@ export type ActionId =
|
||||
| "trash"
|
||||
| "default-homepage"
|
||||
| "default-tag"
|
||||
| "launcher-shortcut";
|
||||
| "launcher-shortcut"
|
||||
| "expiry-date";
|
||||
|
||||
export type Action = {
|
||||
id: ActionId;
|
||||
@@ -163,10 +165,11 @@ export const useActions = ({
|
||||
"notebooks",
|
||||
"customizableSidebar",
|
||||
"customHomepage",
|
||||
"androidLauncherShortcuts"
|
||||
"androidLauncherShortcuts",
|
||||
"expiringNotes"
|
||||
]);
|
||||
const [item, setItem] = useState(propItem);
|
||||
const { colors } = useThemeColors();
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const setMenuPins = useMenuStore((state) => state.setMenuPins);
|
||||
const [isPinnedToMenu, setIsPinnedToMenu] = useState(
|
||||
db.shortcuts.exists(item.id)
|
||||
@@ -1161,6 +1164,45 @@ export const useActions = ({
|
||||
},
|
||||
checked: item.archived,
|
||||
isToggle: true
|
||||
},
|
||||
{
|
||||
id: "expiry-date",
|
||||
title: item.expiryDate ? strings.unsetExpiry() : strings.setExpiry(),
|
||||
icon: item.expiryDate ? "bomb-off" : "bomb",
|
||||
locked: features?.expiringNotes?.isAllowed,
|
||||
onPress: async () => {
|
||||
if (item.expiryDate) {
|
||||
await db.notes.setExpiryDate(null, item.id);
|
||||
setItem((await db.notes.note(item.id)) as Item);
|
||||
} else {
|
||||
if (features && !features?.expiringNotes.isAllowed) {
|
||||
ToastManager.show({
|
||||
message: features?.expiringNotes.error,
|
||||
type: "info",
|
||||
actionText: strings.upgrade(),
|
||||
context: "local",
|
||||
func: () => {
|
||||
PaywallSheet.present(features?.expiringNotes);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
presentDialog({
|
||||
context: "properties",
|
||||
component: (close) => (
|
||||
<DatePickerComponent
|
||||
onCancel={() => close?.()}
|
||||
onConfirm={async (date) => {
|
||||
close?.();
|
||||
await db.notes.setExpiryDate(date.getTime(), item.id);
|
||||
setItem((await db.notes.note(item.id)) as Item);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ import { NotesnookModule } from "../utils/notesnook-module";
|
||||
import { sleep } from "../utils/time";
|
||||
import useFeatureManager from "./use-feature-manager";
|
||||
import { deleteDCacheFiles } from "../common/filesystem/io";
|
||||
import dayjs from "dayjs";
|
||||
import { useRelationStore } from "../stores/use-relation-store";
|
||||
|
||||
const onCheckSyncStatus = async (type: SyncStatusEvent) => {
|
||||
const { disableSync, disableAutoSync } = SettingsService.get();
|
||||
@@ -486,6 +488,28 @@ const IsDatabaseMigrationRequired = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let initialDate = -1;
|
||||
async function expiringNotesTimer() {
|
||||
if (timer != null) clearInterval(timer);
|
||||
|
||||
if (initialDate === -1) {
|
||||
DatabaseLogger.info("Deleting expired notes at startup");
|
||||
db.notes.deleteExpiredNotes();
|
||||
initialDate = dayjs().date();
|
||||
}
|
||||
|
||||
timer = setInterval(() => {
|
||||
if (dayjs().date() != initialDate) {
|
||||
DatabaseLogger.info("Deleting expired notes");
|
||||
db.notes.deleteExpiredNotes();
|
||||
Navigation.queueRoutesForUpdate();
|
||||
useRelationStore.getState().update();
|
||||
initialDate = dayjs().date();
|
||||
}
|
||||
}, 1000 * 60);
|
||||
}
|
||||
|
||||
const initializeDatabase = async (password?: string) => {
|
||||
if (useUserStore.getState().appLocked) return;
|
||||
if (!db.isInitialized) {
|
||||
@@ -509,6 +533,7 @@ const initializeDatabase = async (password?: string) => {
|
||||
Notifications.setupReminders(true);
|
||||
DatabaseLogger.info("Database initialized");
|
||||
Notifications.restorePinnedNotes();
|
||||
expiringNotesTimer();
|
||||
deleteDCacheFiles();
|
||||
}
|
||||
Walkthrough.init();
|
||||
@@ -787,6 +812,7 @@ export const useAppEvents = () => {
|
||||
if (state === "active") {
|
||||
notifee.setBadgeCount(0);
|
||||
updateStatusBarColor();
|
||||
expiringNotesTimer();
|
||||
checkAutoBackup();
|
||||
Sync.run("global", false, "full");
|
||||
reconnectSSE();
|
||||
|
||||
Binary file not shown.
8
apps/mobile/package-lock.json
generated
8
apps/mobile/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.12",
|
||||
"version": "3.3.13-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.12",
|
||||
"version": "3.3.13-beta.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -467,14 +467,14 @@
|
||||
},
|
||||
"../../servers/themes": {
|
||||
"name": "@notesnook/themes-server",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@notesnook/theme": "file:../../packages/theme",
|
||||
"@orama/orama": "^1.0.8",
|
||||
"@sagi.io/workers-kv": "^0.0.15",
|
||||
"@trpc/server": "10.45.2",
|
||||
"async-mutex": "0.5.0",
|
||||
"cloudflare": "^5.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"react": "18.3.1",
|
||||
"util": "^0.12.5",
|
||||
|
||||
@@ -118,7 +118,10 @@ const EXTRA_ICON_NAMES = [
|
||||
"email-newsletter",
|
||||
"cellphone-arrow-down",
|
||||
"format-line-spacing",
|
||||
"calendar-today"
|
||||
"calendar-today",
|
||||
"bomb",
|
||||
"bomb-off",
|
||||
"cancel"
|
||||
];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -24,7 +24,11 @@ import { useEditorStore } from "./stores/editor-store";
|
||||
import { useStore as useAnnouncementStore } from "./stores/announcement-store";
|
||||
import { useStore as useSettingStore } from "./stores/setting-store";
|
||||
import { scheduleBackups, scheduleFullBackups } from "./common/notices";
|
||||
import { introduceFeatures, resetFeatures } from "./common";
|
||||
import {
|
||||
introduceFeatures,
|
||||
resetFeatures,
|
||||
scheduleExpiredNotesDeletion
|
||||
} from "./common";
|
||||
import { AppEventManager, AppEvents } from "./common/app-events";
|
||||
import { db } from "./common/db";
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
@@ -65,6 +69,7 @@ export default function AppEffects() {
|
||||
await FeatureDialog.show({ featureName: "highlights" });
|
||||
await scheduleBackups();
|
||||
await scheduleFullBackups();
|
||||
await scheduleExpiredNotesDeletion();
|
||||
if (useSettingStore.getState().isFullOfflineMode)
|
||||
// NOTE: we deliberately don't await here because we don't want to pause execution.
|
||||
db.attachments.cacheAttachments().catch(logger.error);
|
||||
|
||||
@@ -48,7 +48,6 @@ 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();
|
||||
|
||||
@@ -144,10 +143,6 @@ 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);
|
||||
|
||||
@@ -62,6 +62,7 @@ import { showFeatureNotAllowedToast } from "./toasts";
|
||||
import { UpgradeDialog } from "../dialogs/buy-dialog/upgrade-dialog";
|
||||
import { setToolbarPreset } from "./toolbar-config";
|
||||
import { useKeyStore } from "../interfaces/key-store";
|
||||
import { TaskScheduler } from "../utils/task-scheduler";
|
||||
|
||||
export const CREATE_BUTTON_MAP = {
|
||||
notes: {
|
||||
@@ -560,3 +561,11 @@ export async function resetFeatures() {
|
||||
db.settings.setDefaultTag(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleExpiredNotesDeletion() {
|
||||
await db.notes.deleteExpiredNotes();
|
||||
await TaskScheduler.stop("delete-expired-notes");
|
||||
TaskScheduler.register("delete-expired-notes", "0 0 * * *", async () => {
|
||||
await db.notes.deleteExpiredNotes();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import dayjs from "dayjs";
|
||||
import { hardNavigate, hashNavigate } from "../navigation";
|
||||
import { showToast } from "../utils/toast";
|
||||
import { TaskScheduler } from "../utils/task-scheduler";
|
||||
import { BuyDialog } from "../dialogs/buy-dialog";
|
||||
import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { SettingsDialog } from "../dialogs/settings";
|
||||
|
||||
@@ -223,12 +223,8 @@ import {
|
||||
mdiNoteRemoveOutline,
|
||||
mdiTabPlus,
|
||||
mdiRadar,
|
||||
mdiLinkBoxOutline,
|
||||
mdiHistory,
|
||||
mdiArrowCollapseLeft,
|
||||
mdiArrowCollapseRight,
|
||||
mdiHamburger,
|
||||
mdiNotePlus,
|
||||
mdiNoteEditOutline,
|
||||
mdiArrowUp,
|
||||
mdiInbox
|
||||
|
||||
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
NoteResolvedData,
|
||||
areFeaturesAvailable,
|
||||
exportContent,
|
||||
getFormattedDate,
|
||||
getFormattedReminderTime
|
||||
@@ -66,9 +67,12 @@ import {
|
||||
Attachment,
|
||||
AttachmentError,
|
||||
Circle,
|
||||
Close,
|
||||
Colors,
|
||||
Copy,
|
||||
Destruct,
|
||||
Duplicate,
|
||||
Edit,
|
||||
Export,
|
||||
HTML,
|
||||
InternalLink,
|
||||
@@ -98,6 +102,8 @@ import { Context } from "../list-container/types";
|
||||
import ListItem from "../list-item";
|
||||
import { PublishDialog } from "../publish-view";
|
||||
import TimeAgo from "../time-ago";
|
||||
import { NoteExpiryDateDialog } from "../../dialogs/note-expiry-date-dialog";
|
||||
import { withFeatureCheck } from "../../common";
|
||||
|
||||
type NoteProps = NoteResolvedData & {
|
||||
item: NoteType;
|
||||
@@ -179,6 +185,8 @@ function Note(props: NoteProps) {
|
||||
{locked && <Lock size={11} data-test-id={`locked`} />}
|
||||
{note.favorite && <Star color={primary} size={15} />}
|
||||
{note.readonly && <Readonly size={15} />}
|
||||
{note.expiryDate?.value ? <Destruct size={13} /> : null}
|
||||
|
||||
<TimeAgo live={true} datetime={date} locale="short" />
|
||||
</>
|
||||
) : (
|
||||
@@ -268,6 +276,13 @@ function Note(props: NoteProps) {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{note.expiryDate?.value && (
|
||||
<IconTag
|
||||
icon={Destruct}
|
||||
text={getFormattedDate(note.expiryDate.value, "date")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -289,7 +304,8 @@ export default React.memo(Note, function (prevProps, nextProps) {
|
||||
prevProps.attachments?.failed === nextProps.attachments?.failed &&
|
||||
prevProps.attachments?.total === nextProps.attachments?.total &&
|
||||
prevProps.locked === nextProps.locked &&
|
||||
prevProps.color?.id === nextProps.color?.id
|
||||
prevProps.color?.id === nextProps.color?.id &&
|
||||
prevItem.expiryDate?.value === nextItem.expiryDate?.value
|
||||
);
|
||||
});
|
||||
|
||||
@@ -330,9 +346,9 @@ export const noteMenuItems: (
|
||||
note: NoteType,
|
||||
ids?: string[],
|
||||
context?: { color?: Color; locked?: boolean }
|
||||
) => MenuItem[] = (note, ids = [], context) => {
|
||||
) => Promise<MenuItem[]> = async (note, ids = [], context) => {
|
||||
// const isSynced = db.notes.note(note.id)?.synced();
|
||||
|
||||
const features = await areFeaturesAvailable(["expiringNotes"]);
|
||||
return [
|
||||
{
|
||||
type: "button",
|
||||
@@ -603,6 +619,52 @@ export const noteMenuItems: (
|
||||
multiSelect: true
|
||||
},
|
||||
{ key: "sep3", type: "separator" },
|
||||
note.expiryDate?.value
|
||||
? {
|
||||
type: "button",
|
||||
key: "expiry-date",
|
||||
title: strings.expiryDate(),
|
||||
icon: Destruct.path,
|
||||
menu: {
|
||||
items: [
|
||||
{
|
||||
type: "button",
|
||||
key: "change",
|
||||
title: strings.change(),
|
||||
onClick: async () => {
|
||||
await NoteExpiryDateDialog.show({
|
||||
noteId: note.id,
|
||||
expiryDate: note.expiryDate?.value
|
||||
});
|
||||
},
|
||||
icon: Edit.path
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
key: "remove",
|
||||
title: strings.remove(),
|
||||
onClick: async () => {
|
||||
await db.notes.setExpiryDate(null, ...ids);
|
||||
store.refresh();
|
||||
showToast("success", "Expiry date removed");
|
||||
},
|
||||
icon: Close.path
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
: {
|
||||
type: "button",
|
||||
key: "expiry-date",
|
||||
title: strings.setExpiry(),
|
||||
icon: Destruct.path,
|
||||
premium: !features.expiringNotes.isAllowed,
|
||||
onClick: async () => {
|
||||
await NoteExpiryDateDialog.show({
|
||||
noteId: note.id
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
key: "movetotrash",
|
||||
|
||||
130
apps/web/src/dialogs/note-expiry-date-dialog.tsx
Normal file
130
apps/web/src/dialogs/note-expiry-date-dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
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 { getFormattedDate } from "@notesnook/common";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { PopupPresenter } from "@notesnook/ui";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import { useRef, useState } from "react";
|
||||
import { db } from "../common/db";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { DayPicker } from "../components/day-picker";
|
||||
import Dialog from "../components/dialog";
|
||||
import Field from "../components/field";
|
||||
import { Calendar } from "../components/icons";
|
||||
import { store } from "../stores/note-store";
|
||||
import { useStore as useThemeStore } from "../stores/theme-store";
|
||||
import { setDateOnly } from "../utils/date-time";
|
||||
import { showToast } from "../utils/toast";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
type NoteExpiryDateDialogProps = BaseDialogProps<boolean> & {
|
||||
noteId: string;
|
||||
expiryDate?: number | null;
|
||||
};
|
||||
|
||||
export const NoteExpiryDateDialog = DialogManager.register(
|
||||
function NoteExpiryDateDialog(props: NoteExpiryDateDialogProps) {
|
||||
const { onClose, noteId, expiryDate } = props;
|
||||
const [date, setDate] = useState(
|
||||
expiryDate ? dayjs(expiryDate) : dayjs().add(7, "day")
|
||||
);
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||
const theme = useThemeStore((store) => store.colorScheme);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Set Custom Expiry Date"}
|
||||
onClose={() => onClose(false)}
|
||||
width={400}
|
||||
positiveButton={{
|
||||
text: strings.done(),
|
||||
onClick: async () => {
|
||||
if (date.isBefore(dayjs())) {
|
||||
showToast("error", "Expiry date must be in the future");
|
||||
return;
|
||||
}
|
||||
await db.notes.setExpiryDate(date.valueOf(), noteId);
|
||||
store.refresh();
|
||||
showToast("success", "Expiry date set");
|
||||
onClose(true);
|
||||
}
|
||||
}}
|
||||
negativeButton={{
|
||||
text: strings.cancel(),
|
||||
onClick: () => onClose(false)
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
id="date"
|
||||
label={strings.date()}
|
||||
required
|
||||
inputRef={dateInputRef}
|
||||
helpText={`${db.settings.getDateFormat()}`}
|
||||
action={{
|
||||
icon: Calendar,
|
||||
onClick() {
|
||||
setShowCalendar(true);
|
||||
}
|
||||
}}
|
||||
validate={(t) =>
|
||||
dayjs(t, db.settings.getDateFormat(), true).isValid()
|
||||
}
|
||||
defaultValue={date.format(db.settings.getDateFormat())}
|
||||
onChange={(e) => setDate((d) => setDateOnly(e.target.value, d))}
|
||||
/>
|
||||
<PopupPresenter
|
||||
isOpen={showCalendar}
|
||||
onClose={() => setShowCalendar(false)}
|
||||
position={{
|
||||
isTargetAbsolute: true,
|
||||
target: dateInputRef.current,
|
||||
location: "top"
|
||||
}}
|
||||
>
|
||||
<DayPicker
|
||||
sx={{
|
||||
bg: "background",
|
||||
p: 2,
|
||||
boxShadow: `0px 0px 25px 5px ${
|
||||
theme === "dark" ? "#000000aa" : "#0000004e"
|
||||
}`,
|
||||
borderRadius: "dialog",
|
||||
width: 300
|
||||
}}
|
||||
selected={dayjs(date).toDate()}
|
||||
minDate={dayjs().add(1, "day").toDate()}
|
||||
maxDate={dayjs().add(1, "year").toDate()}
|
||||
onSelect={(day) => {
|
||||
if (!day) return;
|
||||
const dateStr = getFormattedDate(day, "date");
|
||||
setDate((d) => setDateOnly(dateStr, d));
|
||||
if (dateInputRef.current) dateInputRef.current.value = dateStr;
|
||||
setShowCalendar(false);
|
||||
}}
|
||||
/>
|
||||
</PopupPresenter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -480,6 +480,17 @@ const features = {
|
||||
believer: createLimit(true),
|
||||
legacyPro: createLimit(true)
|
||||
}
|
||||
}),
|
||||
expiringNotes: createFeature({
|
||||
id: "expiringNotes",
|
||||
title: "Expiring notes",
|
||||
availability: {
|
||||
free: createLimit(false),
|
||||
essential: createLimit(false),
|
||||
pro: createLimit(true),
|
||||
believer: createLimit(true),
|
||||
legacyPro: createLimit(true)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -784,6 +784,59 @@ test("stress: super concurrent sync", testOptions, async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"test expiring notes auto delete from device B (offline) while device A changes expiryDate val",
|
||||
async (t) => {
|
||||
const [deviceA, deviceB] = await Promise.all([
|
||||
initializeDevice("deviceA"),
|
||||
initializeDevice("deviceB")
|
||||
]);
|
||||
|
||||
t.onTestFinished(async () => {
|
||||
console.log(`${t.task.name} log out`);
|
||||
await cleanup(deviceA, deviceB);
|
||||
});
|
||||
|
||||
const noteId = await deviceA.notes.add({
|
||||
content: {
|
||||
type: "tiptap",
|
||||
data: `<p>Test</p>`
|
||||
}
|
||||
});
|
||||
await deviceA.notes.setExpiryDate(
|
||||
dayjs().add(3, "second").toDate().getTime(),
|
||||
noteId
|
||||
);
|
||||
|
||||
await deviceA.sync({ type: "full" });
|
||||
await delay(1000);
|
||||
await deviceB.sync({ type: "full" });
|
||||
|
||||
expect(await deviceA.notes.note(noteId)).toBeTruthy();
|
||||
expect(await deviceB.notes.note(noteId)).toBeTruthy();
|
||||
|
||||
await delay(3000);
|
||||
|
||||
await deviceB.notes.deleteExpiredNotes();
|
||||
|
||||
await delay(1000);
|
||||
|
||||
await deviceA.notes.setExpiryDate(null, noteId);
|
||||
|
||||
expect(await deviceA.notes.note(noteId)).toBeTruthy();
|
||||
expect(await deviceB.notes.note(noteId)).toBeFalsy();
|
||||
|
||||
await deviceA.sync({ type: "full" });
|
||||
await delay(1000);
|
||||
await deviceB.sync({ type: "full" });
|
||||
await delay(1000);
|
||||
|
||||
expect(await deviceA.notes.note(noteId)).toBeTruthy();
|
||||
expect(await deviceB.notes.note(noteId)).toBeTruthy();
|
||||
},
|
||||
TEST_TIMEOUT
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
TEST_NOTE,
|
||||
TEST_NOTEBOOK,
|
||||
IMG_CONTENT,
|
||||
loginFakeUser
|
||||
loginFakeUser,
|
||||
delay
|
||||
} from "./utils/index.js";
|
||||
import { test, expect } from "vitest";
|
||||
import { GroupOptions, Note } from "../src/types.js";
|
||||
import { sql } from "@streetwriters/kysely";
|
||||
|
||||
async function createAndAddNoteToNotebook(
|
||||
db: Database,
|
||||
@@ -780,3 +782,55 @@ test("edit note's created date", () =>
|
||||
const note = await db.notes.note(id);
|
||||
expect(note?.dateCreated).toBe(date.valueOf());
|
||||
}));
|
||||
|
||||
test("get notes with expiryDate set", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
const expiredOn = dayjs().add(5, "minute").toDate().getTime();
|
||||
await db.notes.setExpiryDate(expiredOn, id);
|
||||
const note = await db.notes.note(id);
|
||||
expect(note?.expiryDate.value).toBe(expiredOn);
|
||||
const expiredNote = await db.notes.all.filter
|
||||
.where(sql.raw("json_extract(expiryDate, '$.value')"), ">", Date.now())
|
||||
.select("expiryDate")
|
||||
.execute();
|
||||
|
||||
expect(expiredNote.length).toBe(1);
|
||||
}));
|
||||
|
||||
test("unset expiryDate", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
const expiredOn = dayjs().add(5, "minute").toDate().getTime();
|
||||
await db.notes.setExpiryDate(expiredOn, id);
|
||||
const note = await db.notes.note(id);
|
||||
expect(note?.expiryDate.value).toBe(expiredOn);
|
||||
const expiringNote = await db.notes.all.filter
|
||||
.where(sql.raw("json_extract(expiryDate, '$.value')"), ">", Date.now())
|
||||
.select("expiryDate")
|
||||
.execute();
|
||||
|
||||
expect(expiringNote.length).toBe(1);
|
||||
|
||||
await db.notes.setExpiryDate(null, id);
|
||||
|
||||
const notExpiringNote = await db.notes.all.filter
|
||||
.where(sql.raw("json_extract(expiryDate, '$.value')"), ">", Date.now())
|
||||
.select("expiryDate")
|
||||
.execute();
|
||||
|
||||
expect(notExpiringNote.length).toBe(0);
|
||||
}));
|
||||
|
||||
test("Delete note on expiryDate", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
const expiredOn = dayjs().add(1, "second").toDate().getTime();
|
||||
await db.notes.setExpiryDate(expiredOn, id);
|
||||
const note = await db.notes.note(id);
|
||||
expect(note?.expiryDate.value).toBe(expiredOn);
|
||||
await delay(2000);
|
||||
db.notes.deleteExpiredNotes();
|
||||
const expiredNote = await db.notes.all.filter
|
||||
.where(sql.raw("json_extract(expiryDate, '$.value')"), ">", Date.now())
|
||||
.select("expiryDate")
|
||||
.execute();
|
||||
expect(expiredNote.length).toBe(0);
|
||||
}));
|
||||
|
||||
@@ -245,7 +245,7 @@ test("deleting a notebook should not re-delete already deleted subnotebooks", ()
|
||||
await db.notebooks.moveToTrash(child3);
|
||||
await db.notebooks.moveToTrash(parent);
|
||||
|
||||
const trash = await db.trash.all("user");
|
||||
const trash = await db.trash.all(["user", "expired"]);
|
||||
expect(trash.some((a) => a.id === child3)).toBe(true);
|
||||
expect(trash.some((a) => a.id === parent)).toBe(true);
|
||||
expect(trash.some((a) => a.id === child2)).toBe(false);
|
||||
@@ -356,7 +356,7 @@ test("permanently deleting a notebook should not delete independently deleted su
|
||||
|
||||
await db.trash.delete(parent);
|
||||
|
||||
const trash = await db.trash.all("user");
|
||||
const trash = await db.trash.all(["user", "expired"]);
|
||||
expect(trash.some((a) => a.id === child3)).toBe(true);
|
||||
expect(trash.some((a) => a.id === parent)).toBe(false);
|
||||
expect(trash.some((a) => a.id === child2)).toBe(false);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ContentItem,
|
||||
Item,
|
||||
MaybeDeletedItem,
|
||||
Note,
|
||||
isDeleted
|
||||
} from "../../types.js";
|
||||
import { ParsedInboxItem, SyncInboxItem } from "./types.js";
|
||||
@@ -45,6 +46,27 @@ class Merger {
|
||||
if (!localItem || remoteItem.dateModified > localItem.dateModified) {
|
||||
return remoteItem;
|
||||
}
|
||||
|
||||
if (
|
||||
!remoteItem.deleted &&
|
||||
remoteItem.type === "note" &&
|
||||
!localItem.deleted &&
|
||||
localItem.type === "trash" &&
|
||||
localItem.itemType === "note" &&
|
||||
localItem.deletedBy === "expired"
|
||||
) {
|
||||
if (
|
||||
remoteItem.expiryDate.dateModified > localItem.expiryDate.dateModified
|
||||
) {
|
||||
localItem.expiryDate = remoteItem.expiryDate;
|
||||
(localItem as unknown as Note).type = "note";
|
||||
(localItem as unknown as Note).deletedBy = null;
|
||||
(localItem as unknown as Note).dateDeleted = null;
|
||||
(localItem as unknown as Note).itemType = null;
|
||||
|
||||
return localItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergeContent(
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
formatTitle
|
||||
} from "../utils/title-format.js";
|
||||
import { clone } from "../utils/clone.js";
|
||||
import { Tiptap } from "../content-types/tiptap.js";
|
||||
import { EMPTY_CONTENT } from "./content.js";
|
||||
import { buildFromTemplate } from "../utils/templates/index.js";
|
||||
import {
|
||||
@@ -41,6 +40,7 @@ import { SQLCollection } from "../database/sql-collection.js";
|
||||
import { isFalse } from "../database/index.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { addItems, deleteItems } from "../utils/array.js";
|
||||
import { sql } from "@streetwriters/kysely";
|
||||
|
||||
export type ExportOptions = {
|
||||
format: "html" | "md" | "txt" | "md-frontmatter";
|
||||
@@ -341,6 +341,16 @@ export class Notes implements ICollection {
|
||||
deleteItems(this.cache.archived, ...ids);
|
||||
}
|
||||
}
|
||||
|
||||
async setExpiryDate(date: number | null, ...ids: string[]) {
|
||||
await this.collection.update(ids, {
|
||||
expiryDate: {
|
||||
dateModified: Date.now(),
|
||||
value: date
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
readonly(state: boolean, ...ids: string[]) {
|
||||
return this.collection.update(ids, { readonly: state });
|
||||
}
|
||||
@@ -527,4 +537,21 @@ export class Notes implements ICollection {
|
||||
"internalLinks"
|
||||
).internalLinks;
|
||||
}
|
||||
|
||||
async deleteExpiredNotes() {
|
||||
const expiredItems = await this.db
|
||||
.sql()
|
||||
.selectFrom("notes")
|
||||
.where("type", "!=", "trash")
|
||||
.where(sql`expiryDate ->> '$.value'`, "<", Date.now())
|
||||
.select("id as noteId")
|
||||
.execute();
|
||||
|
||||
if (!expiredItems.length) return;
|
||||
|
||||
const toDelete = expiredItems
|
||||
.map((item) => item.noteId)
|
||||
.filter((item) => item != null);
|
||||
await this.db.trash.add("note", toDelete, "expired");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ export default class Trash {
|
||||
for (const { id, itemType, deletedBy } of result) {
|
||||
if (itemType === "note") {
|
||||
this.cache.notes.push(id);
|
||||
if (deletedBy === "user") this.userDeletedCache.notes.push(id);
|
||||
if (deletedBy === "user" || deletedBy === "expired")
|
||||
this.userDeletedCache.notes.push(id);
|
||||
} else if (itemType === "notebook") {
|
||||
this.cache.notebooks.push(id);
|
||||
if (deletedBy === "user") this.userDeletedCache.notebooks.push(id);
|
||||
@@ -138,7 +139,8 @@ export default class Trash {
|
||||
deletedBy
|
||||
});
|
||||
this.cache.notes.push(...ids);
|
||||
if (deletedBy === "user") this.userDeletedCache.notes.push(...ids);
|
||||
if (deletedBy === "user" || deletedBy === "expired")
|
||||
this.userDeletedCache.notes.push(...ids);
|
||||
} else if (type === "notebook") {
|
||||
await this.db.notebooks.collection.update(ids, {
|
||||
type: "trash",
|
||||
@@ -226,7 +228,7 @@ export default class Trash {
|
||||
// } else return true;
|
||||
// }
|
||||
|
||||
async all(deletedBy?: TrashItem["deletedBy"]) {
|
||||
async all(deletedBy?: TrashItem["deletedBy"][]) {
|
||||
return [
|
||||
...(await this.trashedNotes(this.cache.notes, deletedBy)),
|
||||
...(await this.trashedNotebooks(this.cache.notebooks, deletedBy))
|
||||
@@ -239,7 +241,7 @@ export default class Trash {
|
||||
|
||||
private async trashedNotes(
|
||||
ids: string[],
|
||||
deletedBy?: TrashItem["deletedBy"]
|
||||
deletedBy?: TrashItem["deletedBy"][]
|
||||
) {
|
||||
if (ids.length <= 0) return [];
|
||||
return (await this.db
|
||||
@@ -247,14 +249,14 @@ export default class Trash {
|
||||
.selectFrom("notes")
|
||||
.where("type", "==", "trash")
|
||||
.where("id", "in", ids)
|
||||
.$if(!!deletedBy, (eb) => eb.where("deletedBy", "==", deletedBy))
|
||||
.$if(!!deletedBy, (eb) => eb.where("deletedBy", "in", deletedBy))
|
||||
.selectAll()
|
||||
.execute()) as TrashItem[];
|
||||
}
|
||||
|
||||
private async trashedNotebooks(
|
||||
ids: string[],
|
||||
deletedBy?: TrashItem["deletedBy"]
|
||||
deletedBy?: TrashItem["deletedBy"][]
|
||||
) {
|
||||
if (ids.length <= 0) return [];
|
||||
return (await this.db
|
||||
@@ -262,7 +264,7 @@ export default class Trash {
|
||||
.selectFrom("notebooks")
|
||||
.where("type", "==", "trash")
|
||||
.where("id", "in", ids)
|
||||
.$if(!!deletedBy, (eb) => eb.where("deletedBy", "==", deletedBy))
|
||||
.$if(!!deletedBy, (eb) => eb.where("deletedBy", "in", deletedBy))
|
||||
.selectAll()
|
||||
.execute()) as TrashItem[];
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ const BooleanProperties: Set<BooleanFields> = new Set([
|
||||
const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
|
||||
note: (row) => {
|
||||
row.conflicted = row.conflicted === 1;
|
||||
if (row.expiryDate) row.expiryDate = JSON.parse(row.expiryDate);
|
||||
},
|
||||
reminder: (row) => {
|
||||
if (row.selectedDays) row.selectedDays = JSON.parse(row.selectedDays);
|
||||
@@ -272,6 +273,9 @@ const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
|
||||
},
|
||||
monograph: (row) => {
|
||||
if (row.password) row.password = JSON.parse(row.password);
|
||||
},
|
||||
trash: (row) => {
|
||||
if (row.expiryDate) row.expiryDate = JSON.parse(row.expiryDate);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -404,6 +404,19 @@ export class NNMigrationProvider implements MigrationProvider {
|
||||
.addColumn("password", "text")
|
||||
.execute();
|
||||
}
|
||||
},
|
||||
"a-2026-01-07": {
|
||||
async up(db) {
|
||||
await db.schema
|
||||
.alterTable("notes")
|
||||
.addColumn("expiryDate", "text")
|
||||
.execute();
|
||||
await db.schema
|
||||
.createIndex("note_expiry_date")
|
||||
.on("notes")
|
||||
.expression(sql`expiryDate ->> '$.value'`)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +210,10 @@ export interface Note extends BaseItem<"note"> {
|
||||
|
||||
isGeneratedTitle?: boolean;
|
||||
archived?: boolean;
|
||||
expiryDate: {
|
||||
dateModified: number;
|
||||
value: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Notebook extends BaseItem<"notebook"> {
|
||||
@@ -543,7 +547,7 @@ export type BaseTrashItem<TItem extends BaseItem<"note" | "notebook">> =
|
||||
/**
|
||||
* deletedBy tells who deleted this specific item.
|
||||
*/
|
||||
deletedBy: "user" | "app";
|
||||
deletedBy: "user" | "app" | "expired";
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
@@ -2700,6 +2700,10 @@ msgstr "Expand sidebar"
|
||||
msgid "Experience the next level of private note taking\""
|
||||
msgstr "Experience the next level of private note taking\""
|
||||
|
||||
#: src/strings.ts:2632
|
||||
msgid "Expiry date"
|
||||
msgstr "Expiry date"
|
||||
|
||||
#: src/strings.ts:2539
|
||||
msgid "Explore all plans"
|
||||
msgstr "Explore all plans"
|
||||
@@ -5854,6 +5858,10 @@ msgstr "Set as light theme"
|
||||
msgid "Set automatic trash cleanup interval from Settings > Behaviour > Clean trash interval."
|
||||
msgstr "Set automatic trash cleanup interval from Settings > Behaviour > Clean trash interval."
|
||||
|
||||
#: src/strings.ts:2630
|
||||
msgid "Set expiry"
|
||||
msgstr "Set expiry"
|
||||
|
||||
#: src/strings.ts:1310
|
||||
msgid "Set full name"
|
||||
msgstr "Set full name"
|
||||
@@ -6807,6 +6815,10 @@ msgstr "Unpublish notes to delete them"
|
||||
msgid "Unregister"
|
||||
msgstr "Unregister"
|
||||
|
||||
#: src/strings.ts:2631
|
||||
msgid "Unset expiry"
|
||||
msgstr "Unset expiry"
|
||||
|
||||
#: src/strings.ts:210
|
||||
#: src/strings.ts:2360
|
||||
msgid "Untitled"
|
||||
|
||||
@@ -2689,6 +2689,10 @@ msgstr ""
|
||||
msgid "Experience the next level of private note taking\""
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2632
|
||||
msgid "Expiry date"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2539
|
||||
msgid "Explore all plans"
|
||||
msgstr ""
|
||||
@@ -5828,6 +5832,10 @@ msgstr ""
|
||||
msgid "Set automatic trash cleanup interval from Settings > Behaviour > Clean trash interval."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2630
|
||||
msgid "Set expiry"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1310
|
||||
msgid "Set full name"
|
||||
msgstr ""
|
||||
@@ -6766,6 +6774,10 @@ msgstr ""
|
||||
msgid "Unregister"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2631
|
||||
msgid "Unset expiry"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:210
|
||||
#: src/strings.ts:2360
|
||||
msgid "Untitled"
|
||||
|
||||
@@ -2626,5 +2626,8 @@ Use this if changes from other devices are not appearing on this device. This wi
|
||||
editCreationDate: () => t`Edit creation date`,
|
||||
unlockIncomingNote: () => t`Unlock incoming note`,
|
||||
unlockIncomingNoteDesc: () =>
|
||||
t`The incoming note could not be unlocked with the provided password. Enter the correct password for the incoming note`
|
||||
t`The incoming note could not be unlocked with the provided password. Enter the correct password for the incoming note`,
|
||||
setExpiry: () => t`Set expiry`,
|
||||
unsetExpiry: () => t`Unset expiry`,
|
||||
expiryDate: () => t`Expiry date`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user