feeds-fun 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/components/EntriesList.vue +11 -0
  3. package/src/components/EntryForList.vue +17 -6
  4. package/src/components/RuleForList.vue +2 -2
  5. package/src/components/RulesList.vue +18 -9
  6. package/src/components/TokensCost.vue +0 -2
  7. package/src/components/collections/DetailedItem.vue +9 -0
  8. package/src/components/collections/PublicIntro.vue +63 -0
  9. package/src/components/collections/PublicSelector.vue +43 -0
  10. package/src/components/main/Block.vue +1 -1
  11. package/src/components/main/Item.vue +1 -1
  12. package/src/components/notifications/LoadedOldNews.vue +47 -0
  13. package/src/components/page_header/ExternalLinks.vue +11 -6
  14. package/src/components/side_pannel/CollapseButton.vue +56 -0
  15. package/src/components/tags/EntryTag.vue +4 -3
  16. package/src/components/tags/FilterTag.vue +6 -3
  17. package/src/components/tags/RuleTag.vue +4 -3
  18. package/src/components/tags/TagsFilter.vue +17 -5
  19. package/src/css/panels.css +5 -5
  20. package/src/css/side_panel_layout.css +4 -0
  21. package/src/layouts/SidePanelLayout.vue +51 -26
  22. package/src/layouts/WideLayout.vue +0 -4
  23. package/src/logic/api.ts +23 -0
  24. package/src/logic/enums.ts +28 -12
  25. package/src/logic/events.ts +56 -8
  26. package/src/logic/tagsFilterState.ts +61 -1
  27. package/src/logic/types.ts +15 -2
  28. package/src/logic/utils.ts +104 -1
  29. package/src/main.ts +10 -0
  30. package/src/router/index.ts +16 -3
  31. package/src/stores/collections.ts +17 -1
  32. package/src/stores/entries.ts +154 -19
  33. package/src/stores/globalSettings.ts +11 -8
  34. package/src/views/AuthView.vue +3 -1
  35. package/src/views/CollectionsView.vue +13 -2
  36. package/src/views/DiscoveryView.vue +3 -1
  37. package/src/views/FeedsView.vue +3 -1
  38. package/src/views/MainView.vue +18 -1
  39. package/src/views/NewsView.vue +36 -97
  40. package/src/views/PublicCollectionView.vue +237 -0
  41. package/src/views/RulesView.vue +31 -29
  42. package/src/views/SettingsView.vue +3 -1
@@ -24,7 +24,7 @@
24
24
  }
25
25
 
26
26
  .ffun-x-info {
27
- @apply ffun-x-panel border p-4 rounded-md;
27
+ @apply ffun-x-panel border p-4 rounded-md my-2;
28
28
  }
29
29
 
30
30
  .ffun-info-settings {
@@ -32,18 +32,18 @@
32
32
  }
33
33
 
34
34
  .ffun-info-common {
35
- @apply ffun-x-info bg-blue-50 border-blue-200 text-blue-800;
35
+ @apply ffun-x-info bg-blue-50 border-blue-200 text-blue-900;
36
36
  }
37
37
 
38
38
  .ffun-info-good {
39
- @apply ffun-x-info bg-green-50 border-green-200 text-green-800;
39
+ @apply ffun-x-info bg-green-50 border-green-200 text-green-900;
40
40
  }
41
41
 
42
42
  .ffun-info-bad {
43
- @apply ffun-x-info bg-red-50 border-red-200 text-red-800;
43
+ @apply ffun-x-info bg-red-50 border-red-200 text-red-900;
44
44
  }
45
45
 
46
46
  .ffun-info-waiting {
47
- @apply ffun-x-info bg-yellow-50 border-yellow-200 text-yellow-800;
47
+ @apply ffun-x-info bg-yellow-50 border-yellow-200 text-yellow-900;
48
48
  }
49
49
  }
@@ -15,6 +15,10 @@
15
15
  }
16
16
  }
17
17
 
