Skip to content

Commit

Permalink
Support pasting multiple URLs in Add form
Browse files Browse the repository at this point in the history
  • Loading branch information
Rustem Mussabekov committed Feb 24, 2025
1 parent a0a50cf commit 2ac828d
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 132 deletions.
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "app",
"version": "5.6.73",
"version": "5.6.74",
"description": "All-in-one bookmark manager",
"author": "Rustem Mussabekov",
"license": "MIT",
Expand Down
190 changes: 74 additions & 116 deletions src/co/bookmarks/add/fallback/link.js
Original file line number Diff line number Diff line change
@@ -1,127 +1,70 @@
import s from './link.module.css'
import React, { useCallback, useState, useRef, useEffect, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import React, { useCallback, useState, useRef, useEffect } from 'react'
import { useDispatch } from 'react-redux'
import t from '~t'
import useDebounce from '~modules/format/callback/use-debounce'
import isURL from 'validator/es/lib/isURL'
import { oneCreate } from '~data/actions/bookmarks'
import { suggestFields } from '~data/actions/bookmarks'
import { makeSuggestedFields } from '~data/selectors/bookmarks'
import { makeCollectionPath } from '~data/selectors/collections'
import { isPro } from '~data/selectors/user'
import { manyCreate, manyReparseInplace } from '~data/actions/bookmarks'
import { extractURLs } from '~modules/format/url'

import { Error } from '~co/overlay/dialog'
import Popover from '~co/overlay/popover'
import { Layout } from '~co/common/form'
import { Layout, Text, Label, Buttons } from '~co/common/form'
import Button from '~co/common/button'
import Icon from '~co/common/icon'
import ItemLink from '~co/bookmarks/edit/form/link'

function Suggestion({ id, primary, disabled, onClick }) {
const getCollectionPath = useMemo(()=>makeCollectionPath(), [])
const path = useSelector(state=>getCollectionPath(state, id, { self: true }))
const shortPath = useMemo(()=>path.map((p)=>p.title).slice(-2).join(' / '), [path])
const fullPath = useMemo(()=>path.map((p)=>p.title).join(' / '), [path])
const collection = useMemo(()=>path?.[path.length-1], [path])
const onSelfClick = useCallback(e=>onClick(parseInt(e.currentTarget.getAttribute('data-id'))), [onClick])

if (!collection?.title)
return null

return (
<Button
data-id={id}
variant={primary ? 'primary' : 'dotted'}
disabled={disabled}
data-shape='pill'
tabIndex='-1'
title={fullPath}
className={s.suggestion}
onClick={onSelfClick}>
{primary && `${t.s('add')} ${t.s('to')}`} {shortPath}
</Button>
)
}

function Suggestions({ item, loading, onClick }) {
const dispatch = useDispatch()

//load suggestions
const debounced = useDebounce(item, 300)
useEffect(()=>dispatch(suggestFields(debounced)), [debounced.link])

//get suggestions
const enabled = useSelector(state=>state.config.ai_suggestions)
const pro = useSelector(state=>isPro(state))
const getSuggestedFields = useMemo(()=>makeSuggestedFields(), [])
const { collections=[] } = useSelector(state=>getSuggestedFields(state, item))

return (
<div className={s.suggestions}>
<Suggestion
id={item.collectionId}
disabled={loading || !item.link}
primary={true}
onClick={onClick} />

{enabled && pro && collections.length ? (<>
<span className={s.or}>{t.s('or')} {t.s('to')}</span>

{collections.map(id=>(
<Suggestion
key={id}
id={id}
disabled={loading}
onClick={onClick} />
))}
</>) : null}
</div>
)
}

function AddForm({ spaceId, onEdit, pin, onClose }) {
const dispatch = useDispatch()

const [item, setItem] = useState(()=>({ collectionId: parseInt(spaceId)||-1 }))
const [loading, setLoading] = useState(true)
const input = useRef(null)
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)

const onChangeField = useCallback((changed)=>
setItem(item=>({...item, ...changed})),
[setItem]
)
//grab link from clipboard
useEffect(()=>{
(async()=>{
//in safari readText works bad, so this line prevents run on safari
await navigator.permissions.query({name: 'clipboard-read'})
return extractURLs(await navigator.clipboard.readText())
})()
.then(urls=>{
setText(urls.join('\n'))
input.current?.select?.()
})
.catch(e=>console.error(e))
}, [setText, input])

const onAddTo = useCallback((collectionId)=>{
setLoading(true)
const onChangeInput = useCallback(e=>{
e.currentTarget?.setCustomValidity('')
setText(e.currentTarget.value)
}, [setText])

dispatch(oneCreate({ ...item, collectionId }, (items)=>{
onEdit && onEdit(items)
onClose()
}, e=>{
Error(e)
setLoading(false)
}))
}, [item, onEdit, setLoading, onClose, dispatch])

const onSubmitForm = useCallback(e=>{
e.preventDefault()
e.stopPropagation()
onAddTo(item.collectionId)
}, [onAddTo, item.collectionId])

//grab link from clipboard
useEffect(()=>{
async function getLink() {
//in safari readText works bad, so this line prevents run on safari
await navigator.permissions.query({name: 'clipboard-read'})
const link = await navigator.clipboard.readText()
if (isURL(link, { require_protocol: true })) return link
const urls = extractURLs(text)
input.current?.setCustomValidity?.(urls.length ? '' : 'Please enter a valid URL(s)')
input.current?.reportValidity?.()

if (urls.length) {
setLoading(true)

dispatch(
manyCreate(
urls.map(link=>({
link,
collectionId: parseInt(spaceId)||-1
})),
(items)=>{
dispatch(manyReparseInplace(items))
onClose()
}, e=>{
Error(e)
setLoading(false)
}
)
)
}

getLink()
.then(link=>onChangeField({ link }))
.catch(e=>console.error(e))
.finally(()=>setLoading(false))
}, [setLoading, onChangeField])
}, [text, input, spaceId, dispatch, setLoading])

