// 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[] readonly message?: string } 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 } } } }