feeds-fun 0.3.0 → 0.5.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feeds-fun",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "author": "Aliaksei Yaletski (Tiendil) <a.eletsky@gmail.com> (https://tiendil.org/)",
5
5
  "description": "Frontend for the Feeds Fun — web-based news reader",
6
6
  "keywords": [
Binary file
Binary file
@@ -9,7 +9,8 @@
9
9
  <entry-for-list
10
10
  :entryId="entryId"
11
11
  :time-field="timeField"
12
- :show-tags="showTags" />
12
+ :show-tags="showTags"
13
+ :tags-count="tagsCount" />
13
14
  </li>
14
15
  </ul>
15
16
 
@@ -36,6 +37,7 @@
36
37
  showTags: boolean;
37
38
  showFromStart: number;
38
39
  showPerPage: number;
40
+ tagsCount: {[key: string]: number};
39
41
  }>();
40
42
 
41
43
  const showEntries = ref(properties.showFromStart);
@@ -14,23 +14,25 @@
14
14
  on-text="read"
15
15
  off-text="new!" />
16
16
 
17
- ·
18
-
19
17
  <a
20
18
  href="#"
21
- style="text-decoration: none"
19
+ style="text-decoration: none; margin-left: 0.25rem"
22
20
  v-if="!showBody"
23
21
  @click.prevent="displayBody()"
24
22
  >&#9660;</a
25
23
  >
26
24
  <a
27
25
  href="#"
28
- style="text-decoration: none"
26
+ style="text-decoration: none; margin-left: 0.25rem"
29
27
  v-if="showBody"
30
28
  @click.prevent="showBody = false"
31
29
  >&#9650;</a
32
30
  >
33
31
 
32
+ <favicon-element
33
+ :url="entry.url"
34
+ style="width: 1rem; height: 1rem; vertical-align: text-bottom; margin-left: 0.25rem; margin-right: 0.25rem" />
35
+
34
36
  <a
35
37
  :href="entry.url"
36
38
  target="_blank"
@@ -43,6 +45,7 @@
43
45
  <br />
44
46
  <tags-list
45
47
  :tags="entry.tags"
48
+ :tags-count="tagsCount"
46
49
  :contributions="entry.scoreContributions" />
47
50
  </template>
48
51
 
@@ -82,6 +85,7 @@
82
85
  entryId: t.EntryId;
83
86
  timeField: string;
84
87
  showTags: boolean;
88
+ tagsCount: {[key: string]: number};
85
89
  }>();
86
90
 