18
+ .ffun-side-panel-collapsed {
19
+ @apply px-4 w-16 bg-slate-50 flex-shrink-0;
20
+ }
21
+
18
22
  .ffun-body-panel {
19
23
  @apply flex-grow;
20
24
  }
@@ -1,10 +1,14 @@
1
1
  <template>
2
2
  <div class="ffun-side-panel-layout">
3
- <div class="ffun-side-panel">
4
- <div class="ffun-page-header">
5
- <div class="ffun-page-header-title">
3
+ <div
4
+ v-if="globalSettings.showSidebar"
5
+ class="ffun-side-panel">
6
+ <div class="ffun-page-header pr-0 mr-0 flex min-w-full">
7
+ <div class="ffun-page-header-title grow">
6
8
  <slot name="main-header"></slot>
7
9
  </div>
10
+
11
+ <side-panel-collapse-button />
8
12
  </div>
9
13
 
10
14
  <hr />
@@ -49,29 +53,43 @@
49
53
  <div class="ffun-body-panel">
50
54
  <div class="ffun-page-header">
51
55
  <div class="ffun-page-header-left-block">
52
- <template
53
- v-for="[mode, props] of e.MainPanelModeProperties"
54
- :key="mode">
55
- <a
56
- v-if="globalSettings.mainPanelMode !== mode"
57
- :href="router.resolve({name: mode, params: {}}).href"
58
- class="ffun-page-header-link"
59
- @click.prevent="router.push({name: mode, params: {}})">
60
- {{ props.text }}
61
- </a>
62
-
63
- <span
64
- class="ffun-page-header-link-disabled"
65
- v-else
66
- >{{ props.text }}</span
67
- >
56
+ <side-panel-collapse-button v-if="!globalSettings.showSidebar" />
57
+
58
+ <a
59
+ v-if="homeButton"
60
+ :href="router.resolve({name: 'main', params: {}}).href"
61
+ class="ffun-page-header-link"
62
+ >Home</a
63
+ >
64
+
65
+ <template v-if="globalState.isLoggedIn">
66
+ <template
67
+ v-for="[mode, props] of e.MainPanelModeProperties"
68
+ :key="mode">
69
+ <template v-if="props.showInMenu">
70
+ <a
71
+ v-if="globalSettings.mainPanelMode !== mode"
72
+ :href="router.resolve({name: mode, params: {}}).href"
73
+ class="ffun-page-header-link"
74
+ @click.prevent="router.push({name: mode, params: {}})">
75
+ {{ props.text }}
76
+ </a>
77
+
78
+ <span
79
+ class="ffun-page-header-link-disabled"
80
+ v-else
81
+ >{{ props.text }}</span
82
+ >
83
+ </template>
84
+ </template>
68
85
  </template>
69
86
 
70
- <page-header-external-links :show-api="true" />
87
+ <page-header-external-links :show-api="globalState.isLoggedIn" />
71
88
  </div>
72
89
 
73
90
  <div class="ffun-page-header-right-block">
74
91
  <a
92
+ v-if="globalState.isLoggedIn"
75
93
  href="#"
76
94
  class="ffun-page-header-link"
77
95
  @click.prevent="logout()"
@@ -80,7 +98,7 @@
80
98
  </div>
81
99
  </div>
82
100
 
83
- <hr class="my-2 border-slate-400" />
101
+ <hr class="mx-4 my-2 border-slate-400" />
84
102
 
85
103
  <main class="mb-4 px-4 min-h-screen">
86
104
  <slot></slot>
@@ -110,10 +128,14 @@
110
128
  const router = useRouter();
111
129
  const slots = useSlots();
112
130
 
113
- const properties = withDefaults(defineProps<{reloadButton?: boolean; loginRequired?: boolean}>(), {
114
- reloadButton: true,
115
- loginRequired: true
116
- });
131
+ const properties = withDefaults(
132
+ defineProps<{reloadButton?: boolean; loginRequired?: boolean; homeButton?: boolean}>(),
133
+ {
134
+ reloadButton: true,
135
+ loginRequired: true,
136
+ homeButton: false
137
+ }
138
+ );
117
139
 
