web: add support for archiving notes (#7810)

* web: archive notes
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* global: arhives -> archived
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-04-28 09:45:59 +05:00
committed by Abdullah Atta
parent 20bbb6dd5b
commit e7ec9e2aa3
24 changed files with 317 additions and 13 deletions

View File

@@ -108,6 +108,12 @@ export class AppModel {
return new TrashViewModel(this.page);
}
async goToArchive() {
await this.page.locator(getTestId("tab-home")).click();
await this.navigateTo("Archive");
return new NotesViewModel(this.page, "notes", "archive");
}
async goToSettings() {
await this.profileDropdown.open(
this.page.locator(getTestId("profile-dropdown")),

View File

@@ -37,6 +37,7 @@ abstract class BaseProperties {
private readonly pinToggle: ToggleModel;
private readonly favoriteToggle: ToggleModel;
private readonly lockToggle: ToggleModel;
private readonly archiveToggle: ToggleModel;
constructor(
page: Page,
@@ -47,6 +48,7 @@ abstract class BaseProperties {
this.pinToggle = new ToggleModel(page, `${itemPrefix}-pin`);
this.lockToggle = new ToggleModel(page, `${itemPrefix}-lock`);
this.favoriteToggle = new ToggleModel(page, `${itemPrefix}-favorite`);
this.archiveToggle = new ToggleModel(page, `${itemPrefix}-archive`);
}
async isPinned() {
@@ -126,6 +128,25 @@ abstract class BaseProperties {
await this.close();
}
async isArchived() {
await this.open();
const state = await this.archiveToggle.isToggled();
await this.close();
return state;
}
async archive() {
await this.open();
await this.archiveToggle.on();
await this.close();
}
async unarchive() {
await this.open();
await this.archiveToggle.off();
await this.close();
}
abstract isColored(color: string): Promise<boolean>;
abstract color(color: string): Promise<void>;
abstract open(): Promise<void>;

View File

@@ -201,6 +201,37 @@ for (const actor of actors) {
expect(await note?.getDescription()).toContain(NOTE.content);
expect(await note?.contextMenu.isLocked()).toBe(false);
});
test(`archive a note using ${actor}`, async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await note?.[actor].archive();
const archive = await app.goToArchive();
const archivedNote = await archive.findNote(NOTE);
expect(await archivedNote?.contextMenu.isArchived()).toBe(true);
expect(await archivedNote?.properties.isArchived()).toBe(true);
});
test(`unarchive a note using ${actor}`, async ({ page }) => {
const app = new AppModel(page);
await app.goto();
let notes = await app.goToNotes();
let note = await notes.createNote(NOTE);
await note?.contextMenu.archive();
const archive = await app.goToArchive();
const archivedNote = await archive.findNote(NOTE);
await archivedNote?.[actor].unarchive();
notes = await app.goToNotes();
note = await notes.findNote(NOTE);
expect(await note?.contextMenu.isArchived()).toBe(false);
expect(await note?.properties.isArchived()).toBe(false);
});
}
test("open a locked note", async ({ page }) => {
@@ -339,3 +370,48 @@ test(`sort notes`, async ({ page }, info) => {
}
}
});
test("archived favorite note shouldn't be in favorites note list", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await note?.contextMenu.favorite();
await note?.contextMenu.archive();
const favorites = await app.goToFavorites();
expect(await favorites.findNote(NOTE)).toBeUndefined();
});
test("archived tag note shouldn't be in tags note list", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const tags = await app.goToTags();
const tag = await tags.createItem({ title: "my-tag" });
const notes = await tag?.open();
const note = await notes?.createNote(NOTE);
expect(await notes?.findNote(NOTE)).toBeDefined();
await note?.contextMenu.archive();
expect(await notes?.findNote(NOTE)).toBeUndefined();
});
test("archived notebook note shouldn't be in notebooks note list", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook({ title: "my-notebook" });
const notes = await notebook?.openNotebook();
const note = await notes?.createNote(NOTE);
expect(await notes?.findNote(NOTE)).toBeDefined();
await note?.contextMenu.archive();
expect(await notes?.findNote(NOTE)).toBeUndefined();
});

