diff --git a/modules/desktop/src/libs/search-index.ts b/modules/desktop/src/libs/search-index.ts new file mode 100644 index 0000000..f2b6172 --- /dev/null +++ b/modules/desktop/src/libs/search-index.ts @@ -0,0 +1,26 @@ +import type { GUIPackage, InstalledPackage, Packages } from "./types"; +import Fuse from "fuse.js"; +import log from "$libs/logger"; + +let packagesIndex: Fuse; + +export function indexPackages(packages: GUIPackage[]) { + try { + packagesIndex = new Fuse(packages, { + keys: ["name", "full_name", "desc", "categories"], + minMatchCharLength: 3, + threshold: 0.3 + }); + log.info("refreshed packages fuse index"); + } catch (error) { + log.error(error); + } +} + +export function searchPackages(term: string, limit = 5): GUIPackage[] { + if (!term || !packagesIndex) return []; + // TODO: if online, use algolia else use Fuse + const res = packagesIndex.search(term, { limit }); + const matchingPackages: GUIPackage[] = res.map((v) => v.item); + return matchingPackages; +} diff --git a/modules/desktop/src/libs/stores.ts b/modules/desktop/src/libs/stores.ts index 52d5f95..a9b3aa2 100644 --- a/modules/desktop/src/libs/stores.ts +++ b/modules/desktop/src/libs/stores.ts @@ -7,14 +7,14 @@ import type { GUIPackage } from "$libs/types"; import { getFeaturedPackages, getPackageReviews } from "@native"; import initAuthStore from "./stores/auth"; import initNavStore from "./stores/nav"; -import initPackagesStore from "./stores/pkgs"; +import pkgStore from "./stores/pkgs"; import initNotificationStore from "./stores/notifications"; import initAppUpdateStore from "./stores/update"; import { trackSearch } from "./analytics"; export const featuredPackages = writable([]); -export const packagesStore = initPackagesStore(); +export const packagesStore = pkgStore; export const initializeFeaturedPackages = async () => { console.log("intialize featured packages"); diff --git a/modules/desktop/src/libs/stores/pkgs.ts b/modules/desktop/src/libs/stores/pkgs.ts index c52da5a..2d3e962 100644 --- a/modules/desktop/src/libs/stores/pkgs.ts +++ b/modules/desktop/src/libs/stores/pkgs.ts @@ -1,7 +1,6 @@ import { derived, writable } from "svelte/store"; import type { GUIPackage, InstalledPackage, Packages } from "../types"; import { PackageStates } from "../types"; -import Fuse from "fuse.js"; import { getPackage, getDistPackages, @@ -29,307 +28,283 @@ import log from "$libs/logger"; import { isPackageUpToDate } from "../packages/pkg-utils"; import withDelay from "$libs/utils/delay"; +import { indexPackages, searchPackages } from "$libs/search-index"; + const packageRefreshInterval = 1000 * 60 * 60; // 1 hour -export default function initPackagesStore() { - let initialized = false; - let isDestroyed = false; - let refreshTimeoutId: ReturnType | null = null; +let initialized = false; +let isDestroyed = false; +let refreshTimeoutId: ReturnType | null = null; - const packageMap = writable({ version: "0", packages: {} }); - const packageList = derived(packageMap, ($packages) => - Object.values($packages.packages).sort((a, b) => { - // implement default sort by last_modified > descending - const aDate = new Date(a.last_modified); - const bDate = new Date(b.last_modified); - return +bDate - +aDate; - }) - ); +const packageMap = writable({ version: "0", packages: {} }); +const packageList = derived(packageMap, ($packages) => + Object.values($packages.packages).sort((a, b) => { + // implement default sort by last_modified > descending + const aDate = new Date(a.last_modified); + const bDate = new Date(b.last_modified); + return +bDate - +aDate; + }) +); - let packagesIndex: Fuse; +const updateAllPackages = (guiPkgs: GUIPackage[]) => { + packageMap.update((pkgs) => { + guiPkgs.forEach((pkg) => { + const oldPkg = pkgs.packages[pkg.full_name]; + pkgs.packages[pkg.full_name] = { ...oldPkg, ...pkg }; + }); + setBadgeCountFromPkgs(pkgs); + return pkgs; + }); +}; + +const updatePackage = (full_name: string, props: Partial, newVersion?: string) => { + packageMap.update((pkgs) => { + const pkg = pkgs.packages[full_name]; + if (pkg) { + const updatedPkg = { ...pkg, ...props }; + + if (newVersion) { + updatedPkg.installed_versions = addInstalledVersion( + updatedPkg.installed_versions, + newVersion + ); + } + + updatedPkg.state = getPackageState(updatedPkg); + pkgs.packages[full_name] = updatedPkg; - const updateAllPackages = (guiPkgs: GUIPackage[]) => { - packageMap.update((pkgs) => { - guiPkgs.forEach((pkg) => { - const oldPkg = pkgs.packages[pkg.full_name]; - pkgs.packages[pkg.full_name] = { ...oldPkg, ...pkg }; - }); setBadgeCountFromPkgs(pkgs); - return pkgs; - }); - }; - - const updatePackage = (full_name: string, props: Partial, newVersion?: string) => { - packageMap.update((pkgs) => { - const pkg = pkgs.packages[full_name]; - if (pkg) { - const updatedPkg = { ...pkg, ...props }; - - if (newVersion) { - updatedPkg.installed_versions = addInstalledVersion( - updatedPkg.installed_versions, - newVersion - ); - } - - updatedPkg.state = getPackageState(updatedPkg); - pkgs.packages[full_name] = updatedPkg; - - setBadgeCountFromPkgs(pkgs); - } - return pkgs; - }); - }; - - // getPackage state centralizes the logic for determining the state of the package based on the other properties - const getPackageState = (pkg: GUIPackage): PackageStates => { - if (pkg.isUninstalling) { - //TODO: maybe there should be an uninstalling state too? Although that needs UI/UX changes - return PackageStates.AVAILABLE; } + return pkgs; + }); +}; - const isUpToDate = isPackageUpToDate(pkg); +// getPackage state centralizes the logic for determining the state of the package based on the other properties +const getPackageState = (pkg: GUIPackage): PackageStates => { + if (pkg.isUninstalling) { + //TODO: maybe there should be an uninstalling state too? Although that needs UI/UX changes + return PackageStates.AVAILABLE; + } - if (isInstalling(pkg)) { - const hasNoVersions = !pkg.installed_versions?.length; - if (hasNoVersions || isUpToDate) { - return PackageStates.INSTALLING; - } - return PackageStates.UPDATING; + const isUpToDate = isPackageUpToDate(pkg); + + if (isInstalling(pkg)) { + const hasNoVersions = !pkg.installed_versions?.length; + if (hasNoVersions || isUpToDate) { + return PackageStates.INSTALLING; } + return PackageStates.UPDATING; + } - if (!pkg.installed_versions?.length) { - return PackageStates.AVAILABLE; - } + if (!pkg.installed_versions?.length) { + return PackageStates.AVAILABLE; + } - return isUpToDate ? PackageStates.INSTALLED : PackageStates.NEEDS_UPDATE; - }; + return isUpToDate ? PackageStates.INSTALLED : PackageStates.NEEDS_UPDATE; +}; - const syncPackageData = async (guiPkg: Partial | undefined) => { - if (!guiPkg) return; +const syncPackageData = async (guiPkg: Partial | undefined) => { + if (!guiPkg) return; - const pkg = await getPackage(guiPkg.full_name!); // ATM: pkg only bottles and github:string - const readmeMd = `# ${guiPkg.full_name} # + const pkg = await getPackage(guiPkg.full_name!); // ATM: pkg only bottles and github:string + const readmeMd = `# ${guiPkg.full_name} # To read more about this package go to [${guiPkg.homepage}](${guiPkg.homepage}). `; - const updatedPackage: Partial = { - bottles: pkg?.bottles || [], - readme: { - data: readmeMd, - type: "md" - }, - synced: true, - github: pkg.github - ? trimGithubSlug(pkg.github) - : pkg.full_name?.includes("github.com") - ? trimGithubSlug(pkg.full_name.split("github.com/")[1]) - : "" - }; - if (updatedPackage.github) { - const [owner, repo] = updatedPackage.github.split("/"); - const [readme, contributors, repoData] = await Promise.all([ - getReadme(owner, repo), - getContributors(owner, repo), - getRepoAsPackage(owner, repo) - ]); - if (readme) { - updatedPackage.readme = readme; - } - updatedPackage.contributors = contributors; - updatedPackage.license = repoData.license; - } - - updatePackage(guiPkg.full_name!, updatedPackage); - }; - - const init = async function () { - log.info("packages store: try initialize"); - - if (!initialized) { - const cachedPkgs: Packages = await loadPackageCache(); - log.info(`Loaded ${Object.keys(cachedPkgs.packages).length} packages from cache`); - packageMap.set(cachedPkgs); - - await refreshPackages(); - initialized = true; - } - log.info("packages store: initialized!"); - }; - - const refreshPackages = async () => { - if (isDestroyed) return; - - log.info("packages store: refreshing..."); - - const pkgs = await getDistPackages(); - const guiPkgs: GUIPackage[] = pkgs.map((p) => ({ - ...p, - state: PackageStates.AVAILABLE - })); - - if (!initialized) { - // set packages data so that i can render something in the UI already - updateAllPackages(guiPkgs); - log.info("initialized packages store with ", guiPkgs.length); - } - - packagesIndex = new Fuse(guiPkgs, { - keys: ["name", "full_name", "desc", "categories"], - minMatchCharLength: 3, - threshold: 0.3 - }); - log.info("refreshed packages fuse index"); - - try { - const installedPkgs: InstalledPackage[] = await getInstalledPackages(); - - log.info("updating state of packages"); - for (const pkg of guiPkgs) { - const iPkg = installedPkgs.find((p) => p.full_name === pkg.full_name); - if (iPkg) { - pkg.installed_versions = iPkg.installed_versions; - updatePackage(pkg.full_name, { - installed_versions: iPkg.installed_versions - }); - } - } - } catch (error) { - log.error(error); - } - - try { - await withRetry(syncPantry); - } catch (err) { - log.error(err); - } - - refreshTimeoutId = setTimeout(() => refreshPackages(), packageRefreshInterval); // refresh every hour - }; - - // Destructor for the package store - const destroy = () => { - isDestroyed = true; - if (refreshTimeoutId) { - clearTimeout(refreshTimeoutId); - } - log.info("packages store: destroyed"); - }; - - const installPkg = async (pkg: GUIPackage, version?: string) => { - const versionToInstall = version || pkg.version; - - try { - updatePackage(pkg.full_name, { install_progress_percentage: 0.01 }); - await installPackage(pkg, versionToInstall); - trackInstall(pkg.full_name); - notificationStore.add({ - message: `Package ${pkg.full_name} v${versionToInstall} has been installed.` - }); - } catch (error) { - log.error(error); - let message = "Unknown Error"; - if (error instanceof Error) message = error.message; - trackInstallFailed(pkg.full_name, message || "unknown"); - - notificationStore.add({ - message: `Package ${pkg.full_name} v${versionToInstall} failed to install.`, - type: NotificationType.ERROR - }); - } finally { - updatePackage(pkg.full_name, { install_progress_percentage: 100 }); - } - }; - - const uninstallPkg = async (pkg: GUIPackage) => { - let fakeTimer: NodeJS.Timer | null = null; - try { - fakeTimer = withFakeLoader(pkg, (progress) => { - updatePackage(pkg.full_name, { - install_progress_percentage: progress, - isUninstalling: true - }); - }); - - for (const v of pkg.installed_versions || []) { - await deletePkg(pkg, v); - } - - await withDelay(() => { - updatePackage(pkg.full_name, { - installed_versions: [] - }); - }, 1000); - } catch (error) { - log.error(error); - notificationStore.add({ - message: `Package ${pkg.full_name} failed to uninstall.`, - type: NotificationType.ERROR - }); - } finally { - fakeTimer && clearTimeout(fakeTimer); - updatePackage(pkg.full_name, { install_progress_percentage: 0, isUninstalling: false }); - } - }; - - const deletePkg = async (pkg: GUIPackage, version: string) => { - log.info("deleting package: ", pkg.full_name, " version: ", version); - await deletePackage({ fullName: pkg.full_name, version }); - updatePackage(pkg.full_name, { - installed_versions: pkg.installed_versions?.filter((v) => v !== version) - }); - }; - - const writePackageCacheWithDebounce = withDebounce(writePackageCache); - packageMap.subscribe(async (pkgs) => { - writePackageCacheWithDebounce(pkgs); - }); - - const cachePkgImage = async (pkg: GUIPackage): Promise => { - let cacheFileURL = ""; - updatePackage(pkg.full_name, { cached_image_url: "" }); - if (pkg.thumb_image_url && !pkg.thumb_image_url.includes("package-thumb-nolabel4.jpg")) { - const result = await cacheImageURL(pkg.thumb_image_url); - if (result) { - cacheFileURL = result; - updatePackage(pkg.full_name, { cached_image_url: cacheFileURL }); - } - } - return cacheFileURL; - }; - - listenToChannel("install-progress", ({ full_name, progress }: any) => { - if (!full_name) { - return; - } - updatePackage(full_name, { install_progress_percentage: progress }); - }); - - listenToChannel("pkg-installed", ({ full_name, version }: any) => { - if (!full_name) { - return; - } - updatePackage(full_name, {}, version); - }); - - return { - packageList, - search: async (term: string, limit = 5): Promise => { - if (!term || !packagesIndex) return []; - // TODO: if online, use algolia else use Fuse - const res = packagesIndex.search(term, { limit }); - const matchingPackages: GUIPackage[] = res.map((v) => v.item); - return matchingPackages; + const updatedPackage: Partial = { + bottles: pkg?.bottles || [], + readme: { + data: readmeMd, + type: "md" }, - init, - installPkg, - uninstallPkg, - syncPackageData, - deletePkg, - destroy, - cachePkgImage + synced: true, + github: pkg.github + ? trimGithubSlug(pkg.github) + : pkg.full_name?.includes("github.com") + ? trimGithubSlug(pkg.full_name.split("github.com/")[1]) + : "" }; -} + if (updatedPackage.github) { + const [owner, repo] = updatedPackage.github.split("/"); + const [readme, contributors, repoData] = await Promise.all([ + getReadme(owner, repo), + getContributors(owner, repo), + getRepoAsPackage(owner, repo) + ]); + if (readme) { + updatedPackage.readme = readme; + } + updatedPackage.contributors = contributors; + updatedPackage.license = repoData.license; + } + + updatePackage(guiPkg.full_name!, updatedPackage); +}; + +const init = async function () { + log.info("packages store: try initialize"); + + if (!initialized) { + const cachedPkgs: Packages = await loadPackageCache(); + log.info(`Loaded ${Object.keys(cachedPkgs.packages).length} packages from cache`); + packageMap.set(cachedPkgs); + + await refreshPackages(); + initialized = true; + } + log.info("packages store: initialized!"); +}; + +const refreshPackages = async () => { + if (isDestroyed) return; + + log.info("packages store: refreshing..."); + + const pkgs = await getDistPackages(); + const guiPkgs: GUIPackage[] = pkgs.map((p) => ({ + ...p, + state: PackageStates.AVAILABLE + })); + + if (!initialized) { + // set packages data so that i can render something in the UI already + updateAllPackages(guiPkgs); + log.info("initialized packages store with ", guiPkgs.length); + } + + // initialize Fuse index for fuzzy search + indexPackages(guiPkgs); + + try { + const installedPkgs: InstalledPackage[] = await getInstalledPackages(); + + log.info("updating state of packages"); + for (const pkg of guiPkgs) { + const iPkg = installedPkgs.find((p) => p.full_name === pkg.full_name); + if (iPkg) { + pkg.installed_versions = iPkg.installed_versions; + updatePackage(pkg.full_name, { + installed_versions: iPkg.installed_versions + }); + } + } + } catch (error) { + log.error(error); + } + + try { + await withRetry(syncPantry); + } catch (err) { + log.error(err); + } + + refreshTimeoutId = setTimeout(() => refreshPackages(), packageRefreshInterval); // refresh every hour +}; + +// Destructor for the package store +const destroy = () => { + isDestroyed = true; + if (refreshTimeoutId) { + clearTimeout(refreshTimeoutId); + } + log.info("packages store: destroyed"); +}; + +const installPkg = async (pkg: GUIPackage, version?: string) => { + const versionToInstall = version || pkg.version; + + try { + updatePackage(pkg.full_name, { install_progress_percentage: 0.01 }); + await installPackage(pkg, versionToInstall); + trackInstall(pkg.full_name); + notificationStore.add({ + message: `Package ${pkg.full_name} v${versionToInstall} has been installed.` + }); + } catch (error) { + log.error(error); + let message = "Unknown Error"; + if (error instanceof Error) message = error.message; + trackInstallFailed(pkg.full_name, message || "unknown"); + + notificationStore.add({ + message: `Package ${pkg.full_name} v${versionToInstall} failed to install.`, + type: NotificationType.ERROR + }); + } finally { + updatePackage(pkg.full_name, { install_progress_percentage: 100 }); + } +}; + +const uninstallPkg = async (pkg: GUIPackage) => { + let fakeTimer: NodeJS.Timer | null = null; + try { + fakeTimer = withFakeLoader(pkg, (progress) => { + updatePackage(pkg.full_name, { + install_progress_percentage: progress, + isUninstalling: true + }); + }); + + for (const v of pkg.installed_versions || []) { + await deletePkg(pkg, v); + } + + await withDelay(() => { + updatePackage(pkg.full_name, { + installed_versions: [] + }); + }, 1000); + } catch (error) { + log.error(error); + notificationStore.add({ + message: `Package ${pkg.full_name} failed to uninstall.`, + type: NotificationType.ERROR + }); + } finally { + fakeTimer && clearTimeout(fakeTimer); + updatePackage(pkg.full_name, { install_progress_percentage: 0, isUninstalling: false }); + } +}; + +const deletePkg = async (pkg: GUIPackage, version: string) => { + log.info("deleting package: ", pkg.full_name, " version: ", version); + await deletePackage({ fullName: pkg.full_name, version }); + updatePackage(pkg.full_name, { + installed_versions: pkg.installed_versions?.filter((v) => v !== version) + }); +}; + +const writePackageCacheWithDebounce = withDebounce(writePackageCache); +packageMap.subscribe(async (pkgs) => { + writePackageCacheWithDebounce(pkgs); +}); + +const cachePkgImage = async (pkg: GUIPackage): Promise => { + let cacheFileURL = ""; + updatePackage(pkg.full_name, { cached_image_url: "" }); + if (pkg.thumb_image_url && !pkg.thumb_image_url.includes("package-thumb-nolabel4.jpg")) { + const result = await cacheImageURL(pkg.thumb_image_url); + if (result) { + cacheFileURL = result; + updatePackage(pkg.full_name, { cached_image_url: cacheFileURL }); + } + } + return cacheFileURL; +}; + +listenToChannel("install-progress", ({ full_name, progress }: any) => { + if (!full_name) { + return; + } + updatePackage(full_name, { install_progress_percentage: progress }); +}); + +listenToChannel("pkg-installed", ({ full_name, version }: any) => { + if (!full_name) { + return; + } + updatePackage(full_name, {}, version); +}); // This is only used for uninstall now export const withFakeLoader = ( @@ -364,3 +339,15 @@ const setBadgeCountFromPkgs = (pkgs: Packages) => { log.error(error); } }; + +export default { + packageList, + search: searchPackages, + init, + installPkg, + uninstallPkg, + syncPackageData, + deletePkg, + destroy, + cachePkgImage +};