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:
Ammar Ahmed
2026-01-16 10:55:40 +05:00
committed by GitHub
parent 09cba1a1df
commit 210549445e
29 changed files with 591 additions and 38 deletions

View 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>
);
}

View File

@@ -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";

View File

@@ -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>
)}
/>

View File

@@ -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"
];

View File

@@ -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);
}}
/>
)
});
}
}
}
);

View File

@@ -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();

View File

@@ -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",

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
});
}

View File

@@ -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";

View File

@@ -223,12 +223,8 @@ import {
mdiNoteRemoveOutline,
mdiTabPlus,
mdiRadar,
mdiLinkBoxOutline,
mdiHistory,
mdiArrowCollapseLeft,
mdiArrowCollapseRight,
mdiHamburger,
mdiNotePlus,
mdiNoteEditOutline,
mdiArrowUp,
mdiInbox

View File

@@ -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",

View 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>
);
}
);

View File

@@ -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)
}
})
};

View File

@@ -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

View File

@@ -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);
}));

View File

@@ -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);

View File

@@ -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(

View File

@@ -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");
}
}

View File

@@ -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[];
}

View File

@@ -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);
}
};

View File

@@ -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();
}
}
};
}

View File

@@ -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
*/

View File

@@ -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"

View File

@@ -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"

View File

@@ -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`
};