feat: enhance Shiori import with progress indicator and error handling
This commit is contained in:
parent
b1ac38d4ab
commit
c3b15a14d2
13 changed files with 1015 additions and 143 deletions
|
@ -140,7 +140,7 @@ func (h *Handler) handleImportOPML(c *gin.Context) {
|
|||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
fmt.Printf("OPML Import - Error getting form file: %v\n", err)
|
||||
|
||||
|
||||
// Check if URL is provided instead
|
||||
url := c.PostForm("url")
|
||||
if url != "" {
|
||||
|
@ -177,7 +177,7 @@ func (h *Handler) handleImportOPML(c *gin.Context) {
|
|||
}()
|
||||
|
||||
// Log file information
|
||||
fmt.Printf("OPML Import - File: %s, Size: %d, Content-Type: %s\n",
|
||||
fmt.Printf("OPML Import - File: %s, Size: %d, Content-Type: %s\n",
|
||||
header.Filename, header.Size, header.Header.Get("Content-Type"))
|
||||
|
||||
// Check file size
|
||||
|
@ -188,8 +188,8 @@ func (h *Handler) handleImportOPML(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Check file extension
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".opml") &&
|
||||
!strings.HasSuffix(strings.ToLower(header.Filename), ".xml") {
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".opml") &&
|
||||
!strings.HasSuffix(strings.ToLower(header.Filename), ".xml") {
|
||||
fmt.Printf("OPML Import - Invalid file extension: %s\n", header.Filename)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "File must be an OPML or XML file"})
|
||||
return
|
||||
|
@ -203,11 +203,11 @@ func (h *Handler) handleImportOPML(c *gin.Context) {
|
|||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Error reading file: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Log the first few bytes of the file
|
||||
sample := string(sampleBuf[:n])
|
||||
fmt.Printf("OPML Import - File sample: %s\n", sample[:min(100, len(sample))])
|
||||
|
||||
|
||||
// Reset the file pointer to the beginning
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
fmt.Printf("OPML Import - Error resetting file pointer: %v\n", err)
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.18.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/node": "^22.13.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
@ -22,8 +22,8 @@
|
|||
"globals": "^15.14.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte": "^5.22.1",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.0",
|
||||
|
@ -169,9 +169,11 @@
|
|||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.34.8", "", { "os": "win32", "cpu": "x64" }, "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||
|
||||
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.17.2", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vypk02baf7qd3SOB1uUwUC/3Oka+srPo2J0a8YN3EfJypRshDkNx9HzNKjSmhOnGWwT+SSO06+N0mAb8iVTmTQ=="],
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.18.0", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-4DGCGiwNzgnPJySlMe/Qi6rKMK3ntphJaV95BTW+aggaTIAVZ5x3Bp+LURVLMxAEAtWAI5U449NafVxTS+kXbQ=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.0.3", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.0", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.15", "vitefu": "^1.0.4" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw=="],
|
||||
|
||||
|
@ -209,8 +211,6 @@
|
|||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"acorn-typescript": ["acorn-typescript@1.4.13", "", { "peerDependencies": { "acorn": ">=8.9.0" } }, "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
@ -451,7 +451,7 @@
|
|||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"svelte": ["svelte@5.20.1", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "acorn-typescript": "^1.4.13", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.3", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-aCARru2WTdzJl55Ws8SK27+kvQwd8tijl4kY7NoDUXUHtTHhxMa8Lf6QNZKmU7cuPu3jjFloDO1j5HgYJNIIWg=="],
|
||||
"svelte": ["svelte@5.22.1", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^1.4.3", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-b9DQGnfrZc+km4u3j6qkEY87pYe23yfgBT03CkeBlYST+Wzij7ut1o0BSoQ+UmiyAO1nPh9DMJJCoGDdUOrunw=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.1.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-v0j7yLbT29MezzaQJPEDwksybTE2Ups9rUxEXy92T06TiA0cbqcO8wAOwNUVkFW6B0hsYHA+oAX3BS8b/2oHtw=="],
|
||||
|
||||
|
|
|
@ -22,8 +22,8 @@
|
|||
"@eslint/js": "^9.18.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.18.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/node": "^22.13.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
@ -31,8 +31,8 @@
|
|||
"globals": "^15.14.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte": "^5.22.1",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.0"
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import type { ReadLaterItem } from './types';
|
||||
|
||||
// Shiori credentials interface
|
||||
interface ShioriCredentials {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function createReadLaterStore() {
|
||||
const { subscribe, set, update } = writable<ReadLaterItem[]>([]);
|
||||
let showArchived = false;
|
||||
|
@ -31,7 +38,6 @@ function createReadLaterStore() {
|
|||
...item,
|
||||
createdAt: new Date(item.createdAt),
|
||||
updatedAt: new Date(item.updatedAt),
|
||||
savedAt: new Date(item.savedAt),
|
||||
readAt: item.readAt ? new Date(item.readAt) : undefined,
|
||||
archivedAt: item.archivedAt ? new Date(item.archivedAt) : undefined
|
||||
},
|
||||
|
@ -121,6 +127,24 @@ function createReadLaterStore() {
|
|||
console.error('Failed to load read later items:', error);
|
||||
set([]);
|
||||
}
|
||||
},
|
||||
importFromShiori: async (creds: ShioriCredentials) => {
|
||||
const response = await fetch('/api/readlist/import/shiori', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(creds)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to import from Shiori');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
getImportStatus: async (importId: string) => {
|
||||
const response = await fetch(`/api/readlist/import/status/${importId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get import status');
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,36 +1,65 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { readlist } from '$lib/readlist';
|
||||
import CardList from '$lib/components/CardList.svelte';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import type { ReadLaterItem } from '$lib/types';
|
||||
|
||||
let items: ReadLaterItem[] = [];
|
||||
let url = '';
|
||||
let isLoading = false;
|
||||
let error: string | null = null;
|
||||
let showArchived = false;
|
||||
let archivingItems: Record<string, boolean> = {};
|
||||
let importCount = 0;
|
||||
let searchQuery = '';
|
||||
let showArchived = false;
|
||||
|
||||
onMount(() => {
|
||||
loadItems();
|
||||
// Shiori import
|
||||
let showShioriImport = false;
|
||||
let shioriUrl = '';
|
||||
let shioriUsername = '';
|
||||
let shioriPassword = '';
|
||||
let isImportingShiori = false;
|
||||
let shioriImportCount = 0;
|
||||
let shioriError: string | null = null;
|
||||
let currentImportId: string | null = null;
|
||||
let importProgress = {
|
||||
totalBookmarks: 0,
|
||||
importedCount: 0,
|
||||
failedCount: 0,
|
||||
inProgress: false
|
||||
};
|
||||
let statusCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
await readlist.load(showArchived);
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
await readlist.load(showArchived);
|
||||
const unsubscribe = readlist.subscribe((value) => {
|
||||
items = value;
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load items';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchived() {
|
||||
showArchived = !showArchived;
|
||||
await loadItems();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!url) return;
|
||||
async function handleAddUrl() {
|
||||
if (!url.trim()) {
|
||||
error = 'Please enter a URL';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await readlist.add(url);
|
||||
url = '';
|
||||
|
@ -41,12 +70,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleDelete(item: ReadLaterItem) {
|
||||
readlist.delete(item.id);
|
||||
}
|
||||
|
||||
async function handleToggleArchive(item: ReadLaterItem) {
|
||||
archivingItems[item.id] = true;
|
||||
try {
|
||||
if (item.archivedAt) {
|
||||
await readlist.unarchive(item.id);
|
||||
|
@ -55,109 +79,333 @@
|
|||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update archive status';
|
||||
} finally {
|
||||
archivingItems[item.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: ReadLaterItem) {
|
||||
if (!confirm(`Are you sure you want to delete "${item.title}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await readlist.delete(item.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete item';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleShowArchived() {
|
||||
showArchived = readlist.toggleShowArchived();
|
||||
await loadItems();
|
||||
}
|
||||
|
||||
function getFilteredItems() {
|
||||
if (!searchQuery) {
|
||||
return $readlist;
|
||||
return items;
|
||||
}
|
||||
|
||||
return $readlist.filter(
|
||||
const query = searchQuery.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.url.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleImportFromShiori() {
|
||||
if (!shioriUrl || !shioriUsername || !shioriPassword) {
|
||||
shioriError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
isImportingShiori = true;
|
||||
shioriError = null;
|
||||
try {
|
||||
// Start the import process - this returns an import ID
|
||||
const result = await readlist.importFromShiori({
|
||||
url: shioriUrl,
|
||||
username: shioriUsername,
|
||||
password: shioriPassword
|
||||
});
|
||||
|
||||
// Store the import ID
|
||||
currentImportId = result.id;
|
||||
|
||||
// Start polling for status updates
|
||||
if (statusCheckInterval) {
|
||||
clearInterval(statusCheckInterval);
|
||||
}
|
||||
|
||||
statusCheckInterval = setInterval(async () => {
|
||||
if (currentImportId) {
|
||||
try {
|
||||
const status = await readlist.getImportStatus(currentImportId);
|
||||
importProgress = {
|
||||
totalBookmarks: status.totalBookmarks,
|
||||
importedCount: status.importedCount,
|
||||
failedCount: status.failedCount,
|
||||
inProgress: status.inProgress
|
||||
};
|
||||
|
||||
// If import is complete, stop polling and update count
|
||||
if (!status.inProgress) {
|
||||
if (statusCheckInterval) {
|
||||
clearInterval(statusCheckInterval);
|
||||
statusCheckInterval = null;
|
||||
}
|
||||
shioriImportCount = status.importedCount;
|
||||
isImportingShiori = false;
|
||||
|
||||
// Reload the list to show new items
|
||||
await readlist.load(showArchived);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking import status:', e);
|
||||
}
|
||||
}
|
||||
}, 1000); // Check every second
|
||||
|
||||
// Clear form
|
||||
shioriUrl = '';
|
||||
shioriUsername = '';
|
||||
shioriPassword = '';
|
||||
showShioriImport = false;
|
||||
} catch (e) {
|
||||
shioriError = e instanceof Error ? e.message : 'Failed to import from Shiori';
|
||||
isImportingShiori = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up interval on component destroy
|
||||
onDestroy(() => {
|
||||
if (statusCheckInterval) {
|
||||
clearInterval(statusCheckInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1 class="title">Read Later</h1>
|
||||
<section class="section">
|
||||
<h1 class="title">Read Later</h1>
|
||||
|
||||
<form onsubmit={handleSubmit} class="mb-4">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
class="input"
|
||||
type="url"
|
||||
placeholder="Enter a URL to save"
|
||||
bind:value={url}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary" disabled={isLoading || !url}>
|
||||
{#if isLoading}
|
||||
<span class="icon">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
{:else}
|
||||
Add Link
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="help is-danger">{error}</p>
|
||||
<div class="notification is-danger">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<div class="level mb-4">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<button class="button is-small" onclick={toggleArchived}>
|
||||
<span class="icon">
|
||||
<i class="fas fa-archive"></i>
|
||||
</span>
|
||||
<span>{showArchived ? 'Hide Archived' : 'Show Archived'}</span>
|
||||
</button>
|
||||
{#if importCount > 0}
|
||||
<div class="notification is-success">
|
||||
<p>Successfully added {importCount} URLs.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if shioriImportCount > 0}
|
||||
<div class="notification is-success">
|
||||
<p>Successfully imported {shioriImportCount} bookmarks from Shiori.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Import progress indicator -->
|
||||
{#if isImportingShiori}
|
||||
<div class="notification is-warning">
|
||||
{#if importProgress.totalBookmarks === 0}
|
||||
<strong>Connecting to Shiori and preparing for import...</strong>
|
||||
{:else}
|
||||
<strong>Import in progress:</strong>
|
||||
{importProgress.importedCount} imported, {importProgress.failedCount} failed out of {importProgress.totalBookmarks}
|
||||
<progress
|
||||
class="progress is-primary"
|
||||
value={importProgress.importedCount + importProgress.failedCount}
|
||||
max={importProgress.totalBookmarks}
|
||||
></progress>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- End of import progress indicator -->
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="url"
|
||||
placeholder="Enter URL to save"
|
||||
bind:value={url}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') handleAddUrl();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-primary"
|
||||
on:click={handleAddUrl}
|
||||
disabled={isLoading || !url}
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-item">
|
||||
<button class="button is-info" on:click={() => (showShioriImport = !showShioriImport)}>
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-import"></i>
|
||||
</span>
|
||||
<span>Import from Shiori</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<button class="button" class:is-info={showArchived} on:click={handleToggleShowArchived}>
|
||||
<span class="icon">
|
||||
<i class="fas fa-archive"></i>
|
||||
</span>
|
||||
<span>{showArchived ? 'Hide Archived' : 'Show Archived'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<SearchBar
|
||||
placeholder="Search saved links by title, description, or URL..."
|
||||
bind:value={searchQuery}
|
||||
{#if showShioriImport}
|
||||
<div class="box mb-4">
|
||||
<h2 class="title is-5">Import from Shiori</h2>
|
||||
|
||||
{#if shioriError}
|
||||
<div class="notification is-danger">
|
||||
<p>{shioriError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="shiori-url">Shiori URL</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="shiori-url"
|
||||
class="input"
|
||||
type="url"
|
||||
placeholder="https://shiori.example.com"
|
||||
bind:value={shioriUrl}
|
||||
/>
|
||||
</div>
|
||||
<p class="help">The URL of your Shiori instance</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="shiori-username">Username</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="shiori-username"
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
bind:value={shioriUsername}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="shiori-password">Password</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="shiori-password"
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={shioriPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-primary"
|
||||
on:click={handleImportFromShiori}
|
||||
disabled={isImportingShiori || !shioriUrl || !shioriUsername || !shioriPassword}
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-import" class:fa-spin={isImportingShiori}></i>
|
||||
</span>
|
||||
<span>{isImportingShiori ? 'Importing...' : 'Import'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button
|
||||
class="button"
|
||||
on:click={() => (showShioriImport = false)}
|
||||
disabled={isImportingShiori}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add status message when import is in progress -->
|
||||
{#if isImportingShiori}
|
||||
<div class="notification is-warning mt-3">
|
||||
<p class="has-text-weight-bold">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
Import is running in the background
|
||||
</p>
|
||||
<p>
|
||||
You can close this form and the import will continue. Progress will be shown at the
|
||||
top of the page.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4">
|
||||
<SearchBar placeholder="Search saved links..." bind:value={searchQuery} />
|
||||
</div>
|
||||
|
||||
<CardList
|
||||
{items}
|
||||
renderCard={(item) => {
|
||||
const readLaterItem = item as ReadLaterItem;
|
||||
return {
|
||||
title: readLaterItem.title,
|
||||
description: readLaterItem.description,
|
||||
timestamp: readLaterItem.createdAt,
|
||||
actions: [
|
||||
{
|
||||
icon: 'fas fa-book-reader',
|
||||
label: 'Read',
|
||||
href: `/readlist/${readLaterItem.id}`
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-external-link-alt',
|
||||
label: 'Original',
|
||||
href: readLaterItem.url,
|
||||
target: '_blank'
|
||||
},
|
||||
{
|
||||
icon: readLaterItem.archivedAt ? 'fas fa-box-open' : 'fas fa-archive',
|
||||
label: readLaterItem.archivedAt ? 'Unarchive' : 'Archive',
|
||||
onClick: () => handleToggleArchive(readLaterItem)
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-trash',
|
||||
label: 'Delete',
|
||||
onClick: () => handleDelete(readLaterItem)
|
||||
}
|
||||
]
|
||||
};
|
||||
}}
|
||||
emptyMessage="No saved links yet."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardList
|
||||
items={getFilteredItems()}
|
||||
renderCard={(item) => {
|
||||
const readLaterItem = item as ReadLaterItem;
|
||||
return {
|
||||
title: readLaterItem.title,
|
||||
description: readLaterItem.description,
|
||||
timestamp: readLaterItem.createdAt,
|
||||
actions: [
|
||||
{
|
||||
icon: 'fas fa-book-reader',
|
||||
label: 'Read',
|
||||
href: `/readlist/${readLaterItem.id}`
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-external-link-alt',
|
||||
label: 'Original',
|
||||
href: readLaterItem.url,
|
||||
target: '_blank'
|
||||
},
|
||||
{
|
||||
icon: readLaterItem.archivedAt ? 'fas fa-box-open' : 'fas fa-archive',
|
||||
label: readLaterItem.archivedAt ? 'Unarchive' : 'Archive',
|
||||
onClick: () => handleToggleArchive(readLaterItem)
|
||||
},
|
||||
{
|
||||
icon: 'fas fa-trash',
|
||||
label: 'Delete',
|
||||
onClick: () => handleDelete(readLaterItem)
|
||||
}
|
||||
]
|
||||
};
|
||||
}}
|
||||
emptyMessage="No saved links yet."
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
5
go.mod
5
go.mod
|
@ -35,13 +35,17 @@ require (
|
|||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.10 // indirect
|
||||
github.com/mmcdole/gofeed v1.3.0 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.1.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
|
@ -52,6 +56,7 @@ require (
|
|||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
|
|
9
go.sum
9
go.sum
|
@ -75,6 +75,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
|
@ -85,6 +87,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
|
@ -93,6 +97,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
|||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
|
@ -101,6 +106,8 @@ github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4
|
|||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
@ -212,6 +219,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
|
|
40
main.go
40
main.go
|
@ -166,14 +166,42 @@ func parseConfig() Configuration {
|
|||
}
|
||||
|
||||
func handleFrontend(c *gin.Context) {
|
||||
// Don't serve API routes
|
||||
if path.Dir(c.Request.URL.Path) == "/api" {
|
||||
c.Status(http.StatusNotFound)
|
||||
requestPath := c.Request.URL.Path
|
||||
|
||||
// Check if the path is a direct file request (CSS, JS, etc.)
|
||||
if isStaticFileRequest(requestPath) {
|
||||
// Use the embedded filesystem instead of reading from disk
|
||||
if err := serveStaticFile(c, "frontend/build"); err != nil {
|
||||
// If not found in build, try static folder
|
||||
if err := serveStaticFile(c, "frontend/static"); err != nil {
|
||||
// Return 404 for static files that should exist
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := serveStaticFile(c, "frontend/build")
|
||||
if err != nil { // if serveStaticFile returns an error, it has already tried to serve index.html as fallback
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// For SPA navigation - serve index.html for all non-API routes
|
||||
// Use the embedded filesystem
|
||||
if err := serveStaticFile(c, "frontend/build"); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error reading index.html")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// isStaticFileRequest determines if a path is for a static file (CSS, JS, image, etc.)
|
||||
func isStaticFileRequest(path string) bool {
|
||||
staticExtensions := []string{
|
||||
".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
|
||||
".json", ".woff", ".woff2", ".ttf", ".eot",
|
||||
}
|
||||
|
||||
for _, ext := range staticExtensions {
|
||||
if strings.HasSuffix(path, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -9,15 +9,18 @@ import (
|
|||
|
||||
// ReadLaterItem represents a saved link with its reader mode content
|
||||
type ReadLaterItem struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ReadAt *time.Time `json:"readAt"`
|
||||
ArchivedAt *time.Time `json:"archivedAt"`
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
URL string `json:"url" gorm:"not null"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Status string `json:"status" gorm:"default:unread"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastModified time.Time `json:"lastModified"`
|
||||
ReadAt *time.Time `json:"readAt"`
|
||||
ArchivedAt *time.Time `json:"archivedAt"`
|
||||
}
|
||||
|
||||
// ParseURL fetches the URL and extracts readable content
|
||||
|
|
|
@ -27,6 +27,8 @@ func (h *Handler) RegisterRoutes(group *gin.RouterGroup) {
|
|||
readlist.POST("/:id/archive", h.handleArchive)
|
||||
readlist.POST("/:id/unarchive", h.handleUnarchive)
|
||||
readlist.DELETE("/:id", h.handleDelete)
|
||||
readlist.POST("/import/shiori", h.handleImportFromShiori)
|
||||
readlist.GET("/import/status/:id", h.handleImportStatus)
|
||||
}
|
||||
|
||||
// Test endpoint
|
||||
|
@ -124,3 +126,47 @@ func (h *Handler) handleUnarchive(c *gin.Context) {
|
|||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleImportFromShiori handles importing bookmarks from a Shiori instance
|
||||
func (h *Handler) handleImportFromShiori(c *gin.Context) {
|
||||
var creds ShioriCredentials
|
||||
if err := c.ShouldBindJSON(&creds); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if creds.URL == "" || creds.Username == "" || creds.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "URL, username, and password are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start the import process
|
||||
importID, err := h.service.ImportFromShiori(creds)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the import ID
|
||||
c.JSON(http.StatusAccepted, gin.H{"importId": importID})
|
||||
}
|
||||
|
||||
// handleImportStatus handles getting the status of an import operation
|
||||
func (h *Handler) handleImportStatus(c *gin.Context) {
|
||||
importID := c.Param("id")
|
||||
if importID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Import ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get import status
|
||||
status, exists := h.service.GetImportStatus(importID)
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Import not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the status
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
|
|
@ -1,21 +1,49 @@
|
|||
package readlist
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-shiori/go-readability"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"jaytaylor.com/html2text"
|
||||
)
|
||||
|
||||
// ImportStatus represents the status of a Shiori import operation
|
||||
type ImportStatus struct {
|
||||
ID string `json:"id"`
|
||||
TotalBookmarks int `json:"totalBookmarks"`
|
||||
ImportedCount int `json:"importedCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
InProgress bool `json:"inProgress"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Service handles read later operations
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
imports map[string]*ImportStatus
|
||||
importsMutex sync.RWMutex
|
||||
rateLimiter *time.Ticker // For rate limiting API calls
|
||||
}
|
||||
|
||||
// NewService creates a new read later service
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{db: db}
|
||||
return &Service{
|
||||
db: db,
|
||||
imports: make(map[string]*ImportStatus),
|
||||
importsMutex: sync.RWMutex{},
|
||||
rateLimiter: time.NewTicker(500 * time.Millisecond), // 500ms delay between requests
|
||||
}
|
||||
}
|
||||
|
||||
// Create adds a new URL to read later
|
||||
|
@ -123,3 +151,473 @@ func (s *Service) Reset() error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShioriCredentials contains the credentials for connecting to a Shiori instance
|
||||
type ShioriCredentials struct {
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ShioriBookmark represents a bookmark in Shiori
|
||||
type ShioriBookmark struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Author string `json:"author"`
|
||||
Public int `json:"public"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
Modified time.Time `json:"modified,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
ImageURL string `json:"imageURL,omitempty"`
|
||||
HasContent bool `json:"hasContent"`
|
||||
HasImage bool `json:"hasImage"`
|
||||
HasArchive bool `json:"hasArchive,omitempty"`
|
||||
HasEbook bool `json:"hasEbook,omitempty"`
|
||||
CreateArchive bool `json:"create_archive,omitempty"`
|
||||
CreateEbook bool `json:"create_ebook,omitempty"`
|
||||
Tags json.RawMessage `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// ShioriLoginResponse represents the response from Shiori login API
|
||||
type ShioriLoginResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message struct {
|
||||
Token string `json:"token"` // JWT token for bearer auth
|
||||
Session string `json:"session"` // Session ID
|
||||
Expires int64 `json:"expires"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
// ShioriBookmarksResponse represents the response from Shiori bookmarks API
|
||||
type ShioriBookmarksResponse struct {
|
||||
Bookmarks []ShioriBookmark `json:"bookmarks"`
|
||||
MaxPage int `json:"maxPage"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ImportFromShiori imports bookmarks from a Shiori instance
|
||||
// Returns the import ID which can be used to check the status
|
||||
func (s *Service) ImportFromShiori(creds ShioriCredentials) (string, error) {
|
||||
// For debugging
|
||||
fmt.Printf("[DEBUG] Starting import from Shiori URL: %s\n", creds.URL)
|
||||
|
||||
// Create a new import status
|
||||
importID := s.CreateImportStatus()
|
||||
fmt.Printf("[DEBUG] Created import status with ID: %s\n", importID)
|
||||
|
||||
// Start the import process in a goroutine
|
||||
go s.runShioriImport(importID, creds)
|
||||
|
||||
// Return the import ID so the caller can check the status
|
||||
return importID, nil
|
||||
}
|
||||
|
||||
// runShioriImport performs the actual import process in the background
|
||||
func (s *Service) runShioriImport(importID string, creds ShioriCredentials) {
|
||||
fmt.Printf("[DEBUG] Starting runShioriImport for ID: %s\n", importID)
|
||||
|
||||
// Define a helper function to mark import as failed
|
||||
markFailed := func(err error) {
|
||||
fmt.Printf("[DEBUG] Import failed: %v\n", err)
|
||||
s.UpdateImportStatus(importID, func(status *ImportStatus) {
|
||||
status.InProgress = false
|
||||
status.Error = err.Error()
|
||||
})
|
||||
}
|
||||
|
||||
// Login to Shiori
|
||||
fmt.Printf("[DEBUG] Attempting to login to Shiori at %s\n", creds.URL)
|
||||
token, err := s.loginToShiori(creds)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] Login failed: %v\n", err)
|
||||
markFailed(fmt.Errorf("failed to login to Shiori: %v", err))
|
||||
return
|
||||
}
|
||||
fmt.Printf("[DEBUG] Login successful, token: %s...\n", token[:min(len(token), 10)])
|
||||
|
||||
// Fetch all bookmarks from Shiori with pagination
|
||||
fmt.Printf("[DEBUG] Fetching bookmarks from Shiori\n")
|
||||
allBookmarks, err := s.fetchShioriBookmarks(creds.URL, token)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] Fetching bookmarks failed: %v\n", err)
|
||||
markFailed(fmt.Errorf("failed to fetch bookmarks: %v", err))
|
||||
return
|
||||
}
|
||||
fmt.Printf("[DEBUG] Fetched %d bookmarks\n", len(allBookmarks))
|
||||
|
||||
// Setup counters
|
||||
importedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
// Update status with total count
|
||||
s.UpdateImportStatus(importID, func(status *ImportStatus) {
|
||||
status.TotalBookmarks = len(allBookmarks)
|
||||
})
|
||||
fmt.Printf("[DEBUG] Updated import status with total count: %d\n", len(allBookmarks))
|
||||
|
||||
// Process each bookmark
|
||||
for i, bookmark := range allBookmarks {
|
||||
fmt.Printf("[DEBUG] Processing bookmark %d/%d: %s\n", i+1, len(allBookmarks), bookmark.URL)
|
||||
<-s.rateLimiter.C // Apply rate limiting for processing each bookmark
|
||||
|
||||
// Create a ReadLaterItem from the bookmark
|
||||
item := ReadLaterItem{
|
||||
ID: uuid.New().String(),
|
||||
URL: bookmark.URL,
|
||||
Title: bookmark.Title,
|
||||
Image: bookmark.ImageURL,
|
||||
Description: bookmark.Excerpt,
|
||||
Status: "unread",
|
||||
Content: bookmark.Content,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// If content is empty, try to fetch it
|
||||
if item.Content == "" {
|
||||
fmt.Printf("[DEBUG] Content empty, fetching from URL: %s\n", bookmark.URL)
|
||||
article, err := readability.FromURL(bookmark.URL, 30*time.Second)
|
||||
if err == nil && article.Content != "" {
|
||||
item.Content = article.Content
|
||||
|
||||
// If description/excerpt is empty, use the beginning of the content
|
||||
if item.Description == "" {
|
||||
plainText, err := s.ConvertHTMLToText(article.Content)
|
||||
if err == nil && plainText != "" {
|
||||
// Use the first 150 characters as description
|
||||
if len(plainText) > 150 {
|
||||
item.Description = plainText[:150] + "..."
|
||||
} else {
|
||||
item.Description = plainText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If title is empty, use article title
|
||||
if item.Title == "" && article.Title != "" {
|
||||
item.Title = article.Title
|
||||
}
|
||||
|
||||
// If image is empty, use article image
|
||||
if item.Image == "" && article.Image != "" {
|
||||
item.Image = article.Image
|
||||
}
|
||||
} else if err != nil {
|
||||
fmt.Printf("[DEBUG] Error fetching article content: %v\n", err)
|
||||
}
|
||||
} else if item.Description == "" { // Convert any existing HTML content to plain text for the description if needed
|
||||
plainText, err := s.ConvertHTMLToText(item.Content)
|
||||
if err == nil && plainText != "" {
|
||||
// Use the first 150 characters as description
|
||||
if len(plainText) > 150 {
|
||||
item.Description = plainText[:150] + "..."
|
||||
} else {
|
||||
item.Description = plainText
|
||||
}
|
||||
} else if err != nil {
|
||||
fmt.Printf("[DEBUG] Error converting HTML to text: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least a basic title if it's still empty
|
||||
if item.Title == "" {
|
||||
item.Title = bookmark.URL
|
||||
}
|
||||
|
||||
// Save the item to the database
|
||||
fmt.Printf("[DEBUG] Saving item to database: %s\n", item.URL)
|
||||
err = s.db.Create(&item).Error
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] Error saving item: %v\n", err)
|
||||
failedCount++
|
||||
} else {
|
||||
importedCount++
|
||||
}
|
||||
|
||||
// Update import status
|
||||
s.UpdateImportStatus(importID, func(status *ImportStatus) {
|
||||
status.ImportedCount = importedCount
|
||||
status.FailedCount = failedCount
|
||||
})
|
||||
}
|
||||
|
||||
// Mark import as complete
|
||||
fmt.Printf("[DEBUG] Import complete. Imported: %d, Failed: %d\n", importedCount, failedCount)
|
||||
s.UpdateImportStatus(importID, func(status *ImportStatus) {
|
||||
status.InProgress = false
|
||||
completedAt := time.Now()
|
||||
status.CompletedAt = &completedAt
|
||||
})
|
||||
}
|
||||
|
||||
// loginToShiori logs in to a Shiori instance and returns the session token
|
||||
func (s *Service) loginToShiori(creds ShioriCredentials) (string, error) {
|
||||
// Apply rate limiting
|
||||
<-s.rateLimiter.C
|
||||
|
||||
fmt.Printf("[DEBUG] loginToShiori: Starting login to %s\n", creds.URL)
|
||||
|
||||
// Construct login URL
|
||||
loginURL := fmt.Sprintf("%s/api/v1/auth/login", strings.TrimSuffix(creds.URL, "/"))
|
||||
fmt.Printf("[DEBUG] loginToShiori: Login URL: %s\n", loginURL)
|
||||
|
||||
// Prepare login data
|
||||
loginData := map[string]string{
|
||||
"username": creds.Username,
|
||||
"password": creds.Password,
|
||||
}
|
||||
|
||||
// Convert login data to JSON
|
||||
jsonData, err := json.Marshal(loginData)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] loginToShiori: Error marshaling login data: %v\n", err)
|
||||
return "", fmt.Errorf("failed to marshal login data: %v", err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", loginURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] loginToShiori: Error creating request: %v\n", err)
|
||||
return "", fmt.Errorf("failed to create login request: %v", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Send request
|
||||
fmt.Printf("[DEBUG] loginToShiori: Sending login request\n")
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] loginToShiori: Error sending request: %v\n", err)
|
||||
return "", fmt.Errorf("failed to send login request: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error closing response body: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check response status
|
||||
fmt.Printf("[DEBUG] loginToShiori: Response status: %s\n", resp.Status)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Read the error response body
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("[DEBUG] loginToShiori: Error response body: %s\n", string(errBody))
|
||||
return "", fmt.Errorf("login failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] loginToShiori: Error reading response body: %v\n", err)
|
||||
return "", fmt.Errorf("failed to read login response: %v", err)
|
||||
}
|
||||
|
||||
// Print response body for debugging
|
||||
fmt.Printf("[DEBUG] loginToShiori: Response body: %s\n", string(bodyBytes))
|
||||
|
||||
// We need to re-create the reader since we consumed it
|
||||
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
// Parse response
|
||||
var loginResp ShioriLoginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
|
||||
fmt.Printf("[DEBUG] loginToShiori: Error parsing response: %v\n", err)
|
||||
return "", fmt.Errorf("failed to parse login response: %v", err)
|
||||
}
|
||||
|
||||
// Extract token
|
||||
token := loginResp.Message.Token
|
||||
fmt.Printf("[DEBUG] loginToShiori: Successfully logged in, token length: %d\n", len(token))
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// fetchShioriBookmarks retrieves bookmarks from a Shiori instance using the session token
|
||||
func (s *Service) fetchShioriBookmarks(baseURL, session string) ([]ShioriBookmark, error) {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Starting fetching bookmarks from %s\n", baseURL)
|
||||
// Apply rate limiting
|
||||
<-s.rateLimiter.C
|
||||
|
||||
// Ensure baseURL doesn't have a trailing slash
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
// Initialize results
|
||||
allBookmarks := []ShioriBookmark{}
|
||||
|
||||
// Start with page 1
|
||||
page := 1
|
||||
maxPage := 1 // Will be updated from the first response
|
||||
|
||||
for page <= maxPage {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Fetching page %d of %d\n", page, maxPage)
|
||||
|
||||
// Construct URL for the current page - fixing the API endpoint path
|
||||
url := fmt.Sprintf("%s/api/bookmarks?page=%d", baseURL, page)
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Request URL for page %d: %s\n", page, url)
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error creating request for page %d: %v\n", page, err)
|
||||
return nil, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session))
|
||||
|
||||
// Send request
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Sending request for page %d\n", page)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error sending request for page %d: %v\n", page, err)
|
||||
return nil, fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error closing response body: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check response
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Response status for page %d: %s\n", page, resp.Status)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Read the error response body
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error response body for page %d: %s\n", page, string(errBody))
|
||||
return nil, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error reading response body for page %d: %v\n", page, err)
|
||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
// Print the first 200 characters of response for debugging
|
||||
previewText := string(bodyBytes)
|
||||
if len(previewText) > 200 {
|
||||
previewText = previewText[:200] + "..."
|
||||
}
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Response body preview for page %d: %s\n", page, previewText)
|
||||
|
||||
// We need to re-create the reader since we consumed it
|
||||
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
// Parse response
|
||||
var bookmarksResp ShioriBookmarksResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bookmarksResp); err != nil {
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error parsing response for page %d: %v\n", page, err)
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Update maxPage from the response (only on first page)
|
||||
if page == 1 {
|
||||
maxPage = bookmarksResp.MaxPage
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Updated max page to %d\n", maxPage)
|
||||
}
|
||||
|
||||
// Add bookmarks from this page to results
|
||||
allBookmarks = append(allBookmarks, bookmarksResp.Bookmarks...)
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Added %d bookmarks from page %d, total so far: %d\n",
|
||||
len(bookmarksResp.Bookmarks), page, len(allBookmarks))
|
||||
|
||||
// Move to next page
|
||||
page++
|
||||
|
||||
// Apply rate limiting before next request (if there is one)
|
||||
if page <= maxPage {
|
||||
<-s.rateLimiter.C
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG] fetchShioriBookmarks: Completed, fetched %d bookmarks total\n", len(allBookmarks))
|
||||
return allBookmarks, nil
|
||||
}
|
||||
|
||||
// CreateImportStatus initializes a new import status and returns its ID
|
||||
func (s *Service) CreateImportStatus() string {
|
||||
id := uuid.New().String()
|
||||
status := &ImportStatus{
|
||||
ID: id,
|
||||
InProgress: true,
|
||||
StartedAt: time.Now(),
|
||||
ImportedCount: 0,
|
||||
FailedCount: 0,
|
||||
}
|
||||
|
||||
s.importsMutex.Lock()
|
||||
s.imports[id] = status
|
||||
s.importsMutex.Unlock()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// UpdateImportStatus updates the status of an import operation
|
||||
func (s *Service) UpdateImportStatus(id string, update func(*ImportStatus)) {
|
||||
s.importsMutex.Lock()
|
||||
defer s.importsMutex.Unlock()
|
||||
|
||||
if status, exists := s.imports[id]; exists {
|
||||
update(status)
|
||||
}
|
||||
}
|
||||
|
||||
// GetImportStatus retrieves the status of an import operation
|
||||
func (s *Service) GetImportStatus(id string) (*ImportStatus, bool) {
|
||||
s.importsMutex.RLock()
|
||||
defer s.importsMutex.RUnlock()
|
||||
|
||||
status, exists := s.imports[id]
|
||||
return status, exists
|
||||
}
|
||||
|
||||
// CleanupOldImports removes completed import statuses older than a day
|
||||
func (s *Service) CleanupOldImports() {
|
||||
s.importsMutex.Lock()
|
||||
defer s.importsMutex.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
for id, status := range s.imports {
|
||||
if !status.InProgress && status.CompletedAt != nil && status.CompletedAt.Before(cutoff) {
|
||||
delete(s.imports, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertHTMLToText converts HTML content to plain text
|
||||
// This is useful when we need to extract text from HTML for searching or displaying in non-HTML contexts
|
||||
func (s *Service) ConvertHTMLToText(htmlContent string) (string, error) {
|
||||
if htmlContent == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Convert HTML to plain text
|
||||
text, err := html2text.FromString(htmlContent, html2text.Options{
|
||||
PrettyTables: true,
|
||||
OmitLinks: false,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// Add helper function for safe string slicing
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
@ -25,9 +25,7 @@ cp static/css/bulma.min.css build/css/
|
|||
|
||||
# Build backend (without CGO)
|
||||
popd
|
||||
echo "Building Go backend..."
|
||||
CGO_ENABLED=0 $GO_CMD build -v -o notes-app || {
|
||||
echo "Go build failed!"
|
||||
exit 1
|
||||
}
|
||||
echo "Go build completed successfully."
|
||||
}
|
|
@ -120,4 +120,17 @@ The system tracks the status of articles using nullable timestamp fields:
|
|||
|
||||
- Input field for the URL to save
|
||||
- Automatic extraction of content after submission
|
||||
- Preview of the extracted content before saving
|
||||
- Preview of the extracted content before saving
|
||||
|
||||
## Shiori Import
|
||||
|
||||
The Readlist module now supports importing bookmarks from a Shiori instance. This feature allows users to migrate or synchronize their bookmarks by connecting to a Shiori service using their credentials.
|
||||
|
||||
### API Endpoint
|
||||
- POST /api/readlist/import/shiori: Accepts a JSON payload containing `url`, `username`, and `password`. The backend authenticates with the Shiori instance, fetches bookmarks, and creates corresponding read later items.
|
||||
|
||||
### Frontend Integration
|
||||
- A form in the readlist UI accepts Shiori credentials. The readlist store includes an `importFromShiori` method that sends a request to the endpoint and processes the response, updating the list of saved articles accordingly.
|
||||
|
||||
### Error Handling
|
||||
- Both the backend and frontend provide clear error messages if authentication or bookmark retrieval fails.
|
Loading…
Add table
Reference in a new issue