Repository navigation
Expand file tree
/
Copy pathPanel.qml
More file actions
2399 lines (2221 loc) · 89.6 KB
/
Copy pathPanel.qml
File metadata and controls
2399 lines (2221 loc) · 89.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import QtQuick
import Quickshell
import Quickshell.Io
import QtQuick.Controls as Controls
import qs.Commons
import qs.Ui
Panel {
id: root
moduleName: "io.github.kitsunesemcalda.feader-rss"
ipcTarget: "io.github.kitsunesemcalda.feader-rss"
manageIpc: false
property var anchorItem: null
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
property bool openedFromHotkey: false
readonly property color foreground: Color.popups.text
readonly property color dim: Color.muted
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
property string home: Quickshell.env("HOME") || ""
property string omarchyPath: Quickshell.env("OMARCHY_PATH") || ""
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") || home + "/.config"
readonly property string configPath: configHome + "/omarchy/rss-reader.json"
readonly property string stateDir: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state")
+ "/omarchy/rss-reader"
readonly property string statePath: stateDir + "/preferences.json"
// Ceiling on how much of rss-reader.json / preferences.json the shell will
// read. Both files are user-editable and restorable from backups, so an
// oversized one must be rejected before it is materialized in the
// long-lived shell process, not after JSON parsing and the feed limits.
// A 100-feed configuration is ~20 KiB; 1 MiB leaves ample headroom.
readonly property int maxSettingsFileBytes: 1048576
readonly property string dbPath: stateDir + "/items.db"
readonly property string fetchBinary: Qt.resolvedUrl("feader-rss-fetch").toString().replace(/^file:\/\//, "")
property var config: ({
feeds: [], maxFeeds: 8, maxItems: 200, retentionItems: 1000, refreshMinutes: 5,
scrollStep: 54, panelGap: Style.gapsOut
})
property var articles: []
property bool loading: false
property string status: ""
property var feedErrors: []
property string lastUpdated: ""
property string prefetchNote: ""
property int unreadNotificationId: 0
property int lastNotifiedUnreadCount: -1
property int selectedIndex: 0
property var selectedArticle: null
property bool detailOpen: false
property bool settingsOpen: false
property bool formControlFocused: false
property bool stateReady: false
property bool articleLoading: false
property string articleContent: ""
property string articleError: ""
// The article requested again while articleProcess was still busy with a
// previous one (it only runs one request at a time). Drained once that
// request settles; see drainQueuedArticle.
property var articleQueuedNext: null
property string searchQuery: ""
property string readFilter: "all"
property string selectedFeed: ""
property string selectedFolder: ""
property bool preferencesReady: false
property int refreshMinutesDraft: 5
property int retentionItemsDraft: 1000
property int maxFeedsDraft: 8
property real scrollStepDraft: 54
property string pendingConfirm: ""
property int globalUnreadCount: -1
property int globalUnreadFeedCount: -1
property bool searchResultsActive: false
// Infinite scroll: articles arrive articlePageSize at a time (up to
// resolvedMaxItems in total). loadedCount is how many rows the database
// has handed over, i.e. the next --offset; pagesGeneration invalidates a
// page request that was in flight when the list was replaced.
readonly property int articlePageSize: 50
property int loadedCount: 0
property bool moreAvailable: false
property bool loadingMore: false
property int moreRequestSize: 0
property int pagesGeneration: 0
property int spinnerTick: 0
property int spinnerSeed: 0
property int moreRequestGeneration: 0
property string searchProcessQuery: ""
property string tagDraft: ""
property int searchDebounceMs: 180
readonly property var refreshOptions: [
{ value: 1, label: "1 min" },
{ value: 2, label: "2 min" },
{ value: 3, label: "3 min" },
{ value: 4, label: "4 min" },
{ value: 5, label: "5 min" },
{ value: 15, label: "15 min" },
{ value: 30, label: "30 min" },
{ value: 60, label: "1 hour" },
{ value: 300, label: "5 hours" }
]
readonly property int localUnreadCount: articles.filter(function(article) { return !article.read }).length
readonly property int localUnreadFeedCount: {
var feeds = {}
for (var i = 0; i < articles.length; i++) {
if (!articles[i].read) feeds[String(articles[i].feed || "")] = true
}
return Object.keys(feeds).filter(function(feed) { return feed !== "" }).length
}
readonly property int unreadCount: globalUnreadCount >= 0 ? globalUnreadCount : localUnreadCount
readonly property int unreadFeedCount: globalUnreadFeedCount >= 0 ? globalUnreadFeedCount : localUnreadFeedCount
// A single glyph, always — BarIconButton renders `text` as one un-clipped
// optical glyph sized for its fixed icon slot. Appending the live unread
// count here (e.g. " 15") used to bleed digits past that slot and
// over whichever bar widget sits next to this one. The count still shows
// in the tooltip (unreadSummary) and inside the panel itself.
readonly property string label: ""
readonly property string unreadSummary: unreadFeedCount + " feeds unread · " + unreadCount + " articles"
readonly property int refreshSeconds: {
return root.normalizeRefreshMinutes(config && config.refreshMinutes) * 60
}
readonly property int resolvedRetentionItems: {
var value = Math.floor(Number(config && config.retentionItems))
return isFinite(value) && value >= 0 ? Math.min(100000, value) : 1000
}
// How many feeds may be configured; mirrors opml.ResolveMaxFeeds.
readonly property int resolvedMaxFeeds: root.normalizeMaxFeeds(config && config.maxFeeds)
readonly property int resolvedMaxItems: {
var value = Math.floor(Number(config && config.maxItems))
return isFinite(value) && value > 0 ? Math.min(100000, value) : 200
}
readonly property real resolvedScrollStep: {
var value = Number(config.scrollStep)
return (isFinite(value) && value > 0) ? value : 54
}
readonly property real resolvedPanelGap: {
var value = Number(config.panelGap)
return (isFinite(value) && value >= 0) ? value : Style.gapsOut
}
// Single-character keys, always lower-cased before matching. Override any
// subset via config.shortcuts (e.g. {"markAllRead": "x"}) — unknown action
// names are ignored so a typo in the config never breaks the rest.
readonly property var defaultShortcuts: ({
refresh: "r",
settings: "s",
search: "/",
markAllRead: "a",
openArticle: "o",
filterAll: "1",
filterUnread: "2",
filterRead: "3",
filterStarred: "4"
})
readonly property var resolvedShortcuts: {
var merged = {}
for (var action in root.defaultShortcuts) merged[action] = root.defaultShortcuts[action]
var overrides = config && config.shortcuts
if (overrides && typeof overrides === "object") {
for (var name in overrides) {
if (!(name in merged)) continue
var key = root.trimmed(overrides[name]).toLowerCase()
if (key !== "") merged[name] = key
}
}
return merged
}
readonly property var visibleArticles: articles.filter(function(article) { return articleMatches(article) })
function shortcutsHint() {
var s = root.resolvedShortcuts
return "Shortcuts: " + s.refresh.toUpperCase() + " refresh · " + s.settings.toUpperCase() + " settings · "
+ s.search.toUpperCase() + " search · " + s.markAllRead.toUpperCase() + " mark all read · "
+ s.openArticle.toUpperCase() + " open · " + s.filterAll + "/" + s.filterUnread + "/" + s.filterRead
+ "/" + s.filterStarred + " filter · ↑↓ navigate · Enter open"
}
function loadJson(raw, fallback) {
try { return JSON.parse(raw) } catch (error) { return fallback }
}
function normalizeRefreshMinutes(value) {
var numeric = Number(value)
if (!isFinite(numeric)) return 5
for (var i = 0; i < root.refreshOptions.length; i++) {
if (root.refreshOptions[i].value === numeric) return numeric
}
return 5
}
function refreshLabel(value) {
var normalized = root.normalizeRefreshMinutes(value)
for (var i = 0; i < root.refreshOptions.length; i++) {
if (root.refreshOptions[i].value === normalized) return root.refreshOptions[i].label
}
return "5 min"
}
function trimmed(value) {
return String(value || "").trim()
}
function inferFeedName(url) {
var match = /^https?:\/\/([^\/?#]+)/i.exec(root.trimmed(url))
if (!match) return ""
var host = match[1].split("@").pop().split(":")[0].toLowerCase()
return host.replace(/^www\./, "")
}
function feedDisplayName(feed) {
if (!feed) return ""
var explicit = root.trimmed(feed.name)
var url = root.trimmed(feed.url)
return explicit || root.inferFeedName(url) || url
}
function feedFolderForName(feedName) {
if (!config || !Array.isArray(config.feeds)) return ""
var target = String(feedName || "")
for (var i = 0; i < config.feeds.length; i++) {
var feed = config.feeds[i]
if (feed && root.feedDisplayName(feed) === target) return root.trimmed(feed.folder)
}
return ""
}
function configuredFeedNamesArray() {
var names = []
if (!config || !Array.isArray(config.feeds)) return names
for (var i = 0; i < config.feeds.length; i++) {
var name = root.feedDisplayName(config.feeds[i])
if (name !== "" && names.indexOf(name) < 0) names.push(name)
}
return names
}
function configuredFolders() {
var folders = []
if (!config || !Array.isArray(config.feeds)) return folders
for (var i = 0; i < config.feeds.length; i++) {
var folder = root.trimmed(config.feeds[i].folder)
if (folder !== "" && folders.indexOf(folder) < 0) folders.push(folder)
}
return folders
}
function formatPublished(value) {
var raw = root.trimmed(value)
if (raw === "") return "Date unknown"
var date = new Date(raw)
if (isNaN(date.getTime())) return raw
return date.toLocaleDateString(Qt.locale(), Locale.ShortFormat)
}
function articleMeta(article) {
if (!article) return ""
var parts = []
var feedName = root.trimmed(article.feed)
var author = root.trimmed(article.author)
if (feedName !== "") parts.push(feedName)
if (author !== "") parts.push("by " + author)
if (root.trimmed(article.published) !== "") parts.push(root.formatPublished(article.published))
var categories = Array.isArray(article.categories) ? article.categories.slice(0, 3) : []
categories = categories.map(function(category) { return root.trimmed(category) })
.filter(function(category) { return category !== "" })
if (categories.length > 0) parts.push(categories.join(", "))
var tags = Array.isArray(article.tags) ? article.tags.slice(0, 3) : []
tags = tags.map(function(tag) { return root.trimmed(tag) })
.filter(function(tag) { return tag !== "" })
if (article.starred) parts.push("saved")
if (tags.length > 0) parts.push("#" + tags.join(" #"))
if (article.cached) parts.push("ready")
return parts.join(" · ")
}
function articleSummary(article) {
var summary = article ? root.trimmed(article.summary) : ""
return summary !== "" ? summary : "This feed did not provide a summary. Open the article to read it."
}
// Bounds and validates feeds coming from disk — a hand-edited config file
// or an OPML import can carry more than maxFeeds entries, non-http(s) URLs, or
// duplicates that the settings form would otherwise have rejected.
function normalizeMaxFeeds(value) {
var numeric = Math.floor(Number(value))
return isFinite(numeric) && numeric > 0 ? Math.min(100, numeric) : 8
}
function sanitizeFeeds(rawFeeds, limit) {
var feeds = []
if (!Array.isArray(rawFeeds)) return feeds
var seenUrls = {}
var seenNames = {}
for (var i = 0; i < rawFeeds.length && feeds.length < limit; i++) {
var feed = rawFeeds[i]
if (!feed) continue
var url = root.trimmed(feed.url)
if (!/^https?:\/\/[^\s]+$/i.test(url)) continue
var normalizedUrl = url.toLowerCase().replace(/\/$/, "")
if (seenUrls[normalizedUrl]) continue
var name = root.trimmed(feed.name) || root.inferFeedName(url) || url
if (seenNames[name.toLowerCase()]) continue
seenUrls[normalizedUrl] = true
seenNames[name.toLowerCase()] = true
feeds.push({ name: name, url: url, folder: root.trimmed(feed.folder) })
}
return feeds
}
function loadConfig(raw) {
var value = loadJson(raw, null)
if (value && Array.isArray(value.feeds)) {
var maxFeeds = root.normalizeMaxFeeds(value.maxFeeds)
var feeds = root.sanitizeFeeds(value.feeds, maxFeeds)
root.config = Object.assign({}, value, { feeds: feeds, maxFeeds: maxFeeds })
maxFeedsDraft = maxFeeds
refreshMinutesDraft = root.normalizeRefreshMinutes(value.refreshMinutes || 5)
retentionItemsDraft = root.resolvedRetentionItems
feedModel.clear()
for (var i = 0; i < feeds.length; i++) {
feedModel.append(feeds[i])
}
}
}
function openSettings() {
resetFeedModel()
settingsOpen = true
detailOpen = false
selectedArticle = null
formControlFocused = false
refreshMinutesDraft = root.normalizeRefreshMinutes(config.refreshMinutes || 5)
retentionItemsDraft = root.resolvedRetentionItems
scrollStepDraft = root.resolvedScrollStep
maxFeedsDraft = root.resolvedMaxFeeds
}
function closeSettings() {
settingsOpen = false
formControlFocused = false
resetFeedModel()
}
function ensureFeedModel() {
if (feedModel.count > 0 || !config || !Array.isArray(config.feeds)) return
resetFeedModel()
}
function resetFeedModel() {
feedModel.clear()
if (!config || !Array.isArray(config.feeds)) return
for (var i = 0; i < config.feeds.length; i++) {
var feed = config.feeds[i]
if (feed && feed.url) feedModel.append({
name: root.feedDisplayName(feed), url: String(feed.url), folder: String(feed.folder || "")
})
}
}
function addFeed() {
if (feedModel.count >= maxFeedsDraft) {
status = "You can configure up to " + maxFeedsDraft + " feeds."
return
}
feedModel.append({ name: "", url: "", folder: "" })
}
function removeFeed(index) {
if (index >= 0 && index < feedModel.count) feedModel.remove(index)
}
function saveConfig() {
if (feedModel.count > maxFeedsDraft) {
status = "Remove " + (feedModel.count - maxFeedsDraft) + " feed(s) or raise the feed limit to " + feedModel.count + "."
return
}
var feeds = []
var seenUrls = {}
var seenNames = {}
for (var i = 0; i < feedModel.count; i++) {
var feed = feedModel.get(i)
var url = root.trimmed(feed.url)
if (!/^https?:\/\/[^\s]+$/i.test(url)) {
status = "Enter a valid http(s) URL for every feed."
return
}
var normalizedUrl = url.toLowerCase().replace(/\/$/, "")
if (seenUrls[normalizedUrl]) {
status = "Each feed URL must be unique."
return
}
seenUrls[normalizedUrl] = true
var name = root.trimmed(feed.name) || root.inferFeedName(url) || url
if (seenNames[name.toLowerCase()]) {
status = "Each feed name must be unique."
return
}
seenNames[name.toLowerCase()] = true
feeds.push({ name: name, url: url, folder: root.trimmed(feed.folder) })
}
root.config = Object.assign({}, root.config, {
feeds: feeds,
maxFeeds: maxFeedsDraft,
maxItems: root.resolvedMaxItems,
retentionItems: retentionItemsDraft,
refreshMinutes: root.normalizeRefreshMinutes(refreshMinutesDraft),
scrollStep: scrollStepDraft
})
configFile.setText(JSON.stringify(root.config, null, 2) + "\n")
settingsOpen = false
// The write is asynchronous; configFile's onSaved/onSaveFailed below
// correct this once Quickshell reports what actually happened, so a
// failed write (e.g. a broken symlink or a missing directory) is never
// reported to the user as a successful save.
status = "Saving feeds…"
refresh()
}
// Reads at most maxBytes of `path` through the shell instead of
// FileView.text(), which slurps the whole file into memory. The output is
// prefixed with one status byte so the result arrives in a single stream:
// M = missing, L = larger than maxBytes (never read), E = not a readable
// regular file, D = the contents follow. head -c still bounds the read if
// the file grows between the size check and the read.
function boundedReadCommand(path, maxBytes) {
return ["sh", "-c",
'if [ ! -e "$1" ] && [ ! -L "$1" ]; then printf M; '
+ 'elif [ ! -f "$1" ] || [ ! -r "$1" ]; then printf E; '
+ 'elif [ "$(wc -c < "$1")" -gt "$2" ]; then printf L; '
+ 'else printf D; head -c "$2" -- "$1"; fi',
"feader-rss-read", String(path), String(maxBytes)]
}
function parseBoundedRead(output, maxBytes) {
var raw = String(output || "")
var tag = raw.charAt(0)
if (tag === "M") return { state: "missing", text: "" }
if (tag === "L") return { state: "tooLarge", text: "" }
if (tag !== "D") return { state: "error", text: "" }
// Defence in depth: the shell already refused oversized files.
if (raw.length - 1 > maxBytes) return { state: "tooLarge", text: "" }
return { state: "ok", text: raw.slice(1) }
}
function readSettingsFile(proc) {
// A change notification while a read is in flight must not be lost.
if (proc.running) { proc.rereadRequested = true; return }
proc.running = true
}
function applyConfigRead(output) {
var result = root.parseBoundedRead(output, root.maxSettingsFileBytes)
if (result.state === "ok") root.loadConfig(result.text)
else if (result.state === "tooLarge")
root.status = "Feed configuration is larger than " + Math.round(root.maxSettingsFileBytes / 1024) + " KiB; not loaded."
// A missing file is the normal first-run state; anything else (a
// dangling symlink, a directory, a permissions problem) is reported
// instead of leaving the panel silently stuck on an empty configuration.
else if (result.state === "error") root.status = "Could not load feed configuration."
root.readSettingsFile(stateRead)
root.refresh()
}
function applyStateRead(output) {
var result = root.parseBoundedRead(output, root.maxSettingsFileBytes)
if (result.state === "ok") {
root.loadState(result.text)
root.stateReady = true
return
}
if (result.state === "tooLarge")
console.warn("io.github.kitsunesemcalda.feader-rss", "preferences.json exceeds "
+ root.maxSettingsFileBytes + " bytes; ignoring it")
root.preferencesReady = true
root.stateReady = true
if (root.searchQuery.trim() !== "") root.requestSearch()
else root.loadInitialArticles()
}
function loadState(raw) {
var value = loadJson(raw, null)
if (value && value.preferences) {
searchQuery = String(value.preferences.searchQuery || "")
readFilter = ["all", "unread", "read", "starred"].indexOf(String(value.preferences.readFilter)) >= 0
? String(value.preferences.readFilter) : "all"
selectedFeed = String(value.preferences.selectedFeed || "")
selectedFolder = String(value.preferences.selectedFolder || "")
selectedIndex = Math.max(0, Number(value.preferences.selectedIndex || 0))
}
preferencesReady = true
if (!config || !Array.isArray(config.feeds) || config.feeds.length === 0) {
root.replaceArticles([], 1)
return
}
if (searchQuery.trim() !== "") root.requestSearch()
else root.loadInitialArticles()
}
function saveState() {
stateFile.setText(JSON.stringify({
version: 2,
updatedAt: new Date().toISOString(),
preferences: { searchQuery: searchQuery, readFilter: readFilter, selectedFeed: selectedFeed, selectedFolder: selectedFolder, selectedIndex: selectedIndex }
}, null, 2) + "\n")
}
function articleMatches(article) {
if (!article) return false
if (readFilter === "unread" && article.read) return false
if (readFilter === "read" && !article.read) return false
if (readFilter === "starred" && !article.starred) return false
if (selectedFeed !== "" && String(article.feed || "") !== selectedFeed) return false
if (selectedFolder !== "" && root.feedFolderForName(article.feed) !== selectedFolder) return false
var query = searchQuery.trim().toLowerCase()
if (query === "") return true
if (searchResultsActive) return true
return (String(article.title || "") + " " + String(article.summary || "") + " "
+ String(article.feed || "") + " " + String(article.author || "") + " "
+ (Array.isArray(article.categories) ? article.categories.join(" ") : ""))
.toLowerCase().indexOf(query) >= 0
}
function setReadFilter(value) {
readFilter = value
selectedIndex = 0
if (preferencesReady) saveState()
}
function setSelectedFeed(value) {
selectedFeed = value
selectedFolder = ""
selectedIndex = 0
if (preferencesReady) saveState()
}
function setSelectedFolder(value) {
selectedFolder = value
selectedFeed = ""
selectedIndex = 0
if (preferencesReady) saveState()
}
function setSearchQuery(value) {
searchQuery = String(value || "")
selectedIndex = 0
searchResultsActive = false
if (preferencesReady) {
saveState()
searchDebounce.restart()
}
}
function clearSearch() {
searchField.text = ""
searchField.forceActiveFocus()
}
function setRefreshMinutes(value) {
refreshMinutesDraft = root.normalizeRefreshMinutes(value)
}
function setMaxFeeds(value) {
// An emptied field is mid-edit, not a request for the default.
if (String(value).trim() === "") return
maxFeedsDraft = root.normalizeMaxFeeds(value)
}
function setRetentionItems(value) {
var numeric = Math.floor(Number(value))
retentionItemsDraft = isFinite(numeric) && numeric >= 0 ? Math.min(100000, numeric) : 1000
}
function setScrollStep(value) {
scrollStepDraft = Math.max(10, Math.min(200, Number(value)))
}
function open() {
root.refresh()
root.controller.show()
Qt.callLater(function() { if (root.opened) setCenterHoverRevealSuppressed(true) })
}
function openFromHotkey() { openedFromHotkey = true; open() }
function close() {
detailOpen = false
if (settingsOpen) { settingsOpen = false; resetFeedModel() }
setCenterHoverRevealSuppressed(false)
root.controller.hide()
}
function closeForPopoutSwitch() { close() }
function toggle() { if (root.opened) close(); else open() }
function setCenterHoverRevealSuppressed(value) {
// The plugin-facing bar object exposes centerHoverRevealSuppressed as
// read-only and offers this setter function instead — assigning the
// property directly throws a TypeError that aborts whichever caller
// (e.g. close()) invoked it mid-function, silently breaking every
// button that routes through it. Only fall back to the direct
// assignment for a bar stub that predates the setter.
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
function firstPageLimit() {
return Math.min(root.articlePageSize, root.resolvedMaxItems)
}
// A refresh keeps everything the reader has already scrolled through
// instead of snapping back to the first page.
function currentPageLimit() {
return Math.min(root.resolvedMaxItems, Math.max(root.firstPageLimit(), root.loadedCount))
}
// Replaces the article list with a freshly loaded first page (or refresh)
// of `rawItems`, which was requested with `limit`.
function replaceArticles(rawItems, limit) {
pagesGeneration++
loadedCount = rawItems.length
moreAvailable = rawItems.length >= limit && rawItems.length < root.resolvedMaxItems
root.articles = filterToConfiguredFeeds(rawItems)
}
function loadMore() {
if (!moreAvailable || loadingMore || loading || !preferencesReady || root.settingsOpen || root.detailOpen) return
if (listProcess.running || searchProcess.running || moreProcess.running) return
var size = Math.min(root.articlePageSize, root.resolvedMaxItems - loadedCount)
if (size <= 0) { moreAvailable = false; return }
var command = searchResultsActive
? [fetchBinary, "search", "--db", dbPath, "--query", searchProcessQuery]
: [fetchBinary, "list", "--db", dbPath]
command.push("--limit", String(size), "--offset", String(loadedCount))
var names = root.configuredFeedNamesArray()
for (var i = 0; i < names.length; i++) command.push("--feed", names[i])
moreRequestSize = size
moreRequestGeneration = pagesGeneration
loadingMore = true
moreProcess.command = command
moreProcess.running = true
}
function appendMore(raw) {
loadingMore = false
if (moreRequestGeneration !== pagesGeneration) return
var result = loadJson(raw, null)
if (!result || !Array.isArray(result.items)) { moreAvailable = false; return }
loadedCount += result.items.length
moreAvailable = result.items.length >= moreRequestSize && loadedCount < root.resolvedMaxItems
var seen = {}
for (var i = 0; i < articles.length; i++) seen[articles[i].id] = true
var fresh = filterToConfiguredFeeds(result.items).filter(function(article) { return !seen[article.id] })
if (fresh.length > 0) root.articles = articles.concat(fresh)
}
// Every whimsical line lives here so the pools are easy to extend. Plain
// text only: color emoji fall back to a different font than the panel's.
function phrasePool(kind) {
var pools = {
loading: [
"Fetching", "Pondering", "Simmering", "Herding feeds", "Untangling RSS",
"Reticulating splines", "Sniffing out news", "Brewing", "Scouting",
"Foraging headlines", "Consulting the oracle", "Warming up the pixels",
"Tickling the servers", "Chasing bytes", "Bribing the CDN", "Summoning articles",
"Sharpening pencils", "Polishing headlines", "Waking the hamsters",
"Decoding XML", "Skimming the internet", "Rummaging through feeds",
"Whispering to the routers", "Counting angle brackets", "Stirring the cauldron",
"Knocking on ports", "Feeding the parser", "Dusting off the archives",
"Tuning the antenna", "Catching packets", "Ironing out the wrinkles",
"Hunting for scoops", "Unfolding the paper", "Spinning up gossip",
"Rehydrating articles", "Asking nicely", "Wrangling Atom", "Cooking up updates",
"Following the breadcrumbs", "Making tea for the scraper"
],
caughtUp: [
"All caught up", "Inbox zero. Go stretch.", "Nothing new. Enjoy the quiet.",
"You read it all. Impressive.", "All clear. New posts will land here.",
"The internet is officially finished.", "Zero unread. Legendary.",
"Go drink some water.", "Nothing left. Touch some grass.",
"Quiet on the feed front.", "You win. Come back later.",
"Clean slate. Feels good.", "Even the feeds are impressed.",
"Read everything. What now?", "Peace and quiet, brought to you by RSS."
],
refreshed: [
"Fresh as of", "Just in at", "Updated at", "Hot off the press at",
"Synced at", "Latest at", "Straight from the wire at", "Newly baked at"
],
empty: [
"No articles match the current filters.", "Nothing here. Try loosening the filters.",
"Crickets. Adjust the filters?", "No matches. The filters are strict today."
]
}
return pools[kind] || []
}
function pickPhrase(kind, n) {
var pool = root.phrasePool(kind)
if (pool.length === 0) return ""
return pool[Math.abs(Math.floor(Number(n)) || 0) % pool.length]
}
// Keyed by day so it stays put while the panel is open and changes on its
// own tomorrow.
function caughtUpMessage(day) {
return root.pickPhrase("caughtUp", day)
}
function refreshedLabel(time, seed) {
return root.pickPhrase("refreshed", seed) + " " + time
}
// Claude Code-style busy line: a glyph that pulses every tick and a verb
// that changes every ~2s.
function spinnerGlyph(tick) {
var frames = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"]
return frames[Math.abs(Math.floor(Number(tick)) || 0) % frames.length]
}
function loadingLabel(tick, seed) {
var step = Math.floor((Math.abs(Math.floor(Number(tick)) || 0)) / 14)
return root.spinnerGlyph(tick) + " " + root.pickPhrase("loading", step + (Number(seed) || 0)) + "…"
}
function listFooterText(count) {
if (loadingMore) return root.spinnerGlyph(spinnerTick) + " Loading more…"
if (moreAvailable) return "Scroll for more"
return count + (count === 1 ? " article" : " articles") + " · that's everything"
}
function maybeLoadMore() {
if (!moreAvailable || root.settingsOpen || root.detailOpen) return
if (scrollArea.contentHeight - scrollArea.contentY - scrollArea.height < scrollArea.height)
root.loadMore()
}
function refresh() {
if (fetchProcess.running || !config || !Array.isArray(config.feeds) || !config.feeds.length) return
loading = true
spinnerSeed = Math.floor(Math.random() * 1000)
status = "Refreshing…"
var command = [fetchBinary, "fetch", "--db", dbPath,
"--limit", String(root.currentPageLimit()),
"--retention", String(root.resolvedRetentionItems)]
for (var i = 0; i < config.feeds.length; i++) {
var feed = config.feeds[i]
if (feed && feed.url) command.push(root.feedDisplayName(feed), String(feed.url))
}
fetchProcess.command = command
fetchProcess.running = true
}
function loadInitialArticles() {
if (listProcess.running) return
var command = [fetchBinary, "list", "--db", dbPath, "--limit", String(root.firstPageLimit())]
var names = root.configuredFeedNamesArray()
for (var i = 0; i < names.length; i++) command.push("--feed", names[i])
listProcess.command = command
listProcess.running = true
}
function applySnapshotStats(result) {
if (!result || !isFinite(Number(result.unreadCount)) || !isFinite(Number(result.unreadFeedCount))) {
globalUnreadCount = -1
globalUnreadFeedCount = -1
return
}
globalUnreadCount = Math.max(0, Number(result.unreadCount))
globalUnreadFeedCount = Math.max(0, Number(result.unreadFeedCount))
}
function requestSearch() {
searchDebounce.stop()
if (!preferencesReady) return
if (searchQuery.trim() === "") {
searchResultsActive = false
root.loadInitialArticles()
return
}
if (searchProcess.running) return
searchProcessQuery = searchQuery
var command = [fetchBinary, "search", "--db", dbPath,
"--query", searchProcessQuery, "--limit", String(root.firstPageLimit())]
var names = root.configuredFeedNamesArray()
for (var i = 0; i < names.length; i++) command.push("--feed", names[i])
searchProcess.command = command
searchProcess.running = true
}
function configuredFeedNames() {
var names = {}
root.configuredFeedNamesArray().forEach(function(name) { names[name] = true })
return names
}
function filterToConfiguredFeeds(items) {
var names = configuredFeedNames()
return items.filter(function(article) {
return article && names[String(article.feed || "")] === true
})
}
function mergeFetched(raw) {
var result = loadJson(raw, null)
if (!result || !Array.isArray(result.items)) {
loading = false; status = "Could not refresh feeds"; return
}
feedErrors = Array.isArray(result.errors) ? result.errors : []
root.applySnapshotStats(result)
searchResultsActive = false
root.replaceArticles(result.items, root.currentPageLimit())
selectedIndex = Math.min(selectedIndex, Math.max(0, visibleArticles.length - 1))
root.notifyNewPosts(Array.isArray(result.newItems) ? result.newItems : [])
loading = false
lastUpdated = new Date().toLocaleTimeString(Qt.locale(), Locale.ShortFormat)
status = feedErrors.length
? feedErrors.length + " feed(s) failed · " + lastUpdated
: root.refreshedLabel(lastUpdated, spinnerSeed)
root.prefetchArticles()
root.updateUnreadNotification()
if (searchQuery.trim() !== "") root.requestSearch()
}
function prefetchArticles() {
// Warm the article cache in the background right after a refresh, so
// opening an unread article is usually instant instead of waiting on a
// live fetch. Runs as its own process and never blocks the UI; loadArticle
// still fetches live if an article wasn't prefetched in time.
if (prefetchProcess.running) return
prefetchProcess.command = [fetchBinary, "prefetch", "--db", dbPath, "--limit", "20", "--concurrency", "3"]
prefetchProcess.running = true
}
function reportPrefetched(raw) {
var result = loadJson(raw, null)
if (!result || !(result.prefetched > 0)) return
root.prefetchNote = result.prefetched === 1
? "1 article ready to read offline"
: result.prefetched + " articles ready to read offline"
}
function applyInitialArticles(raw) {
var result = loadJson(raw, null)
var items = (result && Array.isArray(result.items)) ? result.items : []
root.applySnapshotStats(result)
if (searchQuery.trim() !== "") {
root.requestSearch()
return
}
searchResultsActive = false
root.replaceArticles(items, root.firstPageLimit())
root.prefetchArticles()
root.updateUnreadNotification()
}
function applySearchArticles(raw) {
var result = loadJson(raw, null)
if (searchProcessQuery !== searchQuery) {
searchDebounce.restart()
return
}
root.applySnapshotStats(result)
var items = (result && Array.isArray(result.items)) ? result.items : []
searchResultsActive = true
root.replaceArticles(items, root.firstPageLimit())
selectedIndex = Math.min(selectedIndex, Math.max(0, visibleArticles.length - 1))
root.updateUnreadNotification()
}
function notifyNewPosts(items) {
if (!stateReady || articles.length === 0 || !items || items.length === 0 || !omarchyPath) return
var count = items.length
var headline = count === 1 ? "New RSS post" : count + " new RSS posts"
var description = String(items[0].title || "New article")
if (count > 1) description += " and " + (count - 1) + " more"
Quickshell.execDetached([
omarchyPath + "/bin/omarchy-notification-send",
"--app-name", "Feader RSS",
"-g", "",
"-u", "low",
headline,
description
])
}
function updateUnreadNotification() {
// Conveys the unread count through Omarchy's own notification system
// instead of the bar icon: BarIconButton renders its glyph unclipped,
// so appending a live count there bled past the icon slot and over the
// neighboring bar widget (see BarWidget.qml). This notification is
// replaced in place (via --replace-id) rather than stacking a new toast
// every refresh, and never auto-expires (-t 0) so it behaves like a
// persistent counter until the user dismisses or reads everything.
if (!omarchyPath || !stateReady) return
if (unreadCount === lastNotifiedUnreadCount) return
lastNotifiedUnreadCount = unreadCount
if (unreadCount === 0) {
unreadNotifyProcess.command = [
omarchyPath + "/bin/omarchy-notification-send",
"--app-name", "Feader RSS",
"-g", "",
"-u", "low",
"-t", "4000",
"-r", String(unreadNotificationId),
"Feader RSS",
"All caught up"
]
} else {
var headline = unreadCount === 1 ? "1 unread article" : unreadCount + " unread articles"
var description = unreadFeedCount === 1 ? "1 feed" : unreadFeedCount + " feeds"
var command = [
omarchyPath + "/bin/omarchy-notification-send",
"--app-name", "Feader RSS",
"-g", "",
"-u", "low",
"-t", "0",
"-p"
]
if (unreadNotificationId > 0) command.push("-r", String(unreadNotificationId))
command.push(headline, description)
unreadNotifyProcess.command = command
}
if (!unreadNotifyProcess.running) unreadNotifyProcess.running = true
}
function applyUnreadNotificationId(raw) {
var id = parseInt(String(raw).trim(), 10)
if (isFinite(id) && id > 0) root.unreadNotificationId = id
}
function markRead(article) {
if (!article || article.read) return article
var next = articles.slice()
var articleId = String(article.id || "")
var articleUrl = String(article.url || "")
var index = -1
for (var i = 0; i < next.length; i++) {
var candidate = next[i]
if ((articleId !== "" && String(candidate.id || "") === articleId)
|| (articleId === "" && articleUrl !== "" && String(candidate.url || "") === articleUrl)) {
index = i
break
}
}
if (index < 0) return article
var updated = Object.assign({}, next[index], { read: true })
next[index] = updated
articles = next
if (globalUnreadCount >= 0) globalUnreadCount = Math.max(0, globalUnreadCount - 1)
persistMarkRead(updated.id)
root.updateUnreadNotification()
return updated
}
function persistMarkRead(id) {
Quickshell.execDetached([fetchBinary, "mark-read", "--db", dbPath, String(id)])
}
function markAllRead() {
var next = articles.map(function(article) { return Object.assign({}, article, { read: true }) })
articles = next
var command = [fetchBinary, "mark-all", "--db", dbPath]
var names = root.configuredFeedNamesArray()
for (var i = 0; i < names.length; i++) command.push("--feed", names[i])
Quickshell.execDetached(command)
globalUnreadCount = 0
globalUnreadFeedCount = 0
root.updateUnreadNotification()
}
function markAllUnread() {
var next = articles.map(function(article) { return Object.assign({}, article, { read: false }) })
articles = next
var command = [fetchBinary, "mark-all", "--db", dbPath, "--unread"]
var names = root.configuredFeedNamesArray()
for (var i = 0; i < names.length; i++) command.push("--feed", names[i])
Quickshell.execDetached(command)
// The database may contain more articles than the current page. Let the
// next list/refresh response provide the exact global count instead of
// pretending that the visible subset is the whole store.
globalUnreadCount = -1
globalUnreadFeedCount = -1
root.updateUnreadNotification()
root.loadInitialArticles()
}
function toggleStar(article) {
if (!article || !article.id) return
var articleId = String(article.id)
var starred = !article.starred
var next = articles.slice()
for (var i = 0; i < next.length; i++) {
if (String(next[i].id || "") === articleId) {
next[i] = Object.assign({}, next[i], { starred: starred })
break