118
140
  async function logout() {
119
141
  if (settings.authMode === settings.AuthMode.SingleUser) {
@@ -122,7 +144,10 @@
122
144
  }
123
145
 
124
146
  await supertokens.logout();
125
- router.push({name: "main", params: {}});
147
+
148
+ if (properties.loginRequired) {
149
+ router.push({name: "main", params: {}});
150
+ }
126
151
  }
127
152
 
128
153
  const hasSideFooter = computed(() => {
@@ -1,9 +1,5 @@
1
1
  <template>
2
- <!-- <div class="flex justify-center h-screen"> -->
3
- <!-- <div class="flex flex-col justify-start items-center text-center p-4"> -->
4
2
  <slot></slot>
5
- <!-- </div> -->
6
- <!-- </div> -->
7
3
  </template>
8
4
 
9
5
  <script lang="ts" setup></script>
package/src/logic/api.ts CHANGED
@@ -7,6 +7,7 @@ const ENTRY_POINT = "/api";
7
7
 
8
8
  const API_GET_FEEDS = `${ENTRY_POINT}/get-feeds`;
9
9
  const API_GET_LAST_ENTRIES = `${ENTRY_POINT}/get-last-entries`;
10
+ const API_GET_LAST_COLLECTION_ENTRIES = `${ENTRY_POINT}/get-last-collection-entries`;
10
11
  const API_GET_ENTRIES_BY_IDS = `${ENTRY_POINT}/get-entries-by-ids`;
11
12
  const API_CREATE_OR_UPDATE_RULE = `${ENTRY_POINT}/create-or-update-rule`;
12
13
 
@@ -83,6 +84,28 @@ export async function getLastEntries({period}: {period: number}) {
83
84
  return entries;
84
85
  }
85
86
 
87
+ export async function getLastCollectionEntries({
88
+ period,
89
+ collectionSlug
90
+ }: {
91
+ period: number;
92
+ collectionSlug: t.CollectionSlug | null;
93
+ }) {
94
+ const response = await post({
95
+ url: API_GET_LAST_COLLECTION_ENTRIES,
96
+ data: {period: period, collectionSlug: collectionSlug}
97
+ });
98
+
99
+ const entries = [];
100
+
101
+ for (let rawEntry of response.entries) {
102
+ const entry = t.entryFromJSON(rawEntry, response.tagsMapping);
103
+ entries.push(entry);
104
+ }
105
+
106
+ return entries;
107
+ }
108
+
86
109
  export async function getEntriesByIds({ids}: {ids: t.EntryId[]}) {
87
110
  const response = await post({
88
111
  url: API_GET_ENTRIES_BY_IDS,
@@ -10,16 +10,23 @@ export enum MainPanelMode {
10
10
  Rules = "rules",
11
11
  Discovery = "discovery",
12
12
  Collections = "collections",
13
+ PublicCollection = "public-collection",
13
14
  Settings = "settings"
14
15
  }
15
16
 
16
- export const MainPanelModeProperties = new Map<MainPanelMode, {text: string}>([
17
- [MainPanelMode.Entries, {text: "News"}],
18
- [MainPanelMode.Feeds, {text: "Feeds"}],
19
- [MainPanelMode.Rules, {text: "Rules"}],
20
- [MainPanelMode.Discovery, {text: "Discovery"}],
21
- [MainPanelMode.Collections, {text: "Collections"}],
22
- [MainPanelMode.Settings, {text: "Settings"}]
17
+ export type MainPanelModeProperty = {
18
+ readonly text: string;
19
+ readonly showInMenu: boolean;
20
+ };
21
+
22
+ export const MainPanelModeProperties = new Map<MainPanelMode, MainPanelModeProperty>([
23
+ [MainPanelMode.Entries, {text: "News", showInMenu: true}],
24
+ [MainPanelMode.Feeds, {text: "Feeds", showInMenu: true}],
25
+ [MainPanelMode.Rules, {text: "Rules", showInMenu: true}],
26
+ [MainPanelMode.Discovery, {text: "Discovery", showInMenu: true}],
27
+ [MainPanelMode.Collections, {text: "Collections", showInMenu: true}],
28
+ [MainPanelMode.PublicCollection, {text: "Public Collection", showInMenu: false}],
29
+ [MainPanelMode.Settings, {text: "Settings", showInMenu: true}]
23
30
  ]);
24
31
 
25
32
  export enum LastEntriesPeriod {
@@ -38,8 +45,13 @@ export enum LastEntriesPeriod {
38
45
  AllTime = "alltime"
39
46
  }
40
47
 
48
+ export type LastEntriesPeriodProperty = {
49
+ readonly text: string;
50
+ readonly seconds: number;
51
+ };
52
+
41
53
  // Map preserves the order of the keys
42
- export const LastEntriesPeriodProperties = new Map<LastEntriesPeriod, {text: string; seconds: number}>([
54
+ export const LastEntriesPeriodProperties = new Map<LastEntriesPeriod, LastEntriesPeriodProperty>([
43
55
  [LastEntriesPeriod.Hour1, {text: "1 hour", seconds: c.hour}],
44
56
  [LastEntriesPeriod.Hour3, {text: "3 hours", seconds: 3 * c.hour}],
45
57
  [LastEntriesPeriod.Hour6, {text: "6 hours", seconds: 6 * c.hour}],
@@ -63,10 +75,14 @@ export enum EntriesOrder {
63
75
  Cataloged = "cataloged"
64
76
  }
65
77
 
66
- export const EntriesOrderProperties = new Map<
67
- EntriesOrder,
68
- {text: string; orderField: string; timeField: string; direction: number}
69
- >([
78
+ export type EntriesOrderProperty = {
79
+ readonly text: string;
80
+ readonly orderField: string;
81
+ readonly timeField: string;
82
+ readonly direction: number;
83
+ };
84
+
85
+ export const EntriesOrderProperties = new Map<EntriesOrder, EntriesOrderProperty>([
70
86
  [EntriesOrder.Score, {text: "score", orderField: "score", timeField: "catalogedAt", direction: 1}],
71
87
  [EntriesOrder.ScoreToZero, {text: "score ~ 0", orderField: "scoreToZero", timeField: "catalogedAt", direction: 1}],
72
88
  [EntriesOrder.ScoreBackward, {text: "score backward", orderField: "score", timeField: "catalogedAt", direction: -1}],
@@ -1,37 +1,85 @@
1
1
  import * as api from "@/logic/api";
2
2
  import type * as t from "@/logic/types";
3
+ import {inject} from "vue";
3
4
  import type {State as TagState} from "@/logic/tagsFilterState";
4
5
 
5
- export type TagChangeSource = "news_tags_filter" | "rules_tags_filter" | "entry_record" | "rule_record";
6
+ export type EventsViewName =
7
+ | "news"
8
+ | "rules"
9
+ | "public_collections"
10
+ | "auth"
11
+ | "settings"
12
+ | "discovery"
13
+ | "feeds"
14
+ | "main"
15
+ | "collections";
16
+ export type TagChangeSource = "tags_filter" | "entry_record" | "rule_record";
6
17
 
7
- export async function newsLinkOpened({entryId}: {entryId: t.EntryId}) {
8
- await api.trackEvent({name: "news_link_opened", entry_id: entryId});
18
+ export type SidebarVisibilityChangeEvent = "hide" | "show";
19
+ export type SidebarVisibilityChangeSource = "top_sidebar_button";
20
+
21
+ export async function newsLinkOpened({entryId, view}: {entryId: t.EntryId; view: EventsViewName}) {
22
+ await api.trackEvent({
23
+ name: "news_link_opened",
24
+ view: view,
25
+ entry_id: entryId
26
+ });
9
27
  }
10
28
 
11
- export async function newsBodyOpened({entryId}: {entryId: t.EntryId}) {
12
- await api.trackEvent({name: "news_body_opened", entry_id: entryId});
29
+ export async function newsBodyOpened({entryId, view}: {entryId: t.EntryId; view: EventsViewName}) {
30
+ await api.trackEvent({
31
+ name: "news_body_opened",
32
+ view: view,
33
+ entry_id: entryId
34
+ });
13
35
  }
14
36
 
15
- export async function socialLinkClicked({linkType}: {linkType: string}) {
16
- await api.trackEvent({name: "social_link_clicked", link_type: linkType});
37
+ export async function socialLinkClicked({linkType, view}: {linkType: string; view: EventsViewName}) {
38
+ await api.trackEvent({
39
+ name: "social_link_clicked",
40
+ view: view,
41
+ link_type: linkType
42
+ });
43
+ }
44
+
45
+ export async function sidebarStateChanged({
46
+ subEvent,
47
+ view,
48
+ source
49
+ }: {
50
+ subEvent: SidebarVisibilityChangeEvent;
51
+ view: EventsViewName;
52
+ source: SidebarVisibilityChangeSource;
53
+ }) {
54
+ await api.trackEvent({
55
+ name: "sidebar_state_changed",
56
+ view: view,
57
+ sub_event: subEvent,
58
+ source: source
59
+ });
17
60
  }
18
61
 
19
62
  export async function tagStateChanged({
20
63
  tag,
21
64
  fromState,
22
65
  toState,
23
- source
66
+ source,
67
+ view
24
68
  }: {
25
69
  tag: string;
26
70
  fromState: TagState;
27
71
  toState: TagState;
28
72
  source: TagChangeSource;
73
+ view: EventsViewName;
29
74
  }) {
75
+ // const eventsView = inject<events.EventViewName>("eventsViewName");
76
+
30
77
  await api.trackEvent({
31
78
  name: "tag_filter_state_changed",
32
79
  tag: tag,
33
80
  from_state: fromState,
34
81
  to_state: toState,
82
+ view: view,
35
83
  source: source
36
84
  });
37
85
  }
@@ -1,5 +1,6 @@
1
- import {ref, computed, reactive} from "vue";
1
+ import {ref, computed, reactive, watch} from "vue";
2
2
  import type {ComputedRef} from "vue";
3
+ import _ from "lodash";
3
4
 
4
5
  export type State = "required" | "excluded" | "none";
5
6
 
@@ -102,6 +103,34 @@ export class Storage {
102
103
  return report;
103
104
  }
104
105
 
106
+ tagsForUrl() {
107
+ const selectedTags = Object.keys(this.selectedTags).sort();
108
+
109
+ const tags = [];
110
+
111
+ for (const tag of selectedTags) {
112
+ if (this.requiredTags[tag]) {
113
+ tags.push(tag);
114
+ } else {
115
+ tags.push(`-${tag}`);
116
+ }
117
+ }
118
+
119
+ return tags;
120
+ }
121
+
122
+ setTagsFromUrl(tags: string[]) {
123
+ this.clear();
124
+
125
+ for (const tag of tags) {
126
+ if (tag.startsWith("-")) {
127
+ this.excludedTags[tag.slice(1)] = true;
128
+ } else {
129
+ this.requiredTags[tag] = true;
130
+ }
131
+ }
132
+ }
133
+
105
134
  clear() {
106
135
  Object.keys(this.requiredTags).forEach((key) => {
107
136
  delete this.requiredTags[key];
@@ -112,3 +141,34 @@ export class Storage {
112
141
  });
113
142
  }
114
143
  }
144
+
145
+ // must be called synchoronously from the view
146
+ export function setSyncingTagsWithRoute({tagsStates, route, router}: {tagsStates: Storage; route: any; router: any}) {
147
+ if (!route.params.tags) {
148
+ tagsStates.setTagsFromUrl([]);
149
+ } else {
150
+ tagsStates.setTagsFromUrl(route.params.tags);
151
+ }
152
+
153
+ watch(tagsStates, () => {
154
+ const newParams = _.clone(route.params);
155
+ newParams.tags = tagsStates.tagsForUrl();
156
+
157
+ router.push({
158
+ replace: true,
159
+ name: route.name,
160
+ params: newParams
161
+ });
162
+ });
163
+ }
164
+
165
+ // must be called synchoronously from the view
166
+ export function setSyncingTagsSidebarPoint({tagsStates, globalSettings}: {tagsStates: Storage; globalSettings: any}) {
167
+ watch(
168
+ tagsStates,
169
+ () => {
170
+ globalSettings.showSidebarPoint = tagsStates.hasSelectedTags;
171
+ },
172
+ {immediate: true}
173
+ );
174
+ }
@@ -24,6 +24,12 @@ export function toCollectionId(id: string): CollectionId {
24
24
  return id as CollectionId;
25
25
  }
26
26
 
27
+ export type CollectionSlug = string & {readonly __brand: unique symbol};
28
+
29
+ export function toCollectionSlug(slug: string): CollectionSlug {
30
+ return slug as CollectionSlug;
31
+ }
32
+
27
33
  export type URL = string & {readonly __brand: unique symbol};
28
34
 
29
35
  export function toURL(url: string): URL {
@@ -233,7 +239,7 @@ export type Rule = {
233
239
  readonly id: RuleId;
234
240
  readonly requiredTags: string[];
235
241
  readonly excludedTags: string[];
236
- readonly allTags: string[];
242
+ readonly tags: string[];
237
243
  readonly score: number;
238
244
  readonly createdAt: Date;
239
245
  readonly updatedAt: Date;
@@ -261,7 +267,7 @@ export function ruleFromJSON({
261
267
  id: toRuleId(id),
262
268
  requiredTags: requiredTags,
263
269
  excludedTags: excludedTags,
264
- allTags: requiredTags.concat(excludedTags).sort(),
270
+ tags: requiredTags.concat(excludedTags).sort(),
265
271
  score: score,
266
272
  createdAt: new Date(createdAt),
267
273
  updatedAt: new Date(updatedAt)
@@ -411,6 +417,7 @@ export function resourceHistoryRecordFromJSON({
411
417
 
412
418
  export class Collection {
413
419
  readonly id: CollectionId;
420
+ readonly slug: CollectionSlug;
414
421
  readonly guiOrder: number;
415
422
  readonly name: string;
416
423
  readonly description: string;
@@ -419,6 +426,7 @@ export class Collection {
419
426
 
420
427
  constructor({
421
428
  id,
429
+ slug,
422
430
  guiOrder,
423
431
  name,
424
432
  description,
@@ -426,6 +434,7 @@ export class Collection {
426
434
  showOnMain
427
435
  }: {
428
436
  id: CollectionId;
437
+ slug: CollectionSlug;
429
438
  guiOrder: number;
430
439
  name: string;
431
440
  description: string;
@@ -433,6 +442,7 @@ export class Collection {
433
442
  showOnMain: boolean;
434
443
  }) {
435
444
  this.id = id;
445
+ this.slug = slug;
436
446
  this.guiOrder = guiOrder;
437
447
  this.name = name;
438
448
  this.description = description;
@@ -443,6 +453,7 @@ export class Collection {
443
453
 
444
454
  export function collectionFromJSON({
445
455
  id,
456
+ slug,
446
457
  guiOrder,
447
458
  name,
448
459
  description,
@@ -450,6 +461,7 @@ export function collectionFromJSON({
450
461
  showOnMain
451
462
  }: {
452
463
  id: string;
464
+ slug: string;
453
465
  guiOrder: number;
454
466
  name: string;
455
467
  description: string;
@@ -458,6 +470,7 @@ export function collectionFromJSON({
458
470
  }): Collection {
459
471
  return {
460
472
  id: toCollectionId(id),
473
+ slug: toCollectionSlug(slug),
461
474
  guiOrder: guiOrder,
462
475
  name: name,
463
476
  description: description,
@@ -1,4 +1,5 @@
1
1
  import _ from "lodash";
2
+ import type * as t from "@/logic/types";
2
3
  import DOMPurify from "dompurify";
3
4
 
4
5
  export function timeSince(date: Date) {
@@ -64,7 +65,6 @@ export function faviconForUrl(url: string): string | null {
64
65
  const parsedUrl = new URL(url);
65
66
  return `${parsedUrl.protocol}//${parsedUrl.host}/favicon.ico`;
66
67
  } catch (error) {
67
- console.error("Invalid URL:", error);
68
68
  return null;
69
69
  }
70
70
  }
@@ -96,3 +96,106 @@ export function purifyBody({raw, default_}: {raw: string | null; default_: strin
96
96
 
97
97
  return body;
98
98
  }
99
+
100
+ export function chooseTagByUsage({
101
+ tagsCount,
102
+ border,
103
+ exclude
104
+ }: {
105
+ tagsCount: {[key: string]: number};
106
+ border: number;
107
+ exclude: string[];
108
+ }) {
109
+ if (Object.keys(tagsCount).length === 0) {
110
+ return null;
111
+ }
112
+
113
+ if (!exclude) {
114
+ exclude = [];
115
+ }
116
+
117
+ const tags = _.toPairs(tagsCount).sort((a, b) => {
118
+ if (a[1] === b[1]) {
119
+ return a[0].localeCompare(b[0]);
120
+ }
121
+
122
+ return b[1] - a[1];
123
+ });
124
+
125
+ for (let i = 0; i < tags.length; i++) {
126
+ if (exclude.includes(tags[i][0])) {
127
+ continue;
128
+ }
129
+
130
+ if (tags[i][1] < border) {
131
+ return tags[i][0];
132
+ }
133
+ }
134
+
135
+ return tags[tags.length - 1][0];
136
+ }
137
+
138
+ export function countTags(entries: t.Entry[] | t.Rule[] | null) {
139
+ if (!entries) {
140
+ return {};
141
+ }
142
+
143
+ const tagsCount: {[key: string]: number} = {};
144
+
145
+ for (const entry of entries) {
146
+ for (const tag of entry.tags) {
147
+ if (tag in tagsCount) {
148
+ tagsCount[tag] += 1;
149
+ } else {
150
+ tagsCount[tag] = 1;
151
+ }
152
+ }
153
+ }
154
+
155
+ return tagsCount;
156
+ }
157
+
158
+ export function sortIdsList<ID extends string = string>({
159
+ ids,
160
+ storage,
161
+ field,
162
+ direction
163
+ }: {
164
+ ids: ID[];
165
+ storage: {[key: string]: any};
166
+ field: string;
167
+ direction: number;
168
+ }) {
169
+ // Pre-map to avoid repeated lookups in the comparator
170
+ // required for the cases when storage is reactive
171
+ const mapped = ids.map((id) => {
172
+ // @ts-ignore
173
+ return {id, value: storage[id][field]};
174
+ });
175
+
176
+ mapped.sort((a: {id: ID; value: any}, b: {id: ID; value: any}) => {
177
+ if (a.value === null && b.value === null) {
178
+ return 0;
179
+ }
180
+
181
+ if (a.value === null) {
182
+ return 1;
183
+ }
184
+
185
+ if (b.value === null) {
186
+ return -1;
187
+ }
188
+
189
+ if (a.value < b.value) {
190
+ return direction;
191
+ }
192
+
193
+ if (a.value > b.value) {
194
+ return -direction;
195
+ }
196
+
197
+ return 0;
198
+ });
199
+
200
+ return mapped.map((x) => x.id);
201
+ }