View File

@@ -40,6 +40,7 @@ import {
mdiClose,
mdiDotsVertical,
mdiTrashCanOutline,
mdiArchiveOutline,
mdiBookRemoveOutline,
mdiMagnify,
mdiMenu,
@@ -351,6 +352,7 @@ export const Cross = createIcon(mdiClose);
export const MoreVertical = createIcon(mdiDotsVertical);
export const MoreHorizontal = createIcon(mdiDotsHorizontal);
export const Trash = createIcon(mdiTrashCanOutline);
export const Archive = createIcon(mdiArchiveOutline);
export const TopicRemove = createIcon(mdiBookmarkRemoveOutline);
export const NotebookRemove = createIcon(mdiBookRemoveOutline);
export const Search = createIcon(mdiMagnify);

View File

@@ -36,7 +36,7 @@ export type Context =
}
| NotebookContext
| {
type: "favorite" | "monographs";
type: "favorite" | "monographs" | "archive";
};
export type WithDateEdited<T> = { items: T[]; dateEdited: number };

View File

@@ -46,7 +46,8 @@ import {
Reset,
Rename,
ExpandSidebar,
HamburgerMenu
HamburgerMenu,
Archive
} from "../icons";
import { SortableNavigationItem } from "./navigation-item";
import {
@@ -111,7 +112,7 @@ import { showSortMenu } from "../group-header";
import { Freeze } from "react-freeze";
type Route = {
id: "notes" | "favorites" | "reminders" | "monographs" | "trash";
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
title: string;
path: string;
icon: Icon;
@@ -138,7 +139,13 @@ const routes: Route[] = [
path: "/monographs",
icon: Monographs
},
{ id: "trash", title: strings.routes.Trash(), path: "/trash", icon: Trash }
{ id: "trash", title: strings.routes.Trash(), path: "/trash", icon: Trash },
{
id: "archive",
title: strings.archive(),
path: "/archive",
icon: Archive
}
];
const tabs = [
@@ -706,6 +713,8 @@ function ItemCount({ item }: { item: Route | Color | Notebook | Tag }) {
return trash?.length || 0;
case "monographs":
return monographs?.length || 0;
case "archive":
return db.notes.archived.count();
default:
return 0;
}

View File

@@ -62,6 +62,7 @@ import {
AddReminder,
AddToNotebook,
Alert,
Archive,
Attachment,
AttachmentError,
Circle,
@@ -395,6 +396,15 @@ export const noteMenuItems: (
await AddReminderDialog.show({ note });
}
},
{
type: "button",
key: "archive",
title: strings.archive(),
isChecked: note.archived,
icon: Archive.path,
onClick: () => store.archive(!note.archived, ...ids),
multiSelect: true
},
{ key: "sep1", type: "separator" },
{
type: "button",

View File

@@ -31,7 +31,8 @@ import {
ChevronRight,
LinkedTo,
ReferencedIn as ReferencedInIcon,
Note as NoteIcon
Note as NoteIcon,
Archive
} from "../icons";
import { Box, Button, Flex, Text, FlexProps } from "@theme-ui/components";
import {
@@ -83,6 +84,12 @@ const tools = [
label: strings.readOnly(),
property: "readonly"
},
{
key: "archive",
icon: Archive,
label: strings.archive(),
property: "archived"
},
{
key: "local-only",
icon: SyncOff,
@@ -837,10 +844,17 @@ export function Section({
}
function changeToggleState(
prop: "lock" | "readonly" | "local-only" | "pin" | "favorite",
prop: "lock" | "readonly" | "local-only" | "pin" | "favorite" | "archive",
session: ReadonlyEditorSession | DefaultEditorSession
) {
const { id: sessionId, readonly, localOnly, pinned, favorite } = session.note;
const {
id: sessionId,
readonly,
localOnly,
pinned,
favorite,
archived
} = session.note;
if (!sessionId) return;
switch (prop) {
case "lock":
@@ -855,6 +869,8 @@ function changeToggleState(
return noteStore.pin(!pinned, sessionId);
case "favorite":
return noteStore.favorite(!favorite, sessionId);
case "archive":
return noteStore.archive(!archived, sessionId);
default:
return;
}

View File

@@ -48,6 +48,7 @@ export type TipContext =
| "reminders"
| "monographs"
| "trash"
| "archive"
| "attachments";
export type Tip = {
@@ -207,5 +208,8 @@ const DEFAULT_TIPS: Record<TipContext, Omit<Tip, "contexts">> = {
trash: {
text: ""
},
archive: {
text: strings.yourArchiveIsEmpty()
},
search: { text: "" }
};

View File

@@ -105,6 +105,15 @@ const routes = defineRoutes({
component: Trash
});
},
"/archive": () => {
notestore.setContext({ type: "archive" });
return defineRoute({
key: "notes",
title: strings.archive(),
type: "notes",
component: Notes
});
},
"/tags/:tagId": async ({ tagId }) => {
const tag = await db.tags.tag(tagId);
if (!tag) return NOT_FOUND_ROUTE;

View File

@@ -382,6 +382,8 @@ class EditorStore extends BaseStore<EditorStore> {
event.item.localOnly ?? session.note.localOnly;
session.note.favorite =
event.item.favorite ?? session.note.favorite;
session.note.archived =
event.item.archived ?? session.note.archived;
session.note.dateEdited =
event.item.dateEdited ?? session.note.dateEdited;
});

View File

@@ -62,7 +62,11 @@ class NoteStore extends BaseStore<NoteStore> {
contextNotes: context
? await notesFromContext(context).grouped(
db.settings.getGroupOptions(
context.type === "favorite" ? "favorites" : "notes"
context.type === "favorite"
? "favorites"
: context.type === "archive"
? "archive"
: "notes"
)
)
: undefined
@@ -85,6 +89,11 @@ class NoteStore extends BaseStore<NoteStore> {
await this.refresh();
};
archive = async (state: boolean, ...ids: string[]) => {
await db.notes.archive(state, ...ids);
await this.refresh();
};
unlock = async (id: string) => {
return await Vault.unlockNote(id).then(async (res) => {
await this.refresh();
@@ -141,6 +150,8 @@ export function notesFromContext(context: Context) {
.selector;
case "favorite":
return db.notes.favorites;
case "archive":
return db.notes.archived;
case "monographs":
return db.monographs.all;
}

View File

@@ -35,7 +35,12 @@ function Notes(props: NotesProps) {
const context = useNotesStore((store) => store.context);
const contextNotes = useNotesStore((store) => store.contextNotes);
const refreshContext = useNotesStore((store) => store.refreshContext);
const type = context?.type === "favorite" ? "favorites" : "notes";
const type =
context?.type === "favorite"
? "favorites"
: context?.type === "archive"
? "archive"
: "notes";
const isCompact = useNotesStore((store) => store.viewMode === "compact");
const filteredItems = useSearch(
context?.type === "notebook" ? "notebook" : "notes",
@@ -62,6 +67,8 @@ function Notes(props: NotesProps) {
context={
context.type === "favorite"
? "favorites"
: context.type === "archive"
? "archive"
: context.type === "monographs"
? "monographs"
: "notes"

View File

@@ -707,3 +707,54 @@ for (const group of groups) {
}));
}
}
test("get archived notes", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
expect(await db.notes.archived.count()).toBeGreaterThan(0);
}));
test("archive note", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
const note = await db.notes.note(id);
expect(note?.archived).toBe(true);
}));
test("unarchive note", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
await db.notes.archive(false, id);
const note = await db.notes.note(id);
expect(note?.archived).toBe(false);
}));
test("archiving note should update cache.archived", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
const note = await db.notes.note(id);
expect(db.notes.cache.archived).toEqual([note?.id]);
}));
test("un-archiving note should update cache.archived", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
const note = await db.notes.note(id);
expect(db.notes.cache.archived).toEqual([note?.id]);
await db.notes.archive(false, id);
expect(db.notes.cache.archived).toEqual([]);
}));
test("archived note shouldn't be in all notes", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
expect(await db.notes.all.count()).toBe(0);
}));
test("archived note shouldn't be in favorites", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.favorite(true, id);
await db.notes.archive(true, id);
expect(await db.notes.favorites.count()).toBe(0);
}));

