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 +1 -1
- package/public/favicon.ico +0 -0
- package/public/news-filtering-example.png +0 -0
- package/public/no-favicon.ico +0 -0
- package/src/components/EntriesList.vue +3 -1
- package/src/components/EntryForList.vue +11 -5
- package/src/components/FaviconElement.vue +34 -0
- package/src/components/FeedForList.vue +4 -0
- package/src/components/FfunTag.vue +10 -1
- package/src/components/TagsFilter.vue +54 -18
- package/src/components/TagsList.vue +46 -8
- package/src/logic/tagsFilterState.ts +54 -0
- package/src/logic/types.ts +7 -0
- package/src/logic/utils.ts +10 -0
- package/src/main.ts +2 -2
- package/src/stores/entries.ts +2 -109
- package/src/values/Score.vue +1 -1
- package/src/views/MainView.vue +40 -11
- package/src/views/NewsView.vue +81 -5
- package/src/views/RulesView.vue +34 -4
- package/src/components/TagsFilterElement.vue +0 -89
package/package.json
CHANGED
package/public/favicon.ico
CHANGED
|
Binary file
|
|
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
|
>▼</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
|
>▲</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
|
|
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>
|
|
@@ -77,7 +77,8 @@
|
|
|
77
77
|
.tag {
|
|
78
78
|
display: inline-block;
|
|
79
79
|
cursor: pointer;
|
|
80
|
-
padding: 0.
|
|
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
|
-
<
|
|
6
|
+
<li
|
|
7
|
+
class="filter-element"
|
|
7
8
|
v-for="tag of displayedSelectedTags"
|
|
8
|
-
:key="tag"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
<
|
|
35
|
+
<li
|
|
36
|
+
class="filter-element"
|
|
25
37
|
v-for="tag of displayedTags"
|
|
26
|
-
:key="tag"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
132
|
-
|
|
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
|
|
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="
|
|
13
|
-
:count="
|
|
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<{
|
|
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
|
|
67
|
-
const
|
|
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
|
+
}
|
package/src/logic/types.ts
CHANGED
|
@@ -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
|
package/src/logic/utils.ts
CHANGED
|
@@ -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);
|
package/src/stores/entries.ts
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
resetTag,
|
|
213
|
-
requiredTags,
|
|
214
|
-
excludedTags,
|
|
215
|
-
firstTimeEntriesLoading
|
|
107
|
+
firstTimeEntriesLoading,
|
|
108
|
+
loadedEntriesReport
|
|
216
109
|
};
|
|
217
110
|
});
|
package/src/values/Score.vue
CHANGED
package/src/views/MainView.vue
CHANGED
|
@@ -17,28 +17,50 @@
|
|
|
17
17
|
|
|
18
18
|
<h2>What is it?</h2>
|
|
19
19
|
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
|
|
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>
|
|
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
|
|
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>
|
|
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
|
|
39
|
-
<li>
|
|
40
|
-
<li>
|
|
41
|
-
<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
|
|
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>
|
package/src/views/NewsView.vue
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
|
|
33
33
|
<template #side-footer>
|
|
34
34
|
<tags-filter
|
|
35
|
-
|
|
36
|
-
:
|
|
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="
|
|
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
|
|
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
|
|
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>
|
package/src/views/RulesView.vue
CHANGED
|
@@ -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
|
|
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
|
|
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>
|