return (
<Popover
Expand All @@ -131,18 +74,33 @@ function AddForm({ spaceId, onEdit, pin, onClose }) {
onClose={onClose}>
<form onSubmit={onSubmitForm}>
<Layout>
<ItemLink
key={loading}
autoFocus={loading ? '' : 'link'}
<Label>URL</Label>
<Text
ref={input}
disabled={loading}
selectAll={true}
item={item}
onChange={onChangeField} />

<Suggestions
item={item}
loading={loading}
onClick={onAddTo} />
autoSize={true}
type='text'
autoComplete='off'
spellCheck='false'
autoFocus={true}
maxRows={5}
value={text}
placeholder='https://'
onChange={onChangeInput} />

<Buttons>
<Button
variant='outline'
onClick={onClose}>
{t.s('cancel')}
</Button>

<Button
as='input'
type='submit'
variant='primary'
disabled={loading} />
</Buttons>
</Layout>
</form>
</Popover>
Expand Down
12 changes: 0 additions & 12 deletions src/co/bookmarks/add/fallback/link.module.css
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
.modal {
width: 11rem
}

.suggestions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--padding-small);
padding-bottom: var(--padding-small)
}

.or {
color: var(--secondary-text-color)
}
8 changes: 7 additions & 1 deletion src/data/actions/bookmarks/single.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
BOOKMARK_LOAD_REQ, BOOKMARK_CREATE_REQ, BOOKMARKS_CREATE_REQ, BOOKMARK_UPDATE_REQ, BOOKMARK_REMOVE_REQ, BOOKMARK_UPLOAD_REQ,
BOOKMARK_RECOVER, BOOKMARK_IMPORTANT, BOOKMARK_SCREENSHOT, BOOKMARK_REPARSE, BOOKMARK_MOVE,
BOOKMARK_REORDER,
BOOKMARK_SUGGEST_FIELDS
BOOKMARK_SUGGEST_FIELDS,
BOOKMARKS_REPARSE_INPLACE
} from '../../constants/bookmarks'

//High level API
Expand Down Expand Up @@ -95,4 +96,9 @@ export const manyCreate = (items=[], onSuccess, onFail)=>({
items,
onSuccess: wrapFunc(onSuccess),
onFail: wrapFunc(onFail)
})

export const manyReparseInplace = (items=[])=>({
type: BOOKMARKS_REPARSE_INPLACE,
items
})
1 change: 1 addition & 0 deletions src/data/constants/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const
BOOKMARK_CREATE_ERROR = 'BOOKMARK_CREATE_ERROR',

BOOKMARKS_CREATE_REQ = 'BOOKMARKS_CREATE_REQ',
BOOKMARKS_REPARSE_INPLACE = 'BOOKMARKS_REPARSE_INPLACE',

BOOKMARK_UPLOAD_REQ = 'BOOKMARK_UPLOAD_REQ',
BOOKMARK_UPLOAD_PROGRESS = 'BOOKMARK_UPLOAD_PROGRESS',
Expand Down
25 changes: 24 additions & 1 deletion src/data/sagas/bookmarks/single.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
BOOKMARK_REORDER,
BOOKMARK_SUGGEST_FIELDS, BOOKMARK_SUGGESTED_FIELDS,

BOOKMARK_RECOVER, BOOKMARK_IMPORTANT, BOOKMARK_SCREENSHOT, BOOKMARK_REPARSE, BOOKMARK_MOVE
BOOKMARK_RECOVER, BOOKMARK_IMPORTANT, BOOKMARK_SCREENSHOT, BOOKMARK_REPARSE, BOOKMARK_MOVE,
BOOKMARKS_REPARSE_INPLACE
} from '../../constants/bookmarks'

import {
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function* () {

//many
yield takeEvery(BOOKMARKS_CREATE_REQ, createBookmarks)
yield takeEvery(BOOKMARKS_REPARSE_INPLACE, reparseInplace)
}

function* loadBookmark({ ignore=false, _id }) {
Expand Down Expand Up @@ -393,4 +395,25 @@ function* suggestFields({ obj, ignore }) {
} catch (error) {
console.error(error)
}
}

function* reparseInplace({ items, ignore }) {
if (ignore) return

try{
for(const { _id, link } of items) {
const parsed = yield call(Api.get, 'import/url/parse?url='+encodeURIComponent(link))

yield put({
type: BOOKMARK_UPDATE_REQ,
_id: _id,
set: {
...parsed.item,
pleaseParse: null
}
})
}
} catch (error) {
console.error(error)
}
}
14 changes: 14 additions & 0 deletions src/modules/format/url/extractURLs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import _ from 'lodash-es'
import isURL from 'validator/es/lib/isURL'

const urlPattern = /https?:\/\/[^\s]+/gi;

export function extractURLs(text) {
return _.uniq(
(String(text)||'').match(urlPattern) || []
)
.filter(url=>
(url.match(/https?/g) || []).length == 1 &&
isURL(url, { require_protocol: true, require_tld: true })
)
}
3 changes: 2 additions & 1 deletion src/modules/format/url/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './getDomain'
export * from './normalizeURL'
export * from './isSPA'
export * from './isSPA'
export * from './extractURLs'

0 comments on commit 2ac828d

Please sign in to comment.