View File

@@ -40,6 +40,7 @@ import { ICollection } from "./collection.js";
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";
export type ExportOptions = {
format: "html" | "md" | "txt" | "md-frontmatter";
@@ -51,6 +52,7 @@ export type ExportOptions = {
export class Notes implements ICollection {
name = "notes";
cache: { archived: string[] } = { archived: [] };
/**
* @internal
*/
@@ -69,6 +71,13 @@ export class Notes implements ICollection {
async init() {
await this.collection.init();
this.totalNotes = await this.collection.count();
await this.buildCache();
}
async buildCache() {
this.cache.archived = [];
const archived = await this.archived.ids();
this.cache.archived = archived;
}
async add(
@@ -233,7 +242,11 @@ export class Notes implements ICollection {
get all() {
return this.collection.createFilter<Note>(
(qb) => qb.where(isFalse("dateDeleted")).where(isFalse("deleted")),
(qb) =>
qb
.where(isFalse("dateDeleted"))
.where(isFalse("deleted"))
.where(isFalse("archived")),
this.db.options?.batchSize
);
}
@@ -276,11 +289,23 @@ export class Notes implements ICollection {
qb
.where(isFalse("dateDeleted"))
.where(isFalse("deleted"))
.where(isFalse("archived"))
.where("favorite", "==", true),
this.db.options?.batchSize
);
}
get archived() {
return this.collection.createFilter<Note>(
(qb) =>
qb
.where(isFalse("dateDeleted"))
.where(isFalse("deleted"))
.where("archived", "==", true),
this.db.options?.batchSize
);
}
exists(id: string) {
return this.collection.exists(id);
}
@@ -299,6 +324,14 @@ export class Notes implements ICollection {
favorite(state: boolean, ...ids: string[]) {
return this.collection.update(ids, { favorite: state });
}
async archive(state: boolean, ...ids: string[]) {
await this.collection.update(ids, { archived: state });
if (state) {
addItems(this.cache.archived, ...ids);
} else {
deleteItems(this.cache.archived, ...ids);
}
}
readonly(state: boolean, ...ids: string[]) {
return this.collection.update(ids, { readonly: state });
}

View File

@@ -395,6 +395,11 @@ class RelationsArray<TType extends keyof RelatableTable> {
this.db.trash.cache.notes.length > 0,
(b) => b.where("fromId", "not in", this.db.trash.cache.notes)
)
.$if(
!!this.types?.includes("note" as TType) &&
this.db.notes.cache.archived.length > 0,
(b) => b.where("fromId", "not in", this.db.notes.cache.archived)
)
.$if(
!!this.types?.includes("notebook" as TType) &&
this.db.trash.cache.notebooks.length > 0,
@@ -424,6 +429,11 @@ class RelationsArray<TType extends keyof RelatableTable> {
this.db.trash.cache.notes.length > 0,
(b) => b.where("toId", "not in", this.db.trash.cache.notes)
)
.$if(
!!this.types?.includes("note" as TType) &&
this.db.notes.cache.archived.length > 0,
(b) => b.where("toId", "not in", this.db.notes.cache.archived)
)
.$if(
!!this.types?.includes("notebook" as TType) &&
this.db.trash.cache.notebooks.length > 0,

View File

@@ -62,6 +62,7 @@ const defaultSettings: SettingItemMap = {
"groupOptions:notes": DEFAULT_GROUP_OPTIONS("notes"),
"groupOptions:notebooks": DEFAULT_GROUP_OPTIONS("notebooks"),
"groupOptions:favorites": DEFAULT_GROUP_OPTIONS("favorites"),
"groupOptions:archive": DEFAULT_GROUP_OPTIONS("archive"),
"groupOptions:home": DEFAULT_GROUP_OPTIONS("home"),
"groupOptions:reminders": DEFAULT_GROUP_OPTIONS("reminders"),

View File

@@ -232,7 +232,8 @@ const BooleanProperties: Set<BooleanFields> = new Set([
"readonly",
"remote",
"synced",
"isGeneratedTitle"
"isGeneratedTitle",
"archived"
]);
const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {

View File

@@ -391,6 +391,14 @@ export class NNMigrationProvider implements MigrationProvider {
.addColumn("isGeneratedTitle", "boolean")
.execute();
}
},
"9": {
async up(db) {
await db.schema
.alterTable("notes")
.addColumn("archived", "boolean")
.execute();
}
}
};
}

View File

@@ -49,7 +49,8 @@ export const GroupingKey = [
"tags",
"trash",
"favorites",
"reminders"
"reminders",
"archive"
] as const;
export type GroupingKey = (typeof GroupingKey)[number];
@@ -200,6 +201,7 @@ export interface Note extends BaseItem<"note"> {
deletedBy: null;
isGeneratedTitle?: boolean;
archived?: boolean;
}
export interface Notebook extends BaseItem<"notebook"> {

View File

@@ -31,6 +31,13 @@ export function addItem<T>(array: T[], item: T) {
return true;
}
export function addItems<T>(array: T[], ...items: T[]) {
for (const item of items) {
addItem(array, item);
}
return array;
}
export function deleteItem<T>(array: T[], item: T) {
return deleteAtIndex(array, array.indexOf(item));
}

View File

@@ -866,6 +866,10 @@ msgstr "Apply changes"
msgid "Applying changes"
msgstr "Applying changes"
#: src/strings.ts:2468
msgid "Archive"
msgstr "Archive"
#: src/strings.ts:1431
msgid "Are you scrolling a lot to find a specific note? Pin it to the top from Note properties."
msgstr "Are you scrolling a lot to find a specific note? Pin it to the top from Note properties."
@@ -7007,6 +7011,10 @@ msgstr "Your account password must be strong & unique."
msgid "Your account will be downgraded in {days} days"
msgstr "Your account will be downgraded in {days} days"
#: src/strings.ts:2469
msgid "Your archive is empty"
msgstr "Your archive is empty"
#: src/strings.ts:1914
msgid "Your backup is ready to download"
msgstr "Your backup is ready to download"

View File

@@ -866,6 +866,10 @@ msgstr ""
msgid "Applying changes"
msgstr ""
#: src/strings.ts:2468
msgid "Archive"
msgstr ""
#: src/strings.ts:1431
msgid "Are you scrolling a lot to find a specific note? Pin it to the top from Note properties."
msgstr ""
@@ -6953,6 +6957,10 @@ msgstr ""
msgid "Your account will be downgraded in {days} days"
msgstr ""
#: src/strings.ts:2469
msgid "Your archive is empty"
msgstr ""
#: src/strings.ts:1914
msgid "Your backup is ready to download"
msgstr ""

View File

@@ -2464,5 +2464,7 @@ Use this if changes from other devices are not appearing on this device. This wi
setAsHomepage: () => t`Set as homepage`,
defaultSidebarTab: () => t`Default sidebar tab`,
defaultSidebarTabDesc: () => t`Select the default sidebar tab`,
unsetAsHomepage: () => t`Reset homepage`
unsetAsHomepage: () => t`Reset homepage`,
archive: () => t`Archive`,
yourArchiveIsEmpty: () => t`Your archive is empty`
};