// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
import 'pinia'
import { MaybeRef, shallowRef, watch, computed, Ref, ref } from 'vue';
import { apiCall, useAxios, WebMessage } from '@vnuge/vnlib.browser';
import { useToggle, get, set, useOffsetPagination, watchDebounced, syncRef } from '@vueuse/core';
import { PiniaPluginContext, PiniaPlugin, storeToRefs } from 'pinia'
import { isArray, join, map, split, sortBy } from 'lodash-es';
import { useQuery } from './index';
export interface Bookmark{
readonly Id: string
Name: string
Url: string
Tags: string[]
Description: string
Created: string
LastModified: string
}
export interface BatchUploadResult{
readonly invalid: BookmarkError[]
}
export interface BookmarkError{
readonly subject: Bookmark
readonly errors: Array<{
readonly message: string
readonly property: string
}>
}
export type DownloadContentType = 'application/json' | 'text/html' | 'text/csv' | 'text/plain'
export interface BookmarkApi{
list: (page: number, limit: number, search: BookmarkSearch) => Promise
add: (bookmark: Bookmark) => Promise
addMany: (bookmarks: Bookmark[], failOnValidationError: boolean) => Promise
set: (bookmark: Bookmark) => Promise
getTags: () => Promise
delete: (bookmark: Bookmark | Bookmark[]) => Promise
count: () => Promise
downloadAll: (contentType: DownloadContentType) => Promise
}
export interface BookmarkSearch{
query: string | undefined | null
tags: string[]
}
declare module 'pinia' {
export interface PiniaCustomProperties {
bookmarks:{
api: BookmarkApi
query: string
tags: string[]
allTags: string[]
list: Bookmark[]
pages: ReturnType
refresh: () => void
}
}
}
const useBookmarkApi = (endpoint: MaybeRef): BookmarkApi => {
const axios = useAxios(null)
const listBookmarks = async (page: number, limit: number, search: BookmarkSearch) => {
const query = get(search.query)
const tagQuery = join(get(search.tags), ' ')
const params = new URLSearchParams()
params.append('page', (page - 1).toString())
params.append('limit', limit.toString())
params.append('t', tagQuery)
//Add query if defined
if(query){
params.append('q', query)
}
const { data } = await axios.get(`${get(endpoint)}?${params.toString()}`)
return data;
}
const addBookmark = async (bookmark: Bookmark) => {
const { data } = await axios.post(`${get(endpoint)}`, bookmark)
data.getResultOrThrow();
}
const setBookmark = async (bookmark: Bookmark) => {
const { data } = await axios.patch>(`${get(endpoint)}`, bookmark)
data.getResultOrThrow();
}
const deleteBookmark = async (bookmark: Bookmark | Bookmark[]) => {
if(isArray(bookmark)){
//Delete multiple bookmarks with comma separated ids
const bookmarIds = join(map(bookmark, b => b.Id), ',')
const { data } = await axios.delete>(`${get(endpoint)}?ids=${bookmarIds}`)
data.getResultOrThrow();
}
else {
//Delete a single bookmark
const { data } = await axios.delete>(`${get(endpoint)}?id=${bookmark.Id}`)
data.getResultOrThrow();
}
}
const getItemsCount = async () => {
const { data } = await axios.get>(`${get(endpoint)}?count=true`)
return data.getResultOrThrow();
}
const getTags = async () => {
const { data } = await axios.get(`${get(endpoint)}?getTags=true`)
return sortBy(data);
}
const addMany = async (bookmarks: Bookmark[], failOnValidationError: boolean): Promise => {
let params = ''
if(failOnValidationError){
params = '?failOnInvalid=true'
}
//Exec request, ignore a validation error on a 20x response
const { data } = await axios.put>(`${get(endpoint)}${params}`, bookmarks);
return data.result;
}
const downloadAll = async (contentType: DownloadContentType) => {
//download the bookmarks as a html file
const { data } = await axios.get(`${get(endpoint)}?export=true`, {
headers: { 'Accept': contentType }
})
return data;
}
return {
list: listBookmarks,
add: addBookmark,
set: setBookmark,
delete: deleteBookmark,
count: getItemsCount,
addMany,
getTags,
downloadAll
}
}
const urlPagiation = (p: Ref, l: Ref) => {
const page = useQuery('page')
const limit = useQuery('limit')
const currentPage = computed({
get: () => page.value ? parseInt(page.value) : 1,
set: (value) => set(page, value.toString())
})
const currentPageSize = computed({
get: () => limit.value ? parseInt(limit.value) : 20,
set: (value) => set(limit, value.toString())
})
//Sync current page and limit with the provided refs
syncRef(currentPage, p, { immediate: true })
syncRef(currentPageSize, l, { immediate: true })
}
const searchQuery = (search: Ref, tags: Ref) => {
const query = useQuery('q')
const tagQuery = useQuery('t')
const currentTags = computed({
get: () => split(tagQuery.value, ' '),
set: (value) => set(tagQuery, join(value, ' '))
})
//Sync current page and limit with the provided refs
syncRef(query, search, { immediate: true })
syncRef(currentTags, tags, { immediate: true })
}
export const bookmarkPlugin = (bookmarkEndpoint: MaybeRef): PiniaPlugin => {
return ({ store }: PiniaPluginContext) => {
const { loggedIn } = storeToRefs(store)
const [onRefresh, refresh] = useToggle()
const totalBookmarks = shallowRef(0)
const bookmarks = shallowRef()
const allTags = shallowRef([])
const pages = useOffsetPagination({ page: 1, pageSize: 20 })
const { currentPage, currentPageSize } = pages;
//Sync url query params with the pagination
urlPagiation(currentPage, currentPageSize)
//sync search query and tags
const query = ref(null)
const tags = ref([])
searchQuery(query, tags)
//Init api
const bookmarkApi = useBookmarkApi(bookmarkEndpoint)
watch([loggedIn, onRefresh], ([ li ]) => {
if(!li){
//Clear the bookmarks
set(totalBookmarks, 0)
set(bookmarks, [])
return
}
//Update the total bookmarks
apiCall(async () => {
allTags.value = await bookmarkApi.getTags()
totalBookmarks.value = await bookmarkApi.count()
})
})
//Watch for serach query changes
watchDebounced([currentPage, currentPageSize, tags, query, allTags],
([ page, pageSize, tags, query ]) => {
apiCall(async () => bookmarks.value = await bookmarkApi.list(page, pageSize, { tags, query }))
}, { debounce: 50 })
//Watch for page changes and scroll to top
watch([currentPage], () => window.scrollTo({ top: 0, behavior: 'smooth' }))
//reset current page when tags change or query changes
watch([tags, query], () => set(currentPage, 1))
return {
bookmarks:{
list: bookmarks,
pages,
refresh,
query,
tags,
allTags,
api: bookmarkApi
}
}
}
}