Skip to content

Commit 8beb858

Browse files
committed
feat(#5): create groups and import feeds into them, based on opml
1 parent 4f7db53 commit 8beb858

File tree

12 files changed

+178
-74
lines changed

12 files changed

+178
-74
lines changed

api/group.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ func (f groupAPI) Create(c echo.Context) error {
3333
return err
3434
}
3535

36-
if err := f.srv.Create(c.Request().Context(), &req); err != nil {
36+
resp, err := f.srv.Create(c.Request().Context(), &req)
37+
if err != nil {
3738
return err
3839
}
3940

40-
return c.NoContent(http.StatusCreated)
41+
return c.JSON(http.StatusCreated, resp)
4142
}
4243

4344
func (f groupAPI) Update(c echo.Context) error {

frontend/src/lib/api/group.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ export async function allGroups() {
77
}
88

99
export async function createGroup(name: string) {
10-
return await api.post('groups', {
11-
json: {
12-
name: name
13-
}
14-
});
10+
return await api
11+
.post('groups', {
12+
json: {
13+
name: name
14+
}
15+
})
16+
.json<{ id: number }>();
1517
}
1618

1719
export async function updateGroup(id: number, name: string) {

frontend/src/lib/opml.ts

+48-11
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,55 @@
11
export function parse(content: string) {
2-
const feeds: { name: string; link: string }[] = [];
2+
type feedT = {
3+
name: string;
4+
link: string;
5+
};
6+
type groupT = {
7+
name: string;
8+
feeds: feedT[];
9+
};
10+
const groups = new Map<string, groupT>();
11+
const defaultGroup = { name: 'Default', feeds: [] };
12+
groups.set('Default', defaultGroup);
13+
14+
function dfs(parentGroup: groupT | null, node: Element) {
15+
if (node.tagName !== 'outline') {
16+
return;
17+
}
18+
if (node.getAttribute('type')?.toLowerCase() == 'rss') {
19+
if (!parentGroup) {
20+
parentGroup = defaultGroup;
21+
}
22+
parentGroup.feeds.push({
23+
name: node.getAttribute('title') || node.getAttribute('text') || '',
24+
link: node.getAttribute('xmlUrl') || node.getAttribute('htmlUrl') || ''
25+
});
26+
return;
27+
}
28+
if (!node.children.length) {
29+
return;
30+
}
31+
const nodeName = node.getAttribute('text') || node.getAttribute('title') || '';
32+
const name = parentGroup ? parentGroup.name + '/' + nodeName : nodeName;
33+
let curGroup = groups.get(name);
34+
if (!curGroup) {
35+
curGroup = { name: name, feeds: [] };
36+
groups.set(name, curGroup);
37+
}
38+
for (const n of node.children) {
39+
dfs(curGroup, n);
40+
}
41+
}
42+
343
const xmlDoc = new DOMParser().parseFromString(content, 'text/xml');
4-
const outlines = xmlDoc.getElementsByTagName('outline');
5-
6-
for (let i = 0; i < outlines.length; i++) {
7-
const outline = outlines.item(i);
8-
if (!outline) continue;
9-
const link = outline.getAttribute('xmlUrl') || outline.getAttribute('htmlUrl') || '';
10-
if (!link) continue;
11-
const name = outline.getAttribute('title') || outline.getAttribute('text') || '';
12-
feeds.push({ name, link });
44+
const body = xmlDoc.getElementsByTagName('body')[0];
45+
if (!body) {
46+
return [];
47+
}
48+
for (const n of body.children) {
49+
dfs(null, n);
1350
}
1451

15-
return feeds;
52+
return Array.from(groups.values());
1653
}
1754

1855
export function dump(data: { name: string; feeds: { name: string; link: string }[] }[]) {

frontend/src/routes/(authed)/feeds/ActionAdd.svelte

+3-5
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@
106106
}}
107107
required
108108
/>
109+
<p class="text-sm text-muted-foreground">
110+
The existing feed with the same link will be override.
111+
</p>
109112
</div>
110113

111114
<div>
@@ -121,11 +124,6 @@
121124
}}
122125
required
123126
/>
124-
{#if formData.name}
125-
<p class="text-sm text-muted-foreground">
126-
The existing feed with the same link will be renamed as <b>{formData.name}</b>.
127-
</p>
128-
{/if}
129127
</div>
130128

131129
<div>

frontend/src/routes/(authed)/feeds/ActionOPML.svelte

+55-40
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,71 @@
11
<script lang="ts">
22
import type { groupFeeds } from './+page';
33
import * as Sheet from '$lib/components/ui/sheet';
4-
import * as Select from '$lib/components/ui/select';
54
import * as Tabs from '$lib/components/ui/tabs';
65
import { Button } from '$lib/components/ui/button';
76
import { Label } from '$lib/components/ui/label';
87
import { createFeed } from '$lib/api/feed';
98
import { toast } from 'svelte-sonner';
109
import { invalidateAll } from '$app/navigation';
1110
import { dump, parse } from '$lib/opml';
11+
import { FolderIcon } from 'lucide-svelte';
12+
import { createGroup } from '$lib/api/group';
1213
1314
export let groups: groupFeeds[];
1415
export let open: boolean;
1516
1617
let uploadedOpmls: FileList;
1718
$: parseOPML(uploadedOpmls);
18-
let opmlGroup = { id: groups[0].id, name: groups[0].name };
19-
let parsedOpmlFeeds: { name: string; link: string }[] = [];
19+
let parsedGroupFeeds: { name: string; feeds: { name: string; link: string }[] }[] = [];
20+
let importing = false;
21+
2022
$: {
2123
if (!open) {
22-
parsedOpmlFeeds = [];
24+
parsedGroupFeeds = [];
2325
}
2426
}
2527
2628
function parseOPML(opmls: FileList) {
2729
if (!opmls) return;
30+
2831
const reader = new FileReader();
2932
reader.onload = (f) => {
3033
const content = f.target?.result?.toString();
3134
if (!content) {
3235
toast.error('Failed to load file content');
3336
return;
3437
}
35-
parsedOpmlFeeds = parse(content);
36-
console.log(parsedOpmlFeeds);
38+
parsedGroupFeeds = parse(content).filter((v) => v.feeds.length > 0);
39+
console.log(parsedGroupFeeds);
3740
};
3841
reader.readAsText(opmls[0]);
3942
}
4043
4144
async function handleImportFeeds() {
42-
try {
43-
await createFeed({ group_id: opmlGroup.id, feeds: parsedOpmlFeeds });
44-
toast.success('Feeds have been imported. Refreshing is running in the background');
45-
} catch (e) {
46-
toast.error((e as Error).message);
45+
importing = true;
46+
let success = 0;
47+
const existingGroups = groups.map((v) => {
48+
return { id: v.id, name: v.name };
49+
});
50+
for (const g of parsedGroupFeeds) {
51+
try {
52+
let groupID = existingGroups.find((v) => v.name === g.name)?.id;
53+
if (groupID === undefined) {
54+
groupID = (await createGroup(g.name)).id;
55+
toast.success(`Created group ${g.name}`);
56+
}
57+
await createFeed({ group_id: groupID, feeds: g.feeds });
58+
toast.success(`Imported into group ${g.name}`);
59+
success++;
60+
} catch (e) {
61+
toast.error(`Failed to import group ${g.name}, error: ${(e as Error).message}`);
62+
break;
63+
}
64+
}
65+
if (success === parsedGroupFeeds.length) {
66+
toast.success('All feeds have been imported. Refreshing is running in the background');
4767
}
68+
importing = false;
4869
invalidateAll();
4970
}
5071
@@ -69,7 +90,7 @@
6990
</script>
7091

7192
<Sheet.Root bind:open>
72-
<Sheet.Content class="w-full md:w-auto">
93+
<Sheet.Content class="w-full md:max-w-[700px] overflow-y-auto">
7394
<Sheet.Header>
7495
<Sheet.Title>Import or Export Feeds</Sheet.Title>
7596
<Sheet.Description>
@@ -87,25 +108,6 @@
87108
</Tabs.List>
88109
<Tabs.Content value="import">
89110
<form class="space-y-2" on:submit|preventDefault={handleImportFeeds}>
90-
<div>
91-
<Label for="group">Group</Label>
92-
<Select.Root
93-
disabled={groups.length < 2}
94-
items={groups.map((v) => {
95-
return { value: v.id, label: v.name };
96-
})}
97-
onSelectedChange={(v) => v && (opmlGroup.id = v.value)}
98-
>
99-
<Select.Trigger>
100-
<Select.Value placeholder={opmlGroup.name} />
101-
</Select.Trigger>
102-
<Select.Content>
103-
{#each groups as g}
104-
<Select.Item value={g.id}>{g.name}</Select.Item>
105-
{/each}
106-
</Select.Content>
107-
</Select.Root>
108-
</div>
109111
<div>
110112
<Label for="feed_file">File</Label>
111113
<input
@@ -117,21 +119,34 @@
117119
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
118120
/>
119121
</div>
120-
{#if parsedOpmlFeeds.length > 0}
122+
{#if parsedGroupFeeds.length > 0}
121123
<div>
122-
<p class="text-sm text-muted-foreground">Parsed out {parsedOpmlFeeds.length} feeds</p>
124+
<p class="text-sm text-green-700">Parsed successfully.</p>
123125
<div
124-
class="max-h-[200px] overflow-scroll p-2 rounded-md border bg-muted text-muted-foreground text-nowrap"
126+
class="p-2 rounded-md border bg-muted/40 text-muted-foreground text-nowrap overflow-x-auto"
125127
>
126-
<ul>
127-
{#each parsedOpmlFeeds as feed, index}
128-
<li>{index + 1}. <b>{feed.name}</b> {feed.link}</li>
129-
{/each}
130-
</ul>
128+
{#each parsedGroupFeeds as group}
129+
<div class="flex flex-row items-center gap-1">
130+
<FolderIcon size={14} />{group.name}
131+
</div>
132+
<ul class="list-inside list-decimal ml-[2ch] [&:not(:last-child)]:mb-2">
133+
{#each group.feeds as feed}
134+
<li>{feed.name}, {feed.link}</li>
135+
{/each}
136+
</ul>
137+
{/each}
131138
</div>
132139
</div>
133140
{/if}
134-
<Button type="submit">Import</Button>
141+
<div class="text-sm text-secondary-foreground">
142+
<p>Note:</p>
143+
<p>
144+
1. Feeds will be imported into the corresponding group, which will be created
145+
automatically if it does not exist.
146+
</p>
147+
<p>2. The existing feed with the same link will be override.</p>
148+
</div>
149+
<Button type="submit" disabled={importing}>Import</Button>
135150
</form>
136151
</Tabs.Content>
137152
<Tabs.Content value="export">

frontend/src/routes/+layout.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
</svelte:head>
1212

1313
<ModeWatcher defaultMode="system" />
14-
<Toaster position="top-right" richColors closeButton />
14+
<Toaster position="top-right" richColors closeButton visibleToasts={10} />
1515

1616
<!-- h-screen does not work properly on mobile. Use calc(100dvh) instead.
1717
https://stackoverflow.com/a/76120728/12812480 -->

model/feed.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ type Feed struct {
1616
ID uint `gorm:"primarykey"`
1717
CreatedAt time.Time
1818
UpdatedAt time.Time
19-
DeletedAt soft_delete.DeletedAt
19+
DeletedAt soft_delete.DeletedAt `gorm:"uniqueIndex:idx_link"`
2020

2121
Name *string `gorm:"name;not null"`
22-
Link *string `gorm:"link;not null"` // FIX: unique index?
22+
Link *string `gorm:"link;not null;uniqueIndex:idx_link"`
2323
// LastBuild is the last time the content of the feed changed
2424
LastBuild *time.Time `gorm:"last_build"`
2525
// Failure is the reason of failure. If it is not null or empty, the fetch processor

repo/feed.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/0x2e/fusion/model"
77

88
"gorm.io/gorm"
9+
"gorm.io/gorm/clause"
910
)
1011

1112
func NewFeed(db *gorm.DB) *Feed {
@@ -47,7 +48,10 @@ func (f Feed) Get(id uint) (*model.Feed, error) {
4748
}
4849

4950
func (f Feed) Create(data []*model.Feed) error {
50-
return f.db.Create(data).Error
51+
return f.db.Clauses(clause.OnConflict{
52+
Columns: []clause.Column{{Name: "link"}, {Name: "deleted_at"}},
53+
DoUpdates: clause.AssignmentColumns([]string{"name", "link"}),
54+
}).Create(data).Error
5155
}
5256

5357
func (f Feed) Update(id uint, feed *model.Feed) error {

repo/repo.go

+43
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package repo
22

33
import (
44
"errors"
5+
"log"
56

67
"github.com/0x2e/fusion/conf"
78
"github.com/0x2e/fusion/model"
@@ -27,6 +28,48 @@ func Init() {
2728
}
2829

2930
func migrage() {
31+
// The verison after v0.8.7 will add a unique index to Feed.Link.
32+
// We must delete any duplicate feeds before AutoMigrate applies the
33+
// new unique constraint.
34+
err := DB.Transaction(func(tx *gorm.DB) error {
35+
// query duplicate feeds
36+
dupFeeds := make([]model.Feed, 0)
37+
err := tx.Model(&model.Feed{}).Where(
38+
"link IN (?)",
39+
tx.Model(&model.Feed{}).Select("link").Group("link").
40+
Having("count(link) > 1"),
41+
).Order("link, id").Find(&dupFeeds).Error
42+
if err != nil {
43+
return err
44+
}
45+
46+
// filter out feeds that will be deleted.
47+
// we've queried with order, so the first one is the one we should keep.
48+
distinct := map[string]uint{}
49+
deleteIDs := make([]uint, 0, len(dupFeeds))
50+
for _, f := range dupFeeds {
51+
if _, ok := distinct[*f.Link]; !ok {
52+
distinct[*f.Link] = f.ID
53+
continue
54+
}
55+
deleteIDs = append(deleteIDs, f.ID)
56+
log.Println("delete duplicate feed: ", f.ID, *f.Name, *f.Link)
57+
}
58+
59+
if len(deleteIDs) > 0 {
60+
// **hard** delete duplicate feeds and their items
61+
err = tx.Where("id IN ?", deleteIDs).Unscoped().Delete(&model.Feed{}).Error
62+
if err != nil {
63+
return err
64+
}
65+
return tx.Where("feed_id IN ?", deleteIDs).Unscoped().Delete(&model.Item{}).Error
66+
}
67+
return nil
68+
})
69+
if err != nil {
70+
panic(err)
71+
}
72+
3073
// FIX: gorm not auto drop index and change 'not null'
3174
if err := DB.AutoMigrate(&model.Feed{}, &model.Group{}, &model.Item{}); err != nil {
3275
panic(err)

server/feed.go

-3
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,6 @@ func (f Feed) Create(ctx context.Context, req *ReqFeedCreate) error {
9393
}
9494

9595
if err := f.repo.Create(feeds); err != nil {
96-
if errors.Is(err, repo.ErrDuplicatedKey) {
97-
err = NewBizError(err, http.StatusBadRequest, "link is not allowed to be the same as other feeds")
98-
}
9996
return err
10097
}
10198

0 commit comments

Comments
 (0)