feeds-fun 0.3.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 +8 -4
- package/src/components/FaviconElement.vue +34 -0
- package/src/components/FeedForList.vue +4 -0
- package/src/components/TagsFilter.vue +54 -18
- package/src/components/TagsList.vue +8 -7
- package/src/logic/tagsFilterState.ts +54 -0
- package/src/logic/utils.ts +10 -0
- package/src/main.ts +2 -2
- package/src/stores/entries.ts +2 -109
- package/src/views/MainView.vue +40 -11
- package/src/views/NewsView.vue +81 -5
- package/src/views/RulesView.vue +32 -2
- 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"
|
|
@@ -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>
|
|
@@ -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
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
:key="tag"
|
|
11
11
|
:uid="tag"
|
|
12
12
|
:mode="tagMode(tag)"
|
|
13
|
-
:count="
|
|
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;
|
|
@@ -100,8 +101,8 @@
|
|
|
100
101
|
return 1;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
const aCount =
|
|
104
|
-
const bCount =
|
|
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
|
+
}
|
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/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);
|
|
@@ -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>
|