Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / items / item.utils.ts
blob072b1867e68bda10ac70cc47c1cab9c4960a7b0b
1 import { c } from 'ttag';
3 import { MAX_BATCH_PER_REQUEST } from '@proton/pass/constants';
4 import PassCoreUI from '@proton/pass/lib/core/core.ui';
5 import type { Draft } from '@proton/pass/store/reducers';
6 import type {
7     BulkSelectionDTO,
8     IdentityItemPreview,
9     ItemRevision,
10     ItemRevisionID,
11     ItemSortFilter,
12     ItemType,
13     LoginItem,
14     LoginItemPreview,
15     MaybeNull,
16     SelectedItem,
17     UniqueItem,
18     UnsafeItem,
19 } from '@proton/pass/types';
20 import { groupByKey } from '@proton/pass/utils/array/group-by-key';
21 import { arrayInterpolate } from '@proton/pass/utils/array/interpolate';
22 import { deobfuscate } from '@proton/pass/utils/obfuscate/xor';
23 import { UNIX_DAY, UNIX_MONTH, UNIX_WEEK } from '@proton/pass/utils/time/constants';
24 import { getEpoch } from '@proton/pass/utils/time/epoch';
25 import chunk from '@proton/utils/chunk';
27 import { hasUserIdentifier, isEditItemDraft } from './item.predicates';
29 const SEPERATOR = '::';
30 const toKey = (...args: (string | number)[]) => args.join(SEPERATOR);
32 export const getItemKeyRevision = ({ shareId, itemId, revision }: ItemRevision) => toKey(shareId, itemId, revision);
34 export const getItemKey = <T extends UniqueItem>({ shareId, itemId }: T) => toKey(shareId, itemId);
36 export const fromItemKey = (key: string): UniqueItem => {
37     const [shareId, itemId] = key.split(SEPERATOR);
38     return { itemId, shareId };
41 export const intoSelectedItem = ({ shareId, itemId }: ItemRevision): SelectedItem => ({ shareId, itemId });
43 export const getItemActionId = (
44     payload:
45         | { optimisticId: string; itemId?: string; shareId: string }
46         | { optimisticId?: string; itemId: string; shareId: string }
47 ) => toKey(payload.shareId, payload?.optimisticId ?? payload.itemId!);
49 export const flattenItemsByShareId = (itemsByShareId: {
50     [shareId: string]: { [itemId: string]: ItemRevision };
51 }): ItemRevision[] => Object.values(itemsByShareId).flatMap(Object.values);
53 export const interpolateRecentItems =
54     <T extends ItemRevision>(items: T[]) =>
55     (shouldInterpolate: boolean) => {
56         type DateCluster = { label: string; boundary: number };
57         const now = getEpoch();
59         return arrayInterpolate<T, DateCluster>(items, {
60             clusters: shouldInterpolate
61                 ? [
62                       {
63                           // translator: label means items that have been added or edited in last 24 hours from the current moment
64                           label: c('Label').t`Today`,
65                           boundary: now - UNIX_DAY,
66                       },
67                       {
68                           // translator: label means items that have been added or edited in last 7 days from the current moment
69                           label: c('Label').t`Last week`,
70                           boundary: now - UNIX_WEEK,
71                       },
72                       {
73                           // translator: label means items that have been added or edited in last 14 days from the current moment
74                           label: c('Label').t`Last 2 weeks`,
75                           boundary: now - UNIX_WEEK * 2,
76                       },
77                       {
78                           // translator: label means items that have been added or edited in last 4 weeks from the current moment
79                           label: c('Label').t`Last month`,
80                           boundary: now - UNIX_MONTH,
81                       },
82                   ]
83                 : [],
84             fallbackCluster: {
85                 // translator: label means items that have been added or edited more than a month ago (4 weeks) from the current moment
86                 label: c('Label').t`More than a month`,
87                 boundary: 0,
88             },
89             shouldInterpolate: ({ lastUseTime, modifyTime }, { boundary }) =>
90                 Math.max(lastUseTime ?? modifyTime, modifyTime) > boundary,
91         });
92     };
94 export const filterItemsByShareId =
95     (shareId?: MaybeNull<string>) =>
96     <T extends ItemRevision>(items: T[]) => {
97         if (!shareId) return items;
98         return items.filter((item) => shareId === item.shareId);
99     };
101 export const filterItemsByType =
102     (itemType?: MaybeNull<ItemType>) =>
103     <T extends ItemRevision>(items: T[]) => {
104         if (!itemType) return items;
105         return items.filter((item) => !itemType || itemType === item.data.type);
106     };
108 export const filterItemsByUserIdentifier = (email: string) => (items: LoginItem[]) =>
109     items.reduce<LoginItem[]>((acc, item) => {
110         if (hasUserIdentifier(email)(item)) acc.push(item);
111         return acc;
112     }, []);
114 export const sortItems =
115     (sort?: MaybeNull<ItemSortFilter>) =>
116     <T extends ItemRevision>(items: T[]) => {
117         if (!sort) return items;
119         return items.slice().sort((a, b) => {
120             switch (sort) {
121                 case 'createTimeASC':
122                     return a.createTime - b.createTime;
123                 case 'createTimeDESC':
124                     return b.createTime - a.createTime;
125                 case 'recent':
126                     return (
127                         Math.max(b.lastUseTime ?? b.modifyTime, b.modifyTime) -
128                         Math.max(a.lastUseTime ?? a.modifyTime, a.modifyTime)
129                     );
130                 case 'titleASC':
131                     return a.data.metadata.name.localeCompare(b.data.metadata.name);
132             }
133         });
134     };
136 /** Filters the drafts for a given a shareId. If itemIds are provided it will
137  * also try to match for these specifics items */
138 export const matchDraftsForShare = (drafts: Draft[], shareId: string, itemIds?: string[]) =>
139     drafts.filter((draft) => {
140         if (isEditItemDraft(draft) && draft.shareId === shareId) {
141             return itemIds === undefined || itemIds.includes(draft.itemId);
142         }
144         return false;
145     });
147 /** Batches a list of items by shareId : each individual share batch
148  * is in turn batched according to `MAX_BATCH_ITEMS_PER_REQUEST` */
149 export const batchByShareId = <T extends UniqueItem, R>(
150     items: T[],
151     mapTo: (item: T) => R
152 ): { shareId: string; items: R[] }[] =>
153     groupByKey(items, 'shareId').flatMap((shareTrashedItems) => {
154         const batches = chunk(shareTrashedItems, MAX_BATCH_PER_REQUEST);
155         return batches.map((batch) => ({
156             shareId: batch[0].shareId,
157             items: batch.map(mapTo),
158         }));
159     });
161 /** Converts an item revision to a revision request payload  */
162 export const intoRevisionID = (item: ItemRevision): ItemRevisionID => ({
163     ItemID: item.itemId,
164     Revision: item.revision,
167 export const intoUserIdentifier = (item: ItemRevision<'login'>): string =>
168     /** For autofill we use the username if not empty, otherwise the email */
169     deobfuscate(item.data.content.itemUsername) || deobfuscate(item.data.content.itemEmail);
171 export const intoLoginItemPreview = (item: ItemRevision<'login'>): LoginItemPreview => ({
172     itemId: item.itemId,
173     shareId: item.shareId,
174     name: item.data.metadata.name,
175     userIdentifier: intoUserIdentifier(item),
176     url: item.data.content.urls?.[0],
179 export const intoIdentityItemPreview = (item: ItemRevision<'identity'>): IdentityItemPreview => ({
180     itemId: item.itemId,
181     shareId: item.shareId,
182     name: item.data.metadata.name,
183     fullName: item.data.content.fullName,
186 export const getSanitizedUserIdentifiers = ({
187     itemEmail,
188     itemUsername,
189 }: Pick<UnsafeItem<'login'>['content'], 'itemEmail' | 'itemUsername'>) => {
190     const validEmail = PassCoreUI.is_email_valid(itemEmail);
191     const emailUsername = PassCoreUI.is_email_valid(itemUsername);
193     if (itemUsername) {
194         /* `itemEmail` is empty and `itemUsername` is a valid email: Move username to email field */
195         if (!itemEmail && emailUsername) return { email: itemUsername, username: '' };
196         /* `itemEmail` is invalid but `itemUsername` is a valid email: Swap email and username */
197         if (!validEmail && emailUsername) return { email: itemUsername, username: itemEmail };
198         /* All other cases, return in-place */
199         return { email: itemEmail, username: itemUsername };
200     }
202     /* If `itemEmail` is valid, keep it; otherwise, move it to username field */
203     return validEmail ? { email: itemEmail, username: '' } : { email: '', username: itemEmail };
206 export const intoBulkSelection = (items: UniqueItem[]): BulkSelectionDTO =>
207     items.reduce<BulkSelectionDTO>((dto, { shareId, itemId }) => {
208         dto[shareId] = dto[shareId] ?? {};
209         dto[shareId][itemId] = true;
210         return dto;
211     }, {});
213 export const getBulkSelectionCount = (selected: BulkSelectionDTO) =>
214     Object.values(selected).reduce((acc, items) => acc + Object.keys(items).length, 0);
216 export const formatDisplayNameWithEmail = (name: string, email: string) => `${name} <${email}>`;