feeds-fun 1.27.1 → 1.28.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 +3 -1
- package/src/components/FeedForList.vue +203 -64
- package/src/components/FeedsList.vue +24 -2
- package/src/components/body_list/EntryBody.vue +2 -0
- package/src/components/feed_list/Columns.vue +27 -0
- package/src/components/feed_list/FeedEntriesPerDayChart.vue +219 -0
- package/src/logic/api.ts +13 -0
- package/src/logic/enums.ts +2 -0
- package/src/logic/tests/types.test.ts +80 -1
- package/src/logic/types.ts +39 -8
- package/src/stores/feeds.ts +112 -16
- package/src/stores/globalSettings.ts +0 -2
- package/src/stores/tests/feeds.test.ts +161 -0
- package/src/views/FeedsView.vue +2 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feeds-fun",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.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": [
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@tabler/icons-vue": "^3.31.0",
|
|
37
37
|
"@vueuse/core": "^11.2.0",
|
|
38
38
|
"axios": "^1.7.7",
|
|
39
|
+
"chart.js": "^4.5.1",
|
|
39
40
|
"dompurify": "^3.1.7",
|
|
40
41
|
"lodash": "^4.17.21",
|
|
41
42
|
"neverthrow": "^8.2.0",
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
"set-interval-async": "^3.0.3",
|
|
44
45
|
"vanilla-cookieconsent": "^3.1.0",
|
|
45
46
|
"vue": "^3.5.12",
|
|
47
|
+
"vue-chartjs": "^5.3.3",
|
|
46
48
|
"vue-router": "^4.4.5"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
@@ -1,92 +1,231 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
<
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
:time="feed.linkedAt" />
|
|
22
|
-
|
|
23
|
-
<div class="ffun-body-list-icon-column ml-3">
|
|
24
|
-
<icon
|
|
25
|
-
v-if="feed.isOk"
|
|
26
|
-
title="everything is ok"
|
|
27
|
-
icon="face-smile"
|
|
28
|
-
class="text-green-700" />
|
|
29
|
-
<icon
|
|
30
|
-
v-else
|
|
31
|
-
:title="feed.lastError || 'unknown error'"
|
|
32
|
-
icon="face-sad"
|
|
33
|
-
class="text-red-700" />
|
|
34
|
-
</div>
|
|
35
|
-
|
|
36
|
-
<body-list-favicon-column :url="feed.url" />
|
|
37
|
-
|
|
38
|
-
<div class="flex-grow">
|
|
39
|
-
<external-url
|
|
40
|
-
class="ffun-normal-link"
|
|
41
|
-
:url="feed.url"
|
|
42
|
-
:text="purifiedTitle" />
|
|
43
|
-
|
|
44
|
-
<template v-if="feed.collectionIds.length > 0">
|
|
45
|
-
<span v-for="(collectionId, index) in feed.collectionIds">
|
|
46
|
-
<template v-if="collectionId in collections.collections">
|
|
47
|
-
<br />
|
|
48
|
-
Collections:
|
|
49
|
-
<span class="text-green-700 font-bold">{{ collections.collections[collectionId].name }}</span
|
|
50
|
-
><span v-if="index < feed.collectionIds.length - 1">, </span>
|
|
51
|
-
</template>
|
|
3
|
+
ref="feedTop"
|
|
4
|
+
v-if="feed !== null">
|
|
5
|
+
<feed-list-columns class="text-lg">
|
|
6
|
+
<template #action>
|
|
7
|
+
<a
|
|
8
|
+
href="#"
|
|
9
|
+
class="text-red-500 hover:text-red-600"
|
|
10
|
+
title="Unsubscribe"
|
|
11
|
+
@click.prevent="feedsStore.unsubscribe(feed.id)">
|
|
12
|
+
<icon icon="x" />
|
|
13
|
+
</a>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<template #checked>
|
|
17
|
+
<span
|
|
18
|
+
:class="checkedClass"
|
|
19
|
+
:title="checkedTitle">
|
|
20
|
+
{{ checkedText }}
|
|
52
21
|
</span>
|
|
53
22
|
</template>
|
|
54
|
-
|
|
23
|
+
|
|
24
|
+
<template #added>
|
|
25
|
+
<span title="Added: how long ago you added this feed">
|
|
26
|
+
<value-date-time
|
|
27
|
+
:value="feed.linkedAt"
|
|
28
|
+
:reversed="true" />
|
|
29
|
+
</span>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<template #rate>
|
|
33
|
+
<span
|
|
34
|
+
:class="entriesPerDayClass"
|
|
35
|
+
:title="entriesPerDayTitle">
|
|
36
|
+
{{ feed.entriesPerDay }}/day
|
|
37
|
+
</span>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<template #favicon>
|
|
41
|
+
<favicon-element
|
|
42
|
+
:url="feed.url"
|
|
43
|
+
class="w-5 h-5 mx-1 inline align-text-bottom" />
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<template #main>
|
|
47
|
+
<div
|
|
48
|
+
class="flex min-w-0 cursor-pointer items-baseline gap-2"
|
|
49
|
+
@click="onTitleClick">
|
|
50
|
+
<span
|
|
51
|
+
class="flex-shrink-0 min-w-fit line-clamp-1 mb-0"
|
|
52
|
+
v-html="purifiedTitle" />
|
|
53
|
+
|
|
54
|
+
<span
|
|
55
|
+
v-if="purifiedDescriptionPreview"
|
|
56
|
+
class="min-w-0 flex-1 line-clamp-1 text-sm text-gray-600">
|
|
57
|
+
{{ purifiedDescriptionPreview }}
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
v-if="feed.collectionIds.length > 0"
|
|
63
|
+
class="mt-1 flex min-w-0 flex-wrap gap-1">
|
|
64
|
+
<template
|
|
65
|
+
v-for="collectionId in feed.collectionIds"
|
|
66
|
+
:key="collectionId">
|
|
67
|
+
<span
|
|
68
|
+
v-if="collectionId in collections.collections"
|
|
69
|
+
:title="`This feed is part of the collection: ${collections.collections[collectionId].name}`"
|
|
70
|
+
class="cursor-default rounded border border-green-200 bg-green-50 px-1.5 py-0.5 text-sm font-semibold text-green-700">
|
|
71
|
+
{{ collections.collections[collectionId].name }}
|
|
72
|
+
</span>
|
|
73
|
+
</template>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
</feed-list-columns>
|
|
55
77
|
</div>
|
|
56
78
|
|
|
57
79
|
<body-list-entry-body
|
|
58
|
-
v-if="
|
|
59
|
-
class="
|
|
60
|
-
:url="
|
|
61
|
-
:title="
|
|
62
|
-
:loading="
|
|
63
|
-
:references="
|
|
64
|
-
:text="purifiedDescription"
|
|
80
|
+
v-if="showDescription"
|
|
81
|
+
class="justify-center"
|
|
82
|
+
:url="bodyTitleUrl"
|
|
83
|
+
:title="purifiedTitle"
|
|
84
|
+
:loading="feed.entriesLoadedDetails === null"
|
|
85
|
+
:references="feedReferences"
|
|
86
|
+
:text="purifiedDescription">
|
|
87
|
+
<template
|
|
88
|
+
v-if="feed.entriesLoadedDetails !== null"
|
|
89
|
+
#body-prefix>
|
|
90
|
+
<feed-entries-per-day-chart :entries-loaded-details="feed.entriesLoadedDetails" />
|
|
91
|
+
</template>
|
|
92
|
+
</body-list-entry-body>
|
|
65
93
|
</template>
|
|
66
94
|
|
|
67
95
|
<script lang="ts" setup>
|
|
68
|
-
import {computed,
|
|
69
|
-
import type * as t from "@/logic/types";
|
|
96
|
+
import {computed, useTemplateRef} from "vue";
|
|
70
97
|
import * as e from "@/logic/enums";
|
|
71
|
-
import * as
|
|
98
|
+
import * as t from "@/logic/types";
|
|
72
99
|
import * as utils from "@/logic/utils";
|
|
73
|
-
import {computedAsync} from "@vueuse/core";
|
|
74
|
-
import DOMPurify from "dompurify";
|
|
75
|
-
import {useGlobalSettingsStore} from "@/stores/globalSettings";
|
|
76
100
|
import {useFeedsStore} from "@/stores/feeds";
|
|
77
101
|
import {useCollectionsStore} from "@/stores/collections";
|
|
102
|
+
import FeedListColumns from "@/components/feed_list/Columns.vue";
|
|
103
|
+
import FeedEntriesPerDayChart from "@/components/feed_list/FeedEntriesPerDayChart.vue";
|
|
78
104
|
|
|
79
|
-
const globalSettings = useGlobalSettingsStore();
|
|
80
105
|
const feedsStore = useFeedsStore();
|
|
81
106
|
const collections = useCollectionsStore();
|
|
107
|
+
const topElement = useTemplateRef("feedTop");
|
|
82
108
|
|
|
83
109
|
const properties = defineProps<{feed: t.Feed}>();
|
|
84
110
|
|
|
111
|
+
const showDescription = computed(() => {
|
|
112
|
+
return properties.feed.id == feedsStore.displayedFeedId;
|
|
113
|
+
});
|
|
114
|
+
|
|
85
115
|
const purifiedTitle = computed(() => {
|
|
86
116
|
return utils.purifyTitle({raw: properties.feed.title, default_: properties.feed.url});
|
|
87
117
|
});
|
|
88
118
|
|
|
119
|
+
const purifiedDescriptionPreview = computed(() => {
|
|
120
|
+
if (properties.feed.description === null) {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return utils.purifyTitle({raw: properties.feed.description, default_: ""});
|
|
125
|
+
});
|
|
126
|
+
|
|
89
127
|
const purifiedDescription = computed(() => {
|
|
90
128
|
return utils.purifyBody({raw: properties.feed.description, default_: "No description"});
|
|
91
129
|
});
|
|
130
|
+
|
|
131
|
+
const bodyTitleUrl = computed(() => {
|
|
132
|
+
return properties.feed.siteUrl ?? properties.feed.url;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const feedReferences = computed(() => {
|
|
136
|
+
return [
|
|
137
|
+
new t.Reference({
|
|
138
|
+
kind: e.ReferenceKind.Page,
|
|
139
|
+
url: properties.feed.url,
|
|
140
|
+
title: "feed",
|
|
141
|
+
mimeType: null,
|
|
142
|
+
width: null,
|
|
143
|
+
height: null,
|
|
144
|
+
duration: null,
|
|
145
|
+
size: null,
|
|
146
|
+
extra: null
|
|
147
|
+
})
|
|
148
|
+
];
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const checkedText = computed(() => {
|
|
152
|
+
if (properties.feed.loadedAt === null) {
|
|
153
|
+
return "?";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return utils.timeSince(properties.feed.loadedAt);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const checkedClass = computed(() => {
|
|
160
|
+
if (properties.feed.loadedAt === null) {
|
|
161
|
+
return "text-yellow-700";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (properties.feed.isOk) {
|
|
165
|
+
return "text-green-700";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return "text-red-700";
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const checkedTitle = computed(() => {
|
|
172
|
+
if (properties.feed.loadedAt === null) {
|
|
173
|
+
const title = "Last load: unknown, the feed has no load timestamp.";
|
|
174
|
+
|
|
175
|
+
if (properties.feed.isOk) {
|
|
176
|
+
return `${title} Status: loaded, no recent feed errors.`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return `${title} Status: feed is failing, ${properties.feed.lastError || "unknown error"}.`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (properties.feed.isOk) {
|
|
183
|
+
return "Last load: how long ago news was last loaded from the feed. Status: loaded, no recent feed errors.";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return `Last load: how long ago news was last loaded from the feed. Status: feed is failing, ${properties.feed.lastError || "unknown error"}.`;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const entriesPerDayTitle = computed(() => {
|
|
190
|
+
const title = "News/day: number of news loaded per day over the current statistics window.";
|
|
191
|
+
|
|
192
|
+
if (!properties.feed.young) {
|
|
193
|
+
return title;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return `${title} This feed is young, so statistics may be inaccurate.`;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const entriesPerDayClass = computed(() => {
|
|
200
|
+
return {
|
|
201
|
+
"text-yellow-700": properties.feed.young
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
function onTitleClick(event: MouseEvent) {
|
|
206
|
+
if (!event.ctrlKey) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
|
|
210
|
+
if (showDescription.value) {
|
|
211
|
+
feedsStore.hideFeed({feedId: properties.feed.id});
|
|
212
|
+
} else {
|
|
213
|
+
feedsStore.displayFeed({feedId: properties.feed.id});
|
|
214
|
+
|
|
215
|
+
if (topElement.value) {
|
|
216
|
+
const rect = topElement.value.getBoundingClientRect();
|
|
217
|
+
|
|
218
|
+
const isVisible =
|
|
219
|
+
rect.top >= 0 &&
|
|
220
|
+
rect.left >= 0 &&
|
|
221
|
+
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
|
222
|
+
rect.right <= (window.innerWidth || document.documentElement.clientWidth);
|
|
223
|
+
|
|
224
|
+
if (!isVisible) {
|
|
225
|
+
topElement.value.scrollIntoView({behavior: "instant"});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
92
231
|
</script>
|
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<feed-list-columns class="mb-1 border-b pb-1 text-xs text-gray-500">
|
|
3
|
+
<template #checked>
|
|
4
|
+
<span title="Last load: how long ago news was last loaded from the feed; color shows current feed status">
|
|
5
|
+
Last load
|
|
6
|
+
</span>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<template #added>
|
|
10
|
+
<span title="Subscribed: how long ago you added this feed">Subscribed</span>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<template #rate>
|
|
14
|
+
<span title="News/day: number of news loaded per day">News/day</span>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<template #favicon>
|
|
18
|
+
<span title="Site icon" />
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<template #main>Feed</template>
|
|
22
|
+
</feed-list-columns>
|
|
23
|
+
|
|
2
24
|
<ul>
|
|
3
25
|
<li
|
|
4
26
|
v-for="feed in feeds"
|
|
@@ -10,8 +32,8 @@
|
|
|
10
32
|
</template>
|
|
11
33
|
|
|
12
34
|
<script lang="ts" setup>
|
|
13
|
-
import {computed, ref, onUnmounted, watch} from "vue";
|
|
14
35
|
import type * as t from "@/logic/types";
|
|
36
|
+
import FeedListColumns from "@/components/feed_list/Columns.vue";
|
|
15
37
|
|
|
16
|
-
|
|
38
|
+
defineProps<{feeds: Array<t.Feed>}>();
|
|
17
39
|
</script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex">
|
|
3
|
+
<div class="flex-shrink-0 w-4 mr-2 cursor-default">
|
|
4
|
+
<slot name="action" />
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="flex-shrink-0 w-20 cursor-default whitespace-nowrap text-right pr-2">
|
|
8
|
+
<slot name="checked" />
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="flex-shrink-0 w-24 cursor-default whitespace-nowrap text-right pr-2">
|
|
12
|
+
<slot name="added" />
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="flex-shrink-0 w-24 cursor-default whitespace-nowrap text-right pr-2">
|
|
16
|
+
<slot name="rate" />
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="flex-shrink-0 w-8 cursor-default text-right pr-1">
|
|
20
|
+
<slot name="favicon" />
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="flex-grow min-w-0">
|
|
24
|
+
<slot name="main" />
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full mb-1">
|
|
3
|
+
<div class="h-24">
|
|
4
|
+
<Bar
|
|
5
|
+
:data="chartData"
|
|
6
|
+
:options="chartOptions" />
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="mt-1 text-xs text-gray-500">{{ label }}</div>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script lang="ts" setup>
|
|
14
|
+
import {computed} from "vue";
|
|
15
|
+
import {
|
|
16
|
+
BarElement,
|
|
17
|
+
CategoryScale,
|
|
18
|
+
Chart as ChartJS,
|
|
19
|
+
LinearScale,
|
|
20
|
+
Tooltip,
|
|
21
|
+
type ChartData,
|
|
22
|
+
type ChartOptions,
|
|
23
|
+
type TooltipItem
|
|
24
|
+
} from "chart.js";
|
|
25
|
+
import {Bar} from "vue-chartjs";
|
|
26
|
+
|
|
27
|
+
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip);
|
|
28
|
+
|
|
29
|
+
const properties = defineProps<{
|
|
30
|
+
entriesLoadedDetails: number[];
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
type ChartSlot = {
|
|
34
|
+
value: number;
|
|
35
|
+
padded: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const chartDays = 30;
|
|
39
|
+
|
|
40
|
+
const chartSlots = computed<ChartSlot[]>(() => {
|
|
41
|
+
const details = properties.entriesLoadedDetails.slice(-chartDays);
|
|
42
|
+
const missingDays = chartDays - details.length;
|
|
43
|
+
|
|
44
|
+
const paddedSlots = Array.from({length: missingDays}, () => {
|
|
45
|
+
return {value: 0, padded: true};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const loadedSlots = details.map((value) => {
|
|
49
|
+
return {value, padded: false};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return [...paddedSlots, ...loadedSlots];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const zeroBarHeight = computed(() => {
|
|
56
|
+
const maxValue = Math.max(...chartSlots.value.map((slot) => slot.value));
|
|
57
|
+
|
|
58
|
+
return Math.max(maxValue * 0.04, 0.15);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const barValues = computed(() => {
|
|
62
|
+
return chartSlots.value.map((slot) => {
|
|
63
|
+
if (slot.value > 0) {
|
|
64
|
+
return slot.value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return zeroBarHeight.value;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const barColors = computed(() => {
|
|
72
|
+
return chartSlots.value.map((slot) => {
|
|
73
|
+
if (slot.padded) {
|
|
74
|
+
return "#e5e7eb";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (slot.value === 0) {
|
|
78
|
+
return "#bfdbfe";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return "#93c5fd";
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const suggestedMax = computed(() => {
|
|
86
|
+
return Math.max(1, ...chartSlots.value.map((slot) => slot.value));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function dateForEntry(index: number, entriesCount: number): Date {
|
|
90
|
+
const date = new Date();
|
|
91
|
+
|
|
92
|
+
date.setHours(0, 0, 0, 0);
|
|
93
|
+
date.setDate(date.getDate() - (entriesCount - index - 1));
|
|
94
|
+
|
|
95
|
+
return date;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formattedDate(date: Date): string {
|
|
99
|
+
return [date.getDate(), date.getMonth() + 1, date.getFullYear()]
|
|
100
|
+
.map((value) => value.toString().padStart(2, "0"))
|
|
101
|
+
.join("-");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function entriesLabel(count: number, date: Date): string {
|
|
105
|
+
const entriesNoun = count === 1 ? "entry" : "entries";
|
|
106
|
+
|
|
107
|
+
return `${count} news ${entriesNoun} loaded on ${formattedDate(date)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tooltipLabel(slot: ChartSlot, date: Date): string {
|
|
111
|
+
if (slot.padded) {
|
|
112
|
+
return `No data for ${formattedDate(date)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return entriesLabel(slot.value, date);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dates = computed(() => {
|
|
119
|
+
return chartSlots.value.map((_slot, index) => {
|
|
120
|
+
return dateForEntry(index, chartSlots.value.length);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const labels = computed(() => {
|
|
125
|
+
return dates.value.map(() => "");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const label = computed(() => {
|
|
129
|
+
return `Daily news entries loaded over the last ${chartDays} days`;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const chartData = computed<ChartData<"bar", number[], string>>(() => {
|
|
133
|
+
return {
|
|
134
|
+
labels: labels.value,
|
|
135
|
+
datasets: [
|
|
136
|
+
{
|
|
137
|
+
data: barValues.value,
|
|
138
|
+
backgroundColor: barColors.value,
|
|
139
|
+
hoverBackgroundColor: barColors.value,
|
|
140
|
+
borderRadius: 2,
|
|
141
|
+
borderSkipped: false,
|
|
142
|
+
barPercentage: 0.55,
|
|
143
|
+
categoryPercentage: 0.75
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const chartOptions = computed<ChartOptions<"bar">>(() => {
|
|
150
|
+
return {
|
|
151
|
+
responsive: true,
|
|
152
|
+
maintainAspectRatio: false,
|
|
153
|
+
animation: false,
|
|
154
|
+
layout: {
|
|
155
|
+
padding: {
|
|
156
|
+
bottom: 0,
|
|
157
|
+
right: 8
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
scales: {
|
|
161
|
+
x: {
|
|
162
|
+
offset: true,
|
|
163
|
+
border: {
|
|
164
|
+
display: true,
|
|
165
|
+
color: "#e5e7eb",
|
|
166
|
+
width: 1
|
|
167
|
+
},
|
|
168
|
+
grid: {
|
|
169
|
+
display: false
|
|
170
|
+
},
|
|
171
|
+
ticks: {
|
|
172
|
+
display: false,
|
|
173
|
+
color: "#4b5563",
|
|
174
|
+
font: {
|
|
175
|
+
size: 9
|
|
176
|
+
},
|
|
177
|
+
maxRotation: 0,
|
|
178
|
+
padding: 0,
|
|
179
|
+
autoSkip: false
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
y: {
|
|
183
|
+
beginAtZero: true,
|
|
184
|
+
suggestedMax: suggestedMax.value,
|
|
185
|
+
border: {
|
|
186
|
+
display: false
|
|
187
|
+
},
|
|
188
|
+
grid: {
|
|
189
|
+
display: false
|
|
190
|
+
},
|
|
191
|
+
ticks: {
|
|
192
|
+
display: false,
|
|
193
|
+
precision: 0
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
plugins: {
|
|
198
|
+
legend: {
|
|
199
|
+
display: false
|
|
200
|
+
},
|
|
201
|
+
tooltip: {
|
|
202
|
+
backgroundColor: "#111827",
|
|
203
|
+
borderColor: "#374151",
|
|
204
|
+
borderWidth: 1,
|
|
205
|
+
displayColors: false,
|
|
206
|
+
padding: 8,
|
|
207
|
+
callbacks: {
|
|
208
|
+
title: () => "",
|
|
209
|
+
label: (context: TooltipItem<"bar">) => {
|
|
210
|
+
const slot = chartSlots.value[context.dataIndex];
|
|
211
|
+
|
|
212
|
+
return tooltipLabel(slot, dates.value[context.dataIndex]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
</script>
|
package/src/logic/api.ts
CHANGED
|
@@ -331,6 +331,19 @@ export async function getFeeds() {
|
|
|
331
331
|
return feeds;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
export async function getFeedsByIds({ids}: {ids: t.FeedId[]}) {
|
|
335
|
+
const response = await postPrivate({url: "/get-feeds-by-ids", data: {ids: ids}});
|
|
336
|
+
|
|
337
|
+
const feeds = [];
|
|
338
|
+
|
|
339
|
+
for (let rawFeed of response.feeds) {
|
|
340
|
+
const feed = t.feedFromJSON(rawFeed);
|
|
341
|
+
feeds.push(feed);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return feeds;
|
|
345
|
+
}
|
|
346
|
+
|
|
334
347
|
export async function getLastEntries({period, minTagCount}: {period: number; minTagCount: number}) {
|
|
335
348
|
const response = await postPrivate({
|
|
336
349
|
url: "/get-last-entries",
|
package/src/logic/enums.ts
CHANGED
|
@@ -196,6 +196,7 @@ export const ReferenceKindProperties = new Map<ReferenceKind, ReferenceKindPrope
|
|
|
196
196
|
export enum FeedsOrder {
|
|
197
197
|
Title = "title",
|
|
198
198
|
Url = "url",
|
|
199
|
+
EntriesPerDay = "entries-per-day",
|
|
199
200
|
Loaded = "loaded",
|
|
200
201
|
Linked = "linked"
|
|
201
202
|
}
|
|
@@ -210,6 +211,7 @@ export type FeedsOrderProperty = {
|
|
|
210
211
|
export const FeedsOrderProperties = new Map<FeedsOrder, FeedsOrderProperty>([
|
|
211
212
|
[FeedsOrder.Title, {text: "title", orderField: "title", orderDirection: "asc", default: true}],
|
|
212
213
|
[FeedsOrder.Url, {text: "url", orderField: "url", orderDirection: "asc"}],
|
|
214
|
+
[FeedsOrder.EntriesPerDay, {text: "news/day", orderField: "entriesPerDay", orderDirection: "desc"}],
|
|
213
215
|
[FeedsOrder.Loaded, {text: "loaded", orderField: "loadedAt", orderDirection: "desc"}],
|
|
214
216
|
[FeedsOrder.Linked, {text: "added", orderField: "linkedAt", orderDirection: "desc"}]
|
|
215
217
|
]);
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import {describe, expect, it} from "vitest";
|
|
2
2
|
|
|
3
3
|
import * as e from "@/logic/enums";
|
|
4
|
-
import {entryFromJSON, type RawEntry} from "@/logic/types";
|
|
4
|
+
import {entryFromJSON, feedFromJSON, type RawEntry, type RawFeed} from "@/logic/types";
|
|
5
|
+
|
|
6
|
+
function rawFeed(overrides: Partial<RawFeed> = {}): RawFeed {
|
|
7
|
+
return {
|
|
8
|
+
id: "dca40ac1-73da-49dc-b7d8-40dac312ae40",
|
|
9
|
+
title: "Feed",
|
|
10
|
+
description: "Feed description",
|
|
11
|
+
url: "https://example.com/feed",
|
|
12
|
+
state: "loaded",
|
|
13
|
+
collectionIds: [],
|
|
14
|
+
young: true,
|
|
15
|
+
entriesPerDay: 7,
|
|
16
|
+
...overrides
|
|
17
|
+
};
|
|
18
|
+
}
|
|
5
19
|
|
|
6
20
|
function rawEntry(markers: number[]): RawEntry {
|
|
7
21
|
return {
|
|
@@ -17,6 +31,71 @@ function rawEntry(markers: number[]): RawEntry {
|
|
|
17
31
|
};
|
|
18
32
|
}
|
|
19
33
|
|
|
34
|
+
describe("feedFromJSON", () => {
|
|
35
|
+
it("translates loaded feed response", () => {
|
|
36
|
+
const feed = feedFromJSON(
|
|
37
|
+
rawFeed({
|
|
38
|
+
loadedAt: "2026-05-10T12:00:00Z",
|
|
39
|
+
linkedAt: "2026-05-11T12:00:00Z",
|
|
40
|
+
siteUrl: "https://example.com",
|
|
41
|
+
lastError: "network_connection_timeout",
|
|
42
|
+
collectionIds: ["dca40ac1-73da-49dc-b7d8-40dac312ae41"],
|
|
43
|
+
young: false,
|
|
44
|
+
entriesPerDay: 3,
|
|
45
|
+
entriesLoadedDetails: [1, 0, 3]
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(feed.id).toBe("dca40ac1-73da-49dc-b7d8-40dac312ae40");
|
|
50
|
+
expect(feed.url).toBe("https://example.com/feed");
|
|
51
|
+
expect(feed.siteUrl).toBe("https://example.com");
|
|
52
|
+
expect(feed.state).toBe("loaded");
|
|
53
|
+
expect(feed.lastError).toBe("network_connection_timeout");
|
|
54
|
+
expect(feed.loadedAt).toEqual(new Date("2026-05-10T12:00:00Z"));
|
|
55
|
+
expect(feed.linkedAt).toEqual(new Date("2026-05-11T12:00:00Z"));
|
|
56
|
+
expect(feed.isOk).toBe(true);
|
|
57
|
+
expect(feed.collectionIds).toEqual(["dca40ac1-73da-49dc-b7d8-40dac312ae41"]);
|
|
58
|
+
expect(feed.young).toBe(false);
|
|
59
|
+
expect(feed.entriesPerDay).toBe(3);
|
|
60
|
+
expect(feed.entriesLoadedDetails).toEqual([1, 0, 3]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("uses null defaults for missing optional response values", () => {
|
|
64
|
+
const feed = feedFromJSON(rawFeed());
|
|
65
|
+
|
|
66
|
+
expect(feed.lastError).toBeNull();
|
|
67
|
+
expect(feed.loadedAt).toBeNull();
|
|
68
|
+
expect(feed.linkedAt).toBeNull();
|
|
69
|
+
expect(feed.siteUrl).toBeNull();
|
|
70
|
+
expect(feed.entriesLoadedDetails).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps null optional response values as null", () => {
|
|
74
|
+
const feed = feedFromJSON(
|
|
75
|
+
rawFeed({
|
|
76
|
+
lastError: null,
|
|
77
|
+
loadedAt: null,
|
|
78
|
+
linkedAt: null,
|
|
79
|
+
siteUrl: null,
|
|
80
|
+
entriesLoadedDetails: null
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(feed.lastError).toBeNull();
|
|
85
|
+
expect(feed.loadedAt).toBeNull();
|
|
86
|
+
expect(feed.linkedAt).toBeNull();
|
|
87
|
+
expect(feed.siteUrl).toBeNull();
|
|
88
|
+
expect(feed.entriesLoadedDetails).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("marks non-loaded feed as not ok", () => {
|
|
92
|
+
const feed = feedFromJSON(rawFeed({state: "damaged"}));
|
|
93
|
+
|
|
94
|
+
expect(feed.state).toBe("damaged");
|
|
95
|
+
expect(feed.isOk).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
20
99
|
describe("entryFromJSON", () => {
|
|
21
100
|
it("translates integer markers to frontend enum values", () => {
|
|
22
101
|
const entry = entryFromJSON(rawEntry([1, 2]), {});
|
package/src/logic/types.ts
CHANGED
|
@@ -41,46 +41,62 @@ export class Feed {
|
|
|
41
41
|
readonly title: string | null;
|
|
42
42
|
readonly description: string | null;
|
|
43
43
|
readonly url: URL;
|
|
44
|
+
readonly siteUrl: URL | null;
|
|
44
45
|
readonly state: string;
|
|
45
46
|
readonly lastError: string | null;
|
|
46
47
|
readonly loadedAt: Date | null;
|
|
47
|
-
readonly linkedAt: Date;
|
|
48
|
+
readonly linkedAt: Date | null;
|
|
48
49
|
readonly isOk: boolean;
|
|
49
50
|
readonly collectionIds: CollectionId[];
|
|
51
|
+
readonly young: boolean;
|
|
52
|
+
readonly entriesPerDay: number;
|
|
53
|
+
readonly entriesLoadedDetails: number[] | null;
|
|
50
54
|
|
|
51
55
|
constructor({
|
|
52
56
|
id,
|
|
53
57
|
title,
|
|
54
58
|
description,
|
|
55
59
|
url,
|
|
60
|
+
siteUrl,
|
|
56
61
|
state,
|
|
57
62
|
lastError,
|
|
58
63
|
loadedAt,
|
|
59
64
|
linkedAt,
|
|
60
65
|
isOk,
|
|
61
|
-
collectionIds
|
|
66
|
+
collectionIds,
|
|
67
|
+
young,
|
|
68
|
+
entriesPerDay,
|
|
69
|
+
entriesLoadedDetails
|
|
62
70
|
}: {
|
|
63
71
|
id: FeedId;
|
|
64
72
|
title: string | null;
|
|
65
73
|
description: string | null;
|
|
66
74
|
url: URL;
|
|
75
|
+
siteUrl: URL | null;
|
|
67
76
|
state: string;
|
|
68
77
|
lastError: string | null;
|
|
69
78
|
loadedAt: Date | null;
|
|
70
|
-
linkedAt: Date;
|
|
79
|
+
linkedAt: Date | null;
|
|
71
80
|
isOk: boolean;
|
|
72
81
|
collectionIds: CollectionId[];
|
|
82
|
+
young: boolean;
|
|
83
|
+
entriesPerDay: number;
|
|
84
|
+
entriesLoadedDetails: number[] | null;
|
|
73
85
|
}) {
|
|
74
86
|
this.id = id;
|
|
75
87
|
this.title = title;
|
|
76
88
|
this.description = description;
|
|
77
89
|
this.url = url;
|
|
90
|
+
this.siteUrl = siteUrl;
|
|
78
91
|
this.state = state;
|
|
79
92
|
this.lastError = lastError;
|
|
80
93
|
this.loadedAt = loadedAt;
|
|
81
94
|
this.linkedAt = linkedAt;
|
|
82
95
|
this.isOk = isOk;
|
|
83
96
|
this.collectionIds = collectionIds;
|
|
97
|
+
this.young = young;
|
|
98
|
+
this.entriesPerDay = entriesPerDay;
|
|
99
|
+
this.entriesLoadedDetails = entriesLoadedDetails;
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
|
|
@@ -89,11 +105,15 @@ export type RawFeed = {
|
|
|
89
105
|
title: string | null;
|
|
90
106
|
description: string | null;
|
|
91
107
|
url: string;
|
|
108
|
+
siteUrl?: string | null;
|
|
92
109
|
state: string;
|
|
93
110
|
lastError?: string | null;
|
|
94
111
|
loadedAt?: string | null;
|
|
95
|
-
linkedAt
|
|
112
|
+
linkedAt?: string | null;
|
|
96
113
|
collectionIds: string[];
|
|
114
|
+
young: boolean;
|
|
115
|
+
entriesPerDay: number;
|
|
116
|
+
entriesLoadedDetails?: number[] | null;
|
|
97
117
|
};
|
|
98
118
|
|
|
99
119
|
export function feedFromJSON({
|
|
@@ -101,23 +121,31 @@ export function feedFromJSON({
|
|
|
101
121
|
title,
|
|
102
122
|
description,
|
|
103
123
|
url,
|
|
124
|
+
siteUrl,
|
|
104
125
|
state,
|
|
105
126
|
lastError,
|
|
106
127
|
loadedAt,
|
|
107
128
|
linkedAt,
|
|
108
|
-
collectionIds
|
|
129
|
+
collectionIds,
|
|
130
|
+
young,
|
|
131
|
+
entriesPerDay,
|
|
132
|
+
entriesLoadedDetails
|
|
109
133
|
}: RawFeed): Feed {
|
|
110
134
|
return {
|
|
111
135
|
id: toFeedId(id),
|
|
112
136
|
title: title !== null ? title : null,
|
|
113
137
|
description: description !== null ? description : null,
|
|
114
138
|
url: toURL(url),
|
|
139
|
+
siteUrl: siteUrl !== undefined && siteUrl !== null ? toURL(siteUrl) : null,
|
|
115
140
|
state: state,
|
|
116
141
|
lastError: lastError !== undefined ? lastError : null,
|
|
117
142
|
loadedAt: loadedAt !== undefined && loadedAt !== null ? new Date(loadedAt) : null,
|
|
118
|
-
linkedAt: new Date(linkedAt),
|
|
143
|
+
linkedAt: linkedAt !== undefined && linkedAt !== null ? new Date(linkedAt) : null,
|
|
119
144
|
isOk: state === "loaded",
|
|
120
|
-
collectionIds: collectionIds.map(toCollectionId)
|
|
145
|
+
collectionIds: collectionIds.map(toCollectionId),
|
|
146
|
+
young: young,
|
|
147
|
+
entriesPerDay: entriesPerDay,
|
|
148
|
+
entriesLoadedDetails: entriesLoadedDetails !== undefined ? entriesLoadedDetails : null
|
|
121
149
|
};
|
|
122
150
|
}
|
|
123
151
|
|
|
@@ -389,6 +417,7 @@ export function entryInfoFromJSON({title, body, url, publishedAt}: RawEntryInfo)
|
|
|
389
417
|
|
|
390
418
|
export type FeedInfo = {
|
|
391
419
|
readonly url: URL;
|
|
420
|
+
readonly siteUrl: URL | null;
|
|
392
421
|
readonly title: string;
|
|
393
422
|
readonly description: string;
|
|
394
423
|
readonly entries: EntryInfo[];
|
|
@@ -397,15 +426,17 @@ export type FeedInfo = {
|
|
|
397
426
|
|
|
398
427
|
export type RawFeedInfo = {
|
|
399
428
|
url: string;
|
|
429
|
+
siteUrl?: string | null;
|
|
400
430
|
title: string;
|
|
401
431
|
description: string;
|
|
402
432
|
entries: RawEntryInfo[];
|
|
403
433
|
isLinked: boolean;
|
|
404
434
|
};
|
|
405
435
|
|
|
406
|
-
export function feedInfoFromJSON({url, title, description, entries, isLinked}: RawFeedInfo): FeedInfo {
|
|
436
|
+
export function feedInfoFromJSON({url, siteUrl, title, description, entries, isLinked}: RawFeedInfo): FeedInfo {
|
|
407
437
|
return {
|
|
408
438
|
url: toURL(url),
|
|
439
|
+
siteUrl: siteUrl !== undefined && siteUrl !== null ? toURL(siteUrl) : null,
|
|
409
440
|
title,
|
|
410
441
|
description,
|
|
411
442
|
entries: entries.map(entryInfoFromJSON),
|
package/src/stores/feeds.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {useRouter} from "vue-router";
|
|
1
|
+
import {ref} from "vue";
|
|
3
2
|
import {defineStore} from "pinia";
|
|
4
3
|
|
|
5
|
-
import
|
|
6
|
-
import * as e from "@/logic/enums";
|
|
4
|
+
import * as t from "@/logic/types";
|
|
7
5
|
import * as api from "@/logic/api";
|
|
8
6
|
import {Timer} from "@/logic/timer";
|
|
9
7
|
import {computedAsync} from "@vueuse/core";
|
|
@@ -12,20 +10,112 @@ import {useGlobalSettingsStore} from "@/stores/globalSettings";
|
|
|
12
10
|
export const useFeedsStore = defineStore("feedsStore", () => {
|
|
13
11
|
const globalSettings = useGlobalSettingsStore();
|
|
14
12
|
|
|
15
|
-
const feeds =
|
|
13
|
+
const feeds = ref<{[key: t.FeedId]: t.Feed}>({});
|
|
14
|
+
const requestedFeeds = ref<{[key: t.FeedId]: boolean}>({});
|
|
15
|
+
const displayedFeedId = ref<t.FeedId | null>(null);
|
|
16
|
+
|
|
17
|
+
function feedWithFullDataFromStorage(feed: t.Feed, preserveFullData: boolean) {
|
|
18
|
+
// Full feed details power derived UI, like activity charts. On full list
|
|
19
|
+
// refresh we must drop them so opened feed bodies reload fresh details.
|
|
20
|
+
if (!preserveFullData) {
|
|
21
|
+
return feed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (feed.id in feeds.value) {
|
|
25
|
+
const existingFeed = feeds.value[feed.id];
|
|
26
|
+
|
|
27
|
+
if (feed.entriesLoadedDetails === null && existingFeed.entriesLoadedDetails !== null) {
|
|
28
|
+
return {
|
|
29
|
+
...feed,
|
|
30
|
+
entriesLoadedDetails: existingFeed.entriesLoadedDetails
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return feed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function registerFeeds({newFeeds, replace}: {newFeeds: t.Feed[]; replace: boolean}) {
|
|
39
|
+
const delta: {[key: t.FeedId]: t.Feed} = {};
|
|
40
|
+
|
|
41
|
+
for (const feed of newFeeds) {
|
|
42
|
+
delta[feed.id] = feedWithFullDataFromStorage(feed, !replace);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (replace) {
|
|
46
|
+
feeds.value = delta;
|
|
47
|
+
|
|
48
|
+
if (displayedFeedId.value === null) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!(displayedFeedId.value in feeds.value)) {
|
|
53
|
+
displayedFeedId.value = null;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
requestFullFeed({feedId: displayedFeedId.value});
|
|
58
|
+
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
feeds.value = {...feeds.value, ...delta};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const loadedFeedsReport = computedAsync(async () => {
|
|
16
66
|
// force refresh
|
|
17
67
|
globalSettings.dataVersion;
|
|
18
68
|
|
|
19
69
|
const feedsList = await api.getFeeds();
|
|
20
70
|
|
|
21
|
-
|
|
71
|
+
registerFeeds({
|
|
72
|
+
newFeeds: feedsList,
|
|
73
|
+
replace: true
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return feedsList.map((feed) => feed.id);
|
|
77
|
+
}, null);
|
|
22
78
|
|
|
23
|
-
|
|
24
|
-
|
|
79
|
+
function requestFullFeed({feedId}: {feedId: t.FeedId}) {
|
|
80
|
+
if (feedId in feeds.value && feeds.value[feedId].entriesLoadedDetails !== null) {
|
|
81
|
+
return;
|
|
25
82
|
}
|
|
26
83
|
|
|
27
|
-
|
|
28
|
-
}
|
|
84
|
+
requestedFeeds.value[feedId] = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function displayFeed({feedId}: {feedId: t.FeedId}) {
|
|
88
|
+
displayedFeedId.value = feedId;
|
|
89
|
+
|
|
90
|
+
requestFullFeed({feedId: feedId});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hideFeed({feedId}: {feedId: t.FeedId}) {
|
|
94
|
+
if (displayedFeedId.value === feedId) {
|
|
95
|
+
displayedFeedId.value = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadFullFeeds() {
|
|
100
|
+
const ids: t.FeedId[] = Object.keys(requestedFeeds.value).map((key) => t.toFeedId(key));
|
|
101
|
+
|
|
102
|
+
if (ids.length === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const loadedFeeds = await api.getFeedsByIds({ids: ids});
|
|
107
|
+
|
|
108
|
+
registerFeeds({
|
|
109
|
+
newFeeds: loadedFeeds,
|
|
110
|
+
replace: false
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
requestedFeeds.value = {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const requestedFeedsTimer = new Timer(loadFullFeeds, 1000);
|
|
117
|
+
|
|
118
|
+
requestedFeedsTimer.start();
|
|
29
119
|
|
|
30
120
|
async function unsubscribe(feedId: t.FeedId) {
|
|
31
121
|
await api.unsubscribe({feedId: feedId});
|
|
@@ -34,9 +124,9 @@ export const useFeedsStore = defineStore("feedsStore", () => {
|
|
|
34
124
|
// it cause a lot of unnecessary requests to the server without any benefit
|
|
35
125
|
// we just remove feed from frontend
|
|
36
126
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
127
|
+
const updatedFeeds = {...feeds.value};
|
|
128
|
+
delete updatedFeeds[feedId];
|
|
129
|
+
feeds.value = updatedFeeds;
|
|
40
130
|
}
|
|
41
131
|
|
|
42
132
|
async function subscribe(url: t.URL) {
|
|
@@ -46,13 +136,19 @@ export const useFeedsStore = defineStore("feedsStore", () => {
|
|
|
46
136
|
|
|
47
137
|
// Attention, do not update globalSettings.updateDataVersion here (see above)
|
|
48
138
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
139
|
+
registerFeeds({
|
|
140
|
+
newFeeds: [newFeed],
|
|
141
|
+
replace: false
|
|
142
|
+
});
|
|
52
143
|
}
|
|
53
144
|
|
|
54
145
|
return {
|
|
55
146
|
feeds,
|
|
147
|
+
loadedFeedsReport,
|
|
148
|
+
displayedFeedId,
|
|
149
|
+
requestFullFeed,
|
|
150
|
+
displayFeed,
|
|
151
|
+
hideFeed,
|
|
56
152
|
unsubscribe,
|
|
57
153
|
subscribe
|
|
58
154
|
};
|
|
@@ -177,7 +177,6 @@ export const useGlobalSettingsStore = defineStore("globalSettings", () => {
|
|
|
177
177
|
// Feeds filter settings
|
|
178
178
|
////////////////////////
|
|
179
179
|
|
|
180
|
-
const showFeedsDescriptions = boolBackendSettings("view_feeds_show_feed_descriptions", true);
|
|
181
180
|
const feedsOrder = enumBackendSettings("view_feeds_feed_order", e.FeedsOrderProperties);
|
|
182
181
|
const failedFeedsFirst = boolBackendSettings("view_feeds_failed_feeds_first", false);
|
|
183
182
|
|
|
@@ -217,7 +216,6 @@ export const useGlobalSettingsStore = defineStore("globalSettings", () => {
|
|
|
217
216
|
entriesOrderProperties,
|
|
218
217
|
dataVersion,
|
|
219
218
|
updateDataVersion,
|
|
220
|
-
showFeedsDescriptions,
|
|
221
219
|
userSettingsPresent,
|
|
222
220
|
feedsOrder,
|
|
223
221
|
failedFeedsFirst,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {createPinia, setActivePinia} from "pinia";
|
|
2
|
+
import {beforeEach, describe, expect, it, vi} from "vitest";
|
|
3
|
+
|
|
4
|
+
import * as api from "@/logic/api";
|
|
5
|
+
import * as t from "@/logic/types";
|
|
6
|
+
import {useFeedsStore} from "@/stores/feeds";
|
|
7
|
+
|
|
8
|
+
const timerInstances = vi.hoisted(() => [] as Array<{refresher: () => Promise<void>; delay: number}>);
|
|
9
|
+
|
|
10
|
+
vi.mock("@/logic/timer", () => ({
|
|
11
|
+
Timer: vi.fn().mockImplementation((refresher, delay) => {
|
|
12
|
+
timerInstances.push({refresher, delay});
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
start: vi.fn(),
|
|
16
|
+
stop: vi.fn()
|
|
17
|
+
};
|
|
18
|
+
})
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("@/stores/globalSettings", () => ({
|
|
22
|
+
useGlobalSettingsStore: vi.fn(() => ({
|
|
23
|
+
dataVersion: 0
|
|
24
|
+
}))
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@/logic/api", () => ({
|
|
28
|
+
addFeed: vi.fn(),
|
|
29
|
+
getFeeds: vi.fn(),
|
|
30
|
+
getFeedsByIds: vi.fn(),
|
|
31
|
+
unsubscribe: vi.fn()
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
function feed(overrides: Partial<t.RawFeed> = {}) {
|
|
35
|
+
return t.feedFromJSON({
|
|
36
|
+
id: "dca40ac1-73da-49dc-b7d8-40dac312ae40",
|
|
37
|
+
title: "Feed",
|
|
38
|
+
description: "Feed description",
|
|
39
|
+
url: "https://example.com/feed",
|
|
40
|
+
siteUrl: "https://example.com",
|
|
41
|
+
state: "loaded",
|
|
42
|
+
collectionIds: [],
|
|
43
|
+
young: false,
|
|
44
|
+
entriesPerDay: 3,
|
|
45
|
+
...overrides
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function settleAsyncWork() {
|
|
50
|
+
await Promise.resolve();
|
|
51
|
+
await Promise.resolve();
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("useFeedsStore", () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
vi.clearAllMocks();
|
|
58
|
+
timerInstances.length = 0;
|
|
59
|
+
setActivePinia(createPinia());
|
|
60
|
+
|
|
61
|
+
vi.mocked(api.addFeed).mockResolvedValue(feed());
|
|
62
|
+
vi.mocked(api.getFeeds).mockResolvedValue([]);
|
|
63
|
+
vi.mocked(api.getFeedsByIds).mockResolvedValue([]);
|
|
64
|
+
vi.mocked(api.unsubscribe).mockResolvedValue(undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("loads feed list and preserves already loaded details from storage", async () => {
|
|
68
|
+
const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
|
|
69
|
+
|
|
70
|
+
vi.mocked(api.addFeed).mockResolvedValue(feed({entriesLoadedDetails: [1, 0, 3]}));
|
|
71
|
+
vi.mocked(api.getFeeds).mockResolvedValue([feed({entriesLoadedDetails: null})]);
|
|
72
|
+
|
|
73
|
+
const store = useFeedsStore();
|
|
74
|
+
|
|
75
|
+
await store.subscribe(t.toURL("https://example.com/feed"));
|
|
76
|
+
|
|
77
|
+
expect(store.feeds[feedId].entriesLoadedDetails).toEqual([1, 0, 3]);
|
|
78
|
+
|
|
79
|
+
store.loadedFeedsReport;
|
|
80
|
+
await settleAsyncWork();
|
|
81
|
+
|
|
82
|
+
expect(store.loadedFeedsReport).toEqual([feedId]);
|
|
83
|
+
expect(store.feeds[feedId].entriesLoadedDetails).toEqual([1, 0, 3]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("loads full feed data for displayed feed only once", async () => {
|
|
87
|
+
const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
|
|
88
|
+
const store = useFeedsStore();
|
|
89
|
+
|
|
90
|
+
await store.subscribe(t.toURL("https://example.com/feed"));
|
|
91
|
+
|
|
92
|
+
vi.mocked(api.getFeedsByIds).mockResolvedValue([feed({entriesLoadedDetails: [2, 4]})]);
|
|
93
|
+
|
|
94
|
+
store.displayFeed({feedId});
|
|
95
|
+
|
|
96
|
+
expect(store.displayedFeedId).toBe(feedId);
|
|
97
|
+
|
|
98
|
+
await timerInstances[0].refresher();
|
|
99
|
+
|
|
100
|
+
expect(api.getFeedsByIds).toHaveBeenCalledWith({ids: [feedId]});
|
|
101
|
+
expect(store.feeds[feedId].entriesLoadedDetails).toEqual([2, 4]);
|
|
102
|
+
|
|
103
|
+
vi.mocked(api.getFeedsByIds).mockClear();
|
|
104
|
+
|
|
105
|
+
store.requestFullFeed({feedId});
|
|
106
|
+
|
|
107
|
+
await timerInstances[0].refresher();
|
|
108
|
+
|
|
109
|
+
expect(api.getFeedsByIds).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps full feed request queued when API loading fails", async () => {
|
|
113
|
+
const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
|
|
114
|
+
const store = useFeedsStore();
|
|
115
|
+
|
|
116
|
+
await store.subscribe(t.toURL("https://example.com/feed"));
|
|
117
|
+
|
|
118
|
+
vi.mocked(api.getFeedsByIds).mockRejectedValueOnce(new Error("load failed"));
|
|
119
|
+
|
|
120
|
+
store.requestFullFeed({feedId});
|
|
121
|
+
|
|
122
|
+
await expect(timerInstances[0].refresher()).rejects.toThrow("load failed");
|
|
123
|
+
|
|
124
|
+
vi.mocked(api.getFeedsByIds).mockResolvedValueOnce([feed({entriesLoadedDetails: [7]})]);
|
|
125
|
+
|
|
126
|
+
await timerInstances[0].refresher();
|
|
127
|
+
|
|
128
|
+
expect(api.getFeedsByIds).toHaveBeenLastCalledWith({ids: [feedId]});
|
|
129
|
+
expect(store.feeds[feedId].entriesLoadedDetails).toEqual([7]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("hides only the currently displayed feed", () => {
|
|
133
|
+
const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
|
|
134
|
+
const anotherFeedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae41");
|
|
135
|
+
const store = useFeedsStore();
|
|
136
|
+
|
|
137
|
+
store.displayFeed({feedId});
|
|
138
|
+
|
|
139
|
+
store.hideFeed({feedId: anotherFeedId});
|
|
140
|
+
|
|
141
|
+
expect(store.displayedFeedId).toBe(feedId);
|
|
142
|
+
|
|
143
|
+
store.hideFeed({feedId});
|
|
144
|
+
|
|
145
|
+
expect(store.displayedFeedId).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("removes unsubscribed feeds from storage", async () => {
|
|
149
|
+
const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
|
|
150
|
+
const store = useFeedsStore();
|
|
151
|
+
|
|
152
|
+
await store.subscribe(t.toURL("https://example.com/feed"));
|
|
153
|
+
|
|
154
|
+
expect(store.feeds[feedId]).toBeDefined();
|
|
155
|
+
|
|
156
|
+
await store.unsubscribe(feedId);
|
|
157
|
+
|
|
158
|
+
expect(api.unsubscribe).toHaveBeenCalledWith({feedId});
|
|
159
|
+
expect(store.feeds[feedId]).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/views/FeedsView.vue
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<side-panel-layout>
|
|
3
3
|
<template #side-menu-item-1>
|
|
4
|
-
Show descriptions
|
|
5
|
-
<config-flag
|
|
6
|
-
style="min-width: 2.5rem"
|
|
7
|
-
v-model:flag="globalSettings.showFeedsDescriptions"
|
|
8
|
-
on-text="yes"
|
|
9
|
-
off-text="no" />
|
|
10
|
-
</template>
|
|
11
|
-
|
|
12
|
-
<template #side-menu-item-2>
|
|
13
4
|
Sorted by
|
|
14
5
|
<config-selector
|
|
15
6
|
:values="e.FeedsOrderProperties"
|
|
16
7
|
v-model:property="globalSettings.feedsOrder" />
|
|
17
8
|
</template>
|
|
18
9
|
|
|
19
|
-
<template #side-menu-item-
|
|
10
|
+
<template #side-menu-item-2>
|
|
20
11
|
Show failed
|
|
21
12
|
<config-flag
|
|
22
13
|
v-model:flag="globalSettings.failedFeedsFirst"
|
|
@@ -25,7 +16,7 @@
|
|
|
25
16
|
off-text="last" />
|
|
26
17
|
</template>
|
|
27
18
|
|
|
28
|
-
<template #side-menu-item-
|
|
19
|
+
<template #side-menu-item-3>
|
|
29
20
|
<a
|
|
30
21
|
class="ffun-form-button p-1 my-1 block w-full text-center"
|
|
31
22
|
:href="api.downloadOPMLUrl"
|