87
91
  const entry = computed(() => {
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <img
3
+ :src="faviconUrl"
4
+ @error="handleFaviconError" />
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import * as utils from "@/logic/utils";
9
+ import {computed, ref} from "vue";
10
+
11
+ const properties = defineProps<{url: string}>();
12
+
13
+ const noFavicon = ref(false);
14
+
15
+ function handleFaviconError() {
16
+ noFavicon.value = true;
17
+ }
18
+
19
+ const faviconUrl = computed(() => {
20
+ if (noFavicon.value) {
21
+ return "/no-favicon.ico";
22
+ }
23
+
24
+ const url = utils.faviconForUrl(properties.url);
25
+
26
+ if (url == null) {
27
+ return "/no-favicon.ico";
28
+ }
29
+
30
+ return url;
31
+ });
32
+ </script>
33
+
34
+ <style scoped></style>
@@ -41,6 +41,10 @@
41
41
  >
42
42
  </div>
43
43
 
44
+ <favicon-element
45
+ :url="feed.url"
46
+ style="width: 1rem; height: 1rem; vertical-align: text-bottom; margin-right: 0.25rem" />
47
+
44
48
  <div style="flex-grow: 1">
45
49
  <value-url
46
50
  :value="feed.url"
@@ -3,14 +3,25 @@
3
3
  <ul
4
4
  v-if="displayedSelectedTags.length > 0"
5
5
  style="list-style: none; padding: 0; margin: 0">
6
- <tags-filter-element
6
+ <li
7
+ class="filter-element"
7
8
  v-for="tag of displayedSelectedTags"
8
- :key="tag"
9
- :tag="tag"
10
- :count="tags[tag] ?? 0"
11
- :selected="true"
12
- @tag:selected="onTagSelected"
13
- @tag:deselected="onTagDeselected" />
9
+ :key="tag">
10
+ <ffun-tag
11
+ :uid="tag"
12
+ :count="tags[tag] ?? 0"
13
+ count-mode="no"
14
+ :mode="tagStates[tag]"
15
+ @tag:clicked="onTagClicked">
16
+ <template #start>
17
+ <a
18
+ href="#"
19
+ @click.prevent="deselect(tag)"
20
+ >[X]</a
21
+ >
22
+ </template>
23
+ </ffun-tag>
24
+ </li>
14
25
  </ul>
15
26
 
16
27
  <input
@@ -21,14 +32,17 @@
21
32
  <ul
22
33
  v-if="displayedTags.length > 0"
23
34
  style="list-style: none; padding: 0; margin: 0">
24
- <tags-filter-element
35
+ <li
36
+ class="filter-element"
25
37
  v-for="tag of displayedTags"
26
- :key="tag"
27
- :tag="tag"
28
- :count="tags[tag]"
29
- :selected="false"
30
- @tag:selected="onTagSelected"
31
- @tag:deselected="onTagDeselected" />
38
+ :key="tag">
39
+ <ffun-tag
40
+ :uid="tag"
41
+ :count="tags[tag]"
42
+ count-mode="prefix"
43
+ :mode="null"
44
+ @tag:clicked="onTagClicked" />
45
+ </li>
32
46
  </ul>
33
47
 
34
48
  <hr />
@@ -45,11 +59,15 @@
45
59
  <script lang="ts" setup>
46
60
  import {computed, ref} from "vue";
47
61
  import {useTagsStore} from "@/stores/tags";
48
-
62
+ import type * as tagsFilterState from "@/logic/tagsFilterState";
49
63
  const tagsStore = useTagsStore();
50
64
 
51
65
  const selectedTags = ref<{[key: string]: boolean}>({});
52
66
 
67
+ const tagStates = ref<{[key: string]: tagsFilterState.State}>({});
68
+
69
+ const emit = defineEmits(["tag:stateChanged"]);
70
+
53
71
  const properties = defineProps<{tags: {[key: string]: number}}>();
54
72
 
55
73
  const showFromStart = ref(25);
@@ -128,12 +146,30 @@
128
146
  return values;
129
147
  });
130
148
 
