feeds-fun 0.2.0 → 0.4.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.2.0",
3
+ "version": "0.4.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"
@@ -41,7 +43,10 @@
41
43
 
42
44
  <template v-if="showTags">
43
45
  <br />
44
- <tags-list :tags="entry.tags" />
46
+ <tags-list
47
+ :tags="entry.tags"
48
+ :tags-count="tagsCount"
49
+ :contributions="entry.scoreContributions" />
45
50
  </template>
46
51
 
47
52
  <div
@@ -80,6 +85,7 @@
80
85
  entryId: t.EntryId;
81
86
  timeField: string;
82
87
  showTags: boolean;
88
+ tagsCount: {[key: string]: number};
83
89
  }>();
84
90
 
85
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"
@@ -77,7 +77,8 @@
77
77
  .tag {
78
78
  display: inline-block;
79
79
  cursor: pointer;
80
- padding: 0.25rem;
80
+ padding: 0.1rem;
81
+ margin-right: 0.2rem;
81
82
  white-space: nowrap;
82
83
  }
83
84
 
@@ -92,4 +93,12 @@
92
93
  .tag.excluded {
93
94
  background-color: #ffcccc;
94
95
  }
96
+
97
+ .tag.positive {
98
+ color: darkgreen;
99
+ }
100
+
101
+ .tag.negative {
102
+ color: darkred;
103
+ }
95
104
  </style>
@@ -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
 
@@ -9,8 +9,8 @@
9
9
  v-for="tag of displayedTags"
10
10
  :key="tag"
11
11
  :uid="tag"
12
- :mode="!!selectedTags[tag] ? 'selected' : null"
13
- :count="entriesStore.reportTagsCount[tag]"
12
+ :mode="tagMode(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[]}>();
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;
@@ -55,6 +56,32 @@
55
56
  return preparedTags.value.slice(0, showLimit.value);
56
57
  });
57
58
 
59
+ function tagMode(tag: string) {
60
+ if (!!selectedTags.value[tag]) {
61
+ return "selected";
62
+ }
63
+
64
+ // return null;
65
+
66
+ if (!properties.contributions) {
67
+ return null;
68
+ }
69
+
70
+ if (!(tag in properties.contributions)) {
71
+ return null;
72
+ }
73
+
74
+ if (properties.contributions[tag] == 0) {
75
+ return null;
76
+ }
77
+
78
+ if (properties.contributions[tag] > 0) {
79
+ return "positive";
80
+ }
81
+
82
+ return "negative";
83
+ }
84
+
58
85
  const preparedTags = computed(() => {
59
86
  const values = [];
60
87
 
@@ -63,8 +90,19 @@
63
90
  }
64
91
 
65
92
  values.sort((a, b) => {
66
- const aCount = entriesStore.reportTagsCount[a];
67
- const bCount = entriesStore.reportTagsCount[b];
93
+ const aContributions = Math.abs(properties.contributions[a] || 0);
94
+ const bContributions = Math.abs(properties.contributions[b] || 0);
95
+
96
+ if (aContributions > bContributions) {
97
+ return -1;
98
+ }
99
+
100
+ if (aContributions < bContributions) {
101
+ return 1;
102
+ }
103
+
104
+ const aCount = properties.tagsCount[a];
105
+ const bCount = properties.tagsCount[b];
68
106
 
69
107
  if (aCount > bCount) {
70
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
+ }
@@ -114,6 +114,7 @@ export class Entry {
114
114
  readonly tags: string[];
115
115
  readonly markers: e.Marker[];
116
116
  readonly score: number;
117
+ readonly scoreContributions: {[key: string]: number};
117
118
  readonly scoreToZero: number;
118
119
  readonly publishedAt: Date;
119
120
  readonly catalogedAt: Date;
@@ -127,6 +128,7 @@ export class Entry {
127
128
  tags,
128
129
  markers,
129
130
  score,
131
+ scoreContributions,
130
132
  publishedAt,
131
133
  catalogedAt,
132
134
  body
@@ -138,6 +140,7 @@ export class Entry {
138
140
  tags: string[];
139
141
  markers: e.Marker[];
140
142
  score: number;
143
+ scoreContributions: {[key: string]: number};
141
144
  publishedAt: Date;
142
145
  catalogedAt: Date;
143
146
  body: string | null;
@@ -149,6 +152,7 @@ export class Entry {
149
152
  this.tags = tags;
150
153
  this.markers = markers;
151
154
  this.score = score;
155
+ this.scoreContributions = scoreContributions;
152
156
  this.publishedAt = publishedAt;
153
157
  this.catalogedAt = catalogedAt;
154
158
  this.body = body;
@@ -181,6 +185,7 @@ export function entryFromJSON({
181
185
  tags,
182
186
  markers,
183
187
  score,
188
+ scoreContributions,
184
189
  publishedAt,
185
190
  catalogedAt,
186
191
  body
@@ -192,6 +197,7 @@ export function entryFromJSON({
192
197
  tags: string[];
193
198
  markers: string[];
194
199
  score: number;
200
+ scoreContributions: {[key: string]: number};
195
201
  publishedAt: string;
196
202
  catalogedAt: string;
197
203
  body: string | null;
@@ -210,6 +216,7 @@ export function entryFromJSON({
210
216
  throw new Error(`Unknown marker: ${m}`);
211
217
  }),
212
218
  score: score,
219
+ scoreContributions: scoreContributions,
213
220
  publishedAt: new Date(publishedAt),
214
221
  catalogedAt: new Date(catalogedAt),
215
222
  body: body
@@ -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
  });
@@ -36,7 +36,7 @@
36
36
  .score {
37
37
  display: inline-block;
38
38
  cursor: pointer;
39
- padding: 0.25rem;
39
+ padding: 0.1rem;
40
40
  background-color: #c1c1ff;
41
41
  }
42
42
  </style>
@@ -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);
@@ -67,7 +93,7 @@
67
93
  const valueB = _.get(b, orderField, null);
68
94
 
69
95
  if (valueA === null && valueB === null) {
70
- return 0;
96
+ return utils.compareLexicographically(a.tags, b.tags);
71
97
  }
72
98
 
73
99
  if (valueA === null) {
@@ -86,11 +112,15 @@
86
112
  return -1 * direction;
87
113
  }
88
114
 
89
- return 0;
115
+ return utils.compareLexicographically(a.tags, b.tags);
90
116
  });
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>