131
- function onTagSelected(tag: string) {
132
- selectedTags.value[tag] = true;
149
+ function onTagClicked(tag: string) {
150
+ const state = tagStates.value[tag] || "none";
151
+
152
+ if (state === "none") {
153
+ tagStates.value[tag] = "required";
154
+ selectedTags.value[tag] = true;
155
+ } else if (state === "required") {
156
+ tagStates.value[tag] = "excluded";
157
+ selectedTags.value[tag] = true;
158
+ } else if (state === "excluded") {
159
+ tagStates.value[tag] = "required";
160
+ selectedTags.value[tag] = true;
161
+ } else {
162
+ throw new Error(`Unknown tag state: ${state}`);
163
+ }
164
+
165
+ emit("tag:stateChanged", {tag: tag, state: tagStates.value[tag]});
133
166
  }
134
167
 
135
- function onTagDeselected(tag: string) {
168
+ function deselect(tag: string) {
136
169
  selectedTags.value[tag] = false;
170
+ tagStates.value[tag] = "none";
171
+
172
+ emit("tag:stateChanged", {tag: tag, state: tagStates.value[tag]});
137
173
  }
138
174
  </script>
139
175
 
@@ -10,7 +10,7 @@
10
10
  :key="tag"
11
11
  :uid="tag"
12
12
  :mode="tagMode(tag)"
13
- :count="entriesStore.reportTagsCount[tag]"
13
+ :count="tagsCount[tag]"
14
14
  count-mode="tooltip"
15
15
  @tag:clicked="onTagClicked" />
16
16
 
@@ -32,16 +32,17 @@
32
32
 
33
33
  <script lang="ts" setup>
34
34
  import {computed, ref} from "vue";
35
- import {useEntriesStore} from "@/stores/entries";
36
-
37
- const entriesStore = useEntriesStore();
38
35
 
39
36
  const showAll = ref(false);
40
37
  const showLimit = ref(5);
41
38
 
42
39
  const selectedTags = ref<{[key: string]: boolean}>({});
43
40
 
44
- const properties = defineProps<{tags: string[]; contributions: {[key: string]: number}}>();
41
+ const properties = defineProps<{
42
+ tags: string[];
43
+ contributions: {[key: string]: number};
44
+ tagsCount: {[key: string]: number};
45
+ }>();
45
46
 
46
47
  const tagsNumber = computed(() => {
47
48
  return properties.tags.length;
@@ -100,8 +101,8 @@
100
101
  return 1;
101
102
  }
102
103
 
103
- const aCount = entriesStore.reportTagsCount[a];
104
- const bCount = entriesStore.reportTagsCount[b];
104
+ const aCount = properties.tagsCount[a];
105
+ const bCount = properties.tagsCount[b];
105
106
 
106
107
  if (aCount > bCount) {
107
108
  return -1;
@@ -0,0 +1,54 @@
1
+ export type State = "required" | "excluded" | "none";
2
+
3
+ interface ReturnTagsForEntity {
4
+ (entity: any): string[];
5
+ }
6
+
7
+ export class Storage {
8
+ requiredTags: {[key: string]: boolean};
9
+ excludedTags: {[key: string]: boolean};
10
+
11
+ constructor() {
12
+ this.requiredTags = {};
13
+ this.excludedTags = {};
14
+ }
15
+
16
+ onTagStateChanged({tag, state}: {tag: string; state: State}) {
17
+ if (state === "required") {
18
+ this.requiredTags[tag] = true;
19
+ this.excludedTags[tag] = false;
20
+ } else if (state === "excluded") {
21
+ this.excludedTags[tag] = true;
22
+ this.requiredTags[tag] = false;
23
+ } else if (state === "none") {
24
+ this.excludedTags[tag] = false;
25
+ this.requiredTags[tag] = false;
26
+ } else {
27
+ throw new Error(`Unknown tag state: ${state}`);
28
+ }
29
+ }
30
+
31
+ filterByTags(entities: any[], getTags: ReturnTagsForEntity) {
32
+ let report = entities.slice();
33
+
34
+ report = report.filter((entity) => {
35
+ for (const tag of getTags(entity)) {
36
+ if (this.excludedTags[tag]) {
37
+ return false;
38
+ }
39
+ }
40
+ return true;
41
+ });
42
+
43
+ report = report.filter((entity) => {
44
+ for (const tag of Object.keys(this.requiredTags)) {
45
+ if (this.requiredTags[tag] && !getTags(entity).includes(tag)) {
46
+ return false;
47
+ }
48
+ }
49
+ return true;
50
+ });
51
+
52
+ return report;
53
+ }
54
+ }
@@ -57,3 +57,13 @@ export function compareLexicographically(a: string[], b: string[]) {
57
57
 
58
58
  return 0;
59
59
  }
60
+
61
+ export function faviconForUrl(url: string): string | null {
62
+ try {
63
+ const parsedUrl = new URL(url);
64
+ return `${parsedUrl.protocol}//${parsedUrl.host}/favicon.ico`;
65
+ } catch (error) {
66
+ console.error("Invalid URL:", error);
67
+ return null;
68
+ }
69
+ }
package/src/main.ts CHANGED
@@ -15,7 +15,6 @@ import EntryForList from "./components/EntryForList.vue";
15
15
  import RuleConstructor from "./components/RuleConstructor.vue";
16
16
  import RuleScoreUpdater from "./components/RuleScoreUpdater.vue";
17
17
  import TagsFilter from "./components/TagsFilter.vue";
18
- import TagsFilterElement from "./components/TagsFilterElement.vue";
19
18
  import DiscoveryForm from "./components/DiscoveryForm.vue";
20
19
  import FeedInfo from "./components/FeedInfo.vue";
21
20
  import EntryInfo from "./components/EntryInfo.vue";
@@ -28,6 +27,7 @@ import FfunTag from "./components/FfunTag.vue";
28
27
  import SimplePagination from "./components/SimplePagination.vue";
29
28
  import UserSetting from "./components/UserSetting.vue";
30
29
  import OpenaiTokensUsage from "./components/OpenaiTokensUsage.vue";
30
+ import FaviconElement from "./components/FaviconElement.vue";
31
31
 
32
32
  import ScoreSelector from "./inputs/ScoreSelector.vue";
33
33
  import InputMarker from "./inputs/Marker.vue";
@@ -57,7 +57,6 @@ app.component("EntryForList", EntryForList);
57
57
  app.component("RuleConstructor", RuleConstructor);
58
58
  app.component("RuleScoreUpdater", RuleScoreUpdater);
59
59
  app.component("TagsFilter", TagsFilter);
60
- app.component("TagsFilterElement", TagsFilterElement);
61
60
  app.component("DiscoveryForm", DiscoveryForm);
62
61
  app.component("FeedInfo", FeedInfo);
63
62
  app.component("EntryInfo", EntryInfo);
@@ -70,6 +69,7 @@ app.component("FfunTag", FfunTag);
70
69
  app.component("SimplePagination", SimplePagination);
71
70
  app.component("UserSetting", UserSetting);
72
71
  app.component("OpenaiTokensUsage", OpenaiTokensUsage);
72
+ app.component("FaviconElement", FaviconElement);
73
73
 
74
74
  app.component("ScoreSelector", ScoreSelector);
75
75
  app.component("InputMarker", InputMarker);
@@ -15,9 +15,6 @@ export const useEntriesStore = defineStore("entriesStore", () => {
15
15
  const entries = ref<{[key: t.EntryId]: t.Entry}>({});
16
16
  const requestedEntries = ref<{[key: t.EntryId]: boolean}>({});
17
17
 
18
- const requiredTags = ref<{[key: string]: boolean}>({});
19
- const excludedTags = ref<{[key: string]: boolean}>({});
20
-
21
18
  const firstTimeEntriesLoading = ref(true);
22
19
 
23
20
  function registerEntry(entry: t.Entry) {
@@ -58,89 +55,6 @@ export const useEntriesStore = defineStore("entriesStore", () => {
58
55
  return report;
59
56
  }, []);
60
57
 
61
- const entriesReport = computedAsync(async () => {
62
- let report = loadedEntriesReport.value.slice();
63
-
64
- if (!globalSettings.showRead) {
65
- report = report.filter((entryId) => {
66
- return !entries.value[entryId].hasMarker(e.Marker.Read);
67
- });
68
- }
69
-
70
- report = report.filter((entryId) => {
71
- for (const tag of entries.value[entryId].tags) {
72
- if (excludedTags.value[tag]) {
73
- return false;
74
- }
75
- }
76
- return true;
77
- });
78
-
79
- report = report.filter((entryId) => {
80
- for (const tag of Object.keys(requiredTags.value)) {
81
- if (requiredTags.value[tag] && !entries.value[entryId].tags.includes(tag)) {
82
- return false;
83
- }
84
- }
85
- return true;
86
- });
87
-
88
- report = report.sort((a: t.EntryId, b: t.EntryId) => {
89
- const orderProperties = e.EntriesOrderProperties.get(globalSettings.entriesOrder);
90
-
91
- if (orderProperties === undefined) {
92
- throw new Error(`Unknown order ${globalSettings.entriesOrder}`);
93
- }
94
-
95
- const field = orderProperties.orderField;
96
-
97
- const valueA = _.get(entries.value[a], field, null);
98
- const valueB = _.get(entries.value[b], field, null);
99
-
100
- if (valueA === null && valueB === null) {
101
- return 0;
102
- }
103
-
104
- if (valueA === null) {
105
- return 1;
106
- }
107
-
108
- if (valueB === null) {
109
- return -1;
110
- }
111
-
112
- if (valueA < valueB) {
113
- return 1;
114
- }
115
-
116
- if (valueA > valueB) {
117
- return -1;
118
- }
119
-
120
- return 0;
121
- });
122
-
123
- return report;
124
- }, []);
125
-
126
- const reportTagsCount = computed(() => {
127
- const tagsCount: {[key: string]: number} = {};
128
-
129
- for (const entryId of entriesReport.value) {
130
- const entry = entries.value[entryId];
131
-
132
- for (const tag of entry.tags) {
133
- if (tag in tagsCount) {
134
- tagsCount[tag] += 1;
135
- } else {
136
- tagsCount[tag] = 1;
137
- }
138
- }
139
- }
140
-
141
- return tagsCount;
142
- });
143
-
144
58
  function requestFullEntry({entryId}: {entryId: t.EntryId}) {
145
59
  if (entryId in entries.value && entries.value[entryId].body !== null) {
146
60
  return;
@@ -185,33 +99,12 @@ export const useEntriesStore = defineStore("entriesStore", () => {
185
99
  }
186
100
  }
187
101
 
188
- function requireTag({tag}: {tag: string}) {
189
- requiredTags.value[tag] = true;
190
- excludedTags.value[tag] = false;
191
- }
192
-
193
- function excludeTag({tag}: {tag: string}) {
194
- excludedTags.value[tag] = true;
195
- requiredTags.value[tag] = false;
196
- }
197
-
198
- function resetTag({tag}: {tag: string}) {
199
- excludedTags.value[tag] = false;
200
- requiredTags.value[tag] = false;
201
- }
202
-
203
102
  return {
204
103
  entries,
205
- entriesReport,
206
- reportTagsCount,
207
104
  requestFullEntry,
208
105
  setMarker,
209
106
  removeMarker,
210
- requireTag,
211
- excludeTag,
212
- resetTag,
213
- requiredTags,
214
- excludedTags,
215
- firstTimeEntriesLoading
107
+ firstTimeEntriesLoading,
108
+ loadedEntriesReport
216
109
  };
217
110
  });
@@ -9,6 +9,9 @@
9
9
  <script lang="ts" setup>
10
10
  import * as api from "@/logic/api";
11
11
  import type * as t from "@/logic/types";
12
+ import {useTagsStore} from "@/stores/tags";
13
+
14
+ const tagsStore = useTagsStore();
12
15
 
13
16
  const properties = defineProps<{value: number; entryId: t.EntryId}>();
14
17
 
@@ -25,7 +28,14 @@
25
28
  const strings = [];
26
29
 
27
30
  for (const rule of rules) {
28
- strings.push(rule.score.toString().padStart(2, " ") + " — " + rule.tags.join(", "));
31
+ const tags = [];
32
+
33
+ for (const tagId of rule.tags) {
34
+ const tagInfo = tagsStore.tags[tagId];
35
+ tags.push(tagInfo.name);
36
+ }
37
+
38
+ strings.push(rule.score.toString().padStart(2, " ") + " — " + tags.join(", "));
29
39
  }
30
40
 
31
41
  alert(strings.join("\n"));
@@ -17,28 +17,50 @@
17
17
 
18
18
  <h2>What is it?</h2>
19
19
 
20
- <ul style="text-align: left">
21
- <li>Web-based news reader. Self-hosted, if it is your way.</li>
22
- <li>Automatically assigns tags to news entries.</li>
20
+ <p>
21
+ <i>Web-based news reader. Self-hosted, if it is your way.</i>
22
+ </p>
23
+
24
+ <ul class="main-page-element">
25
+ <li>Reader automatically assigns tags to news entries.</li>
23
26
  <li>You create rules to score news by tags.</li>
24
- <li>Then filter and sort news how you want.</li>
27
+ <li>Filter and sort news how you want, to read only what you want.</li>
28
+ <li>?????</li>
29
+ <li>Profit.</li>
25
30
  </ul>
26
31
 
27
32
  <h2>You are in control</h2>
28
33
 
29
- <ul style="text-align: left">
34
+ <ul class="main-page-element">
30
35
  <li>No black box recommendation algorithms.</li>
31
36
  <li>No "smart" reordering of your news.</li>
32
37
  <li>No ads.</li>
33
38
  <li>No selling of your data.</li>
34
39
  </ul>
35
40
 
36
- <h2>What will it become?</h2>
41
+ <h2>Screenshots</h2>
42
+
43
+ <p><i>GUI is still in the early development stage, like the whole project. It will become more pleasurable.</i></p>
44
+
45
+ <img
46
+ src="/news-filtering-example.png"
47
+ alt="News filtering example" />
48
+
49
+ <p class="main-page-element"><strong>Explanation</strong></p>
50
+
51
+ <ul class="main-page-element">
52
+ <li>From the news for the last week, sorted by score.</li>
53
+ <li>Show only news about <code>game-development</code> from <code>reddit.com</code>.</li>
54
+ <li>Exclude news related to <code>employment</code>.</li>
55
+ <li>Hide already read news.</li>
56
+ </ul>
57
+
58
+ <p class="main-page-element"><strong>Tags sorting for news records</strong></p>
37
59
 
38
- <ul style="text-align: left">
39
- <li>Will become much prettier.</li>
40
- <li> Will collect not only RSS/ATOM but any news feeds, including private if you allow. </li>
41
- <li>Will suggest new rules to score & new feeds, if you allow.</li>
60
+ <ul class="main-page-element">
61
+ <li>Tags are sorted by the impact on the score.</li>
62
+ <li>Green tags have a positive impact.</li>
63
+ <li>Red tags have a negative impact.</li>
42
64
  </ul>
43
65
  </wide-layout>
44
66
  </template>
@@ -64,4 +86,11 @@
64
86
  }
65
87
  </script>
66
88
 
67
- <style></style>
89
+ <style>
90
+ .main-page-element {
91
+ text-align: left;
92
+ margin-left: auto;
93
+ max-width: 27rem;
94
+ margin-right: auto;
95
+ }
96
+ </style>
@@ -32,8 +32,8 @@
32
32
 
33
33
  <template #side-footer>
34
34
  <tags-filter
35
- v-if="globalSettings.mainPanelMode == e.MainPanelMode.Entries"
36
- :tags="entriesStore.reportTagsCount" />
35
+ :tags="tagsCount"
36
+ @tag:stateChanged="onTagStateChanged" />
37
37
  </template>
38
38
 
39
39
  <template #main-header>
@@ -50,9 +50,10 @@
50
50
  </template>
51
51
 
52
52
  <entries-list
53
- :entriesIds="entriesStore.entriesReport"
53
+ :entriesIds="entriesReport"
54
54
  :time-field="timeField"
55
55
  :show-tags="globalSettings.showEntriesTags"
56
+ :tags-count="tagsCount"
56
57
  :showFromStart="25"
57
58
  :showPerPage="25" />
58
59
  </side-panel-layout>
@@ -62,20 +63,91 @@
62
63
  import {computed, ref, onUnmounted, watch} from "vue";
63
64
  import {computedAsync} from "@vueuse/core";
64
65
  import * as api from "@/logic/api";
65
- import * as t from "@/logic/types";
66
+ import * as tagsFilterState from "@/logic/tagsFilterState";
66
67
  import * as e from "@/logic/enums";
68
+ import type * as t from "@/logic/types";
67
69
  import {useGlobalSettingsStore} from "@/stores/globalSettings";
68
70
  import {useEntriesStore} from "@/stores/entries";
71
+ import _ from "lodash";
69
72
 
70
73
  const globalSettings = useGlobalSettingsStore();
71
74
  const entriesStore = useEntriesStore();
72
75
 
76
+ const tagsStates = ref<tagsFilterState.Storage>(new tagsFilterState.Storage());
77
+
73
78
  globalSettings.mainPanelMode = e.MainPanelMode.Entries;
74
79
 
75
80
  globalSettings.updateDataVersion();
76
81
 
82
+ const entriesReport = computed(() => {
83
+ let report = entriesStore.loadedEntriesReport.slice();
84
+
85
+ if (!globalSettings.showRead) {
86
+ report = report.filter((entryId) => {
87
+ return !entriesStore.entries[entryId].hasMarker(e.Marker.Read);
88
+ });
89
+ }
90
+
91
+ report = tagsStates.value.filterByTags(report, (entryId) => entriesStore.entries[entryId].tags);
92
+
93
+ report = report.sort((a: t.EntryId, b: t.EntryId) => {
94
+ const orderProperties = e.EntriesOrderProperties.get(globalSettings.entriesOrder);
95
+
96
+ if (orderProperties === undefined) {
97
+ throw new Error(`Unknown order ${globalSettings.entriesOrder}`);
98
+ }
99
+
100
+ const field = orderProperties.orderField;
101
+
102
+ const valueA = _.get(entriesStore.entries[a], field, null);
103
+ const valueB = _.get(entriesStore.entries[b], field, null);
104
+
105
+ if (valueA === null && valueB === null) {
106
+ return 0;
107
+ }
108
+
109
+ if (valueA === null) {
110
+ return 1;
111
+ }
112
+
113
+ if (valueB === null) {
114
+ return -1;
115
+ }
116
+
117
+ if (valueA < valueB) {
118
+ return 1;
119
+ }
120
+
121
+ if (valueA > valueB) {
122
+ return -1;
123
+ }
124
+
125
+ return 0;
126
+ });
127
+
128
+ return report;
129
+ });
130
+
131
+ const tagsCount = computed(() => {
132
+ const tagsCount: {[key: string]: number} = {};
133
+
134
+ for (const entryId of entriesReport.value) {
135
+ const entry = entriesStore.entries[entryId];
136
+
137
+ for (const tag of entry.tags) {
138
+ if (tag in tagsCount) {
139
+ tagsCount[tag] += 1;
140
+ } else {
141
+ tagsCount[tag] = 1;
142
+ }
143
+ }
144
+ }
145
+
146
+ return tagsCount;
147
+ });
148
+
77
149
  const entriesNumber = computed(() => {
78
- return entriesStore.entriesReport.length;
150
+ return entriesReport.value.length;
79
151
  });
80
152
 
81
153
  const hasEntries = computed(() => {
@@ -91,6 +163,10 @@
91
163
 
92
164
  return orderProperties.timeField;
93
165
  });
166
+
167
+ function onTagStateChanged({tag, state}: {tag: string; state: tagsFilterState.State}) {
168
+ tagsStates.value.onTagStateChanged({tag, state});
169
+ }
94
170
  </script>
95
171
 
96
172
  <style></style>
@@ -12,6 +12,12 @@
12
12
  v-model:property="globalSettings.rulesOrder" />
13
13
  </template>
14
14
 
15
+ <template #side-footer>
16
+ <tags-filter
17
+ :tags="tags"
18
+ @tag:stateChanged="onTagStateChanged" />
19
+ </template>
20
+
15
21
  <rules-list
16
22
  v-if="rules"
17
23
  :rules="sortedRules" />
@@ -27,6 +33,8 @@
27
33
  import * as api from "@/logic/api";
28
34
  import type * as t from "@/logic/types";
29
35
  import * as e from "@/logic/enums";
36
+ import * as tagsFilterState from "@/logic/tagsFilterState";
37
+ const tagsStates = ref<tagsFilterState.Storage>(new tagsFilterState.Storage());
30
38
 
31
39
  const globalSettings = useGlobalSettingsStore();
32
40
 
@@ -38,11 +46,31 @@
38
46
  return await api.getRules();
39
47
  }, null);
40
48
 
49
+ const tags = computed(() => {
50
+ if (!rules.value) {
51
+ return {};
52
+ }
53
+
54
+ const tags: {[key: string]: number} = {};
55
+
56
+ for (const rule of rules.value) {
57
+ for (const tag of rule.tags) {
58
+ tags[tag] = (tags[tag] || 0) + 1;
59
+ }
60
+ }
61
+
62
+ return tags;
63
+ });
64
+
41
65
  const sortedRules = computed(() => {
42
66
  if (!rules.value) {
43
67
  return null;
44
68
  }
45
69
 
70
+ let sorted = rules.value.slice();
71
+
72
+ sorted = tagsStates.value.filterByTags(sorted, (rule) => rule.tags);
73
+
46
74
  const orderProperties = e.RulesOrderProperties.get(globalSettings.rulesOrder);
47
75
 
48
76
  if (!orderProperties) {
@@ -56,8 +84,6 @@
56
84
  throw new Error(`Invalid order direction: ${orderProperties.orderDirection}`);
57
85
  }
58
86
 
59
- let sorted = rules.value.slice();
60
-
61
87
  sorted = sorted.sort((a: t.Rule, b: t.Rule) => {
62
88
  if (globalSettings.rulesOrder === e.RulesOrder.Tags) {
63
89
  return utils.compareLexicographically(a.tags, b.tags);
@@ -91,6 +117,10 @@
91
117
 
92
118
  return sorted;
93
119
  });
120
+
121
+ function onTagStateChanged({tag, state}: {tag: string; state: tagsFilterState.State}) {
122
+ tagsStates.value.onTagStateChanged({tag, state});
123
+ }
94
124
  </script>
95
125
 
96
126
  <style></style>
@@ -1,89 +0,0 @@
1
- <template>
2
- <li class="filter-element">
3
- <ffun-tag
4
- :uid="tag"
5
- :count="count"
6
- :count-mode="countMode"
7
- :mode="mode"
8
- @tag:clicked="onTagClicked">
9
- <template #start>
10
- <a
11
- v-if="selected"
12
- href="#"
13
- @click.prevent="deselect(tag)"
14
- >[X]</a
15
- >
16
- </template>
17
- </ffun-tag>
18
- </li>
19
- </template>
20
-
21
- <script lang="ts" setup>
22
- import {computed, ref} from "vue";
23
- import {useEntriesStore} from "@/stores/entries";
24
-
25
- const entriesStore = useEntriesStore();
26
-
27
- const properties = defineProps<{
28
- tag: string;
29
- count: number;
30
- selected: boolean;
31
- }>();
32
-
33
- const emit = defineEmits(["tag:selected", "tag:deselected"]);
34
-
35
- const countMode = computed(() => {
36
- if (properties.selected) {
37
- return "no";
38
- }
39
-
40
- return "prefix";
41
- });
42
-
43
- const mode = computed(() => {
44
- if (entriesStore.requiredTags[properties.tag]) {
45
- return "required";
46
- } else if (entriesStore.excludedTags[properties.tag]) {
47
- return "excluded";
48
- } else if (properties.selected) {
49
- return "selected";
50
- }
51
-
52
- return null;
53
- });
54
-
55
- function onTagClicked(tag: string) {
56
- if (entriesStore.requiredTags[properties.tag]) {
57
- switchToExcluded(tag);
58
- } else {
59
- switchToRequired(tag);
60
- }
61
- }
62
-
63
- function switchToExcluded(tag: string) {
64
- entriesStore.excludeTag({tag: tag});
65
- emit("tag:selected", properties.tag);
66
- }
67
-
68
- function switchToRequired(tag: string) {
69
- entriesStore.requireTag({tag: tag});
70
- emit("tag:selected", properties.tag);
71
- }
72
-
73
- function deselect(tag: string) {
74
- entriesStore.resetTag({tag: tag});
75
- emit("tag:deselected", properties.tag);
76
- }
77
- </script>
78
-
79
- <style scoped>
80
- .filter-element {
81
- overflow: hidden;
82
- white-space: nowrap;
83
- }
84
-
85
- .filter-element value-tag {
86
- overflow: hidden;
87
- text-overflow: ellipsis;
88
- }
89
- </style>