From d99f4a88e2ab02bc29b375e34672e6056d95005a Mon Sep 17 00:00:00 2001 From: Josh Pinto Date: Thu, 14 Aug 2025 02:14:54 +0000 Subject: [PATCH] :alembic: Create and list destinations --- README.md | 40 ++++++++++++++++++++++++++++++++-------- package-lock.json | 8 ++++---- package.json | 16 ++++++++-------- src/create-destination.tsx | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/list-destinations.tsx | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/auth.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/destination.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 file(s) changed, 453 insertion(s)(+), 20 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ - You somehow ended up with 50 tabs generated from some niche rabbit-hole research on a topic and need to quickly organize them all into maps and workspaces. - You use multiple machines regularly and want to be able to access your saved resources across browsers or (without) a browser. +## πŸ“Ί Demo Video & Setup Tutorial + ## πŸš€ Usage To run this extension, simply: 1. Clone this repository to your device using `git clone` and navigate (`cd`) into it, and run the `npm run dev` script, 2. launch Raycast like usual and search for the "Regreso Login" command -3. Fill in your regreso account email/password (you can create one [here](https://regreso.netlify.app/sign-up)) and submit. +3. Fill in your regreso account email/password (you can create one [here](https://regreso.netlify.app/sign-up), but make sure to use email/password!) and submit. 4. Start using Regreso for Raycast! You can use the commands described in ## πŸ› οΈ Developing @@ -36,27 +38,49 @@ A list of the current commands available in the extension. -- Log In - Create Destination - List Destinations -## πŸ—ΊοΈ TODOs +## βš™οΈ Configuration > [!IMPORTANT] -> The most difficult thing about the Regreso Raycast extension is authentication. Because the extension serves not simply as a map RSS-feed-reader and enables all sorts of mutational procedures, we need to be careful. However, until Regreso has its own OAuth support (coming soon I promise!), this extension will remain in an alpha/beta stage. +> Only email/password account auth is fully supported by this client! Whilst GitHub login can be used through a session ID, obtained from session cookie, this will eventually expire and may eventually be deprecated from this extension. + +The following configuration preferences can be set: + +- Instance URL (optional), **Default: https://regreso.netlify.app**: If self-hosting Regreso, enter your instance's web address here. +- Email (mandatory): If using session id auth, just write something random in this field. +- Password (mandatory): : If using session id auth, just write something random in this field. +- Session ID (optional) Optional but overrides email/password fields in favor of a statically-provided session ID. + +## πŸ—ΊοΈ ROADMAP + +### πŸ“ A note about auth + +The most complicated thing about the Regreso Raycast extension is the need for secure, simple authentication. Because the extension doesn't just serve as an RSS-feed-reader but enables mutational procedures as well, caution is important. + +### β˜‘οΈ TODOs - [ ] Auth + - [x] basic email/password or session token - [ ] Support Regreso OAuth - [ ] Support destination commands - - [ ] Support creating destinations - - [ ] Support listing/searching destinations - - [ ] Support - - [ ] Support saving destination drafts + - [x] Support creating destinations + - [x] scraped metadata + - [ ] with arguments + - [ ] searchable tagpicker + - [ ] Support browser tab integrations + - [ ] Support browser bookmarks integrations + - [ ] Support saving destination drafts + - [x] Support listing/searching destinations + - [ ] List item details - [ ] Support map commands - [ ] Support creating maps - [ ] Support listing/searching maps - [ ] Support opening maps (listing destinations) + +- [ ] Store some data locally (SQL) - [ ] Release Stuff - [ ] Test on MacOS diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "@raycast/api": "^1.101.1", "@raycast/utils": "^1.17.0", - "@regreso/utils": "^0.1.6" + "@regreso/utils": "^0.1.7" }, "devDependencies": { "@raycast/eslint-config": "^2.0.4", @@ -1206,9 +1206,9 @@ } }, "node_modules/@regreso/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@regreso/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-4HAoQtFjGFLDUhQI/hOoID65JF2Zb78RXvl8p+wEGfGlVY2qYamBlR72FNo7qtjkEEBDwmTUWpCtZOlYyE/ubw==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@regreso/utils/-/utils-0.1.7.tgz", + "integrity": "sha512-c3jcqUeN+3ExxvpAmgWep7ftpF28fnV8r1Epk7u72UnDUvawSlFPxVBVZ4fT/o49SBXjKVPHAdXkhut1dbIYTA==", "dependencies": { "node-html-parser": "^7.0.1" } diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -30,14 +30,14 @@ } ], "preferences": [ - { + { "name": "ai-tagging", - "title": "Enable AI Tagging", - "description": "Enable AI-tagging for new destinations using your LLM instance.", - "type": "checkbox", - "required": true, - "default": false - }, + "title": "AI Tagging", + "description": "Enable AI-tagging for new destinations using your LLM instance.", + "type": "checkbox", + "required": true, + "default": false + }, { "name": "ai-instance", "title": "AI Instance URL", @@ -91,7 +91,7 @@ "dependencies": { "@raycast/api": "^1.101.1", "@raycast/utils": "^1.17.0", - "@regreso/utils": "^0.1.6" + "@regreso/utils": "^0.1.7" }, "devDependencies": { "@raycast/eslint-config": "^2.0.4", diff --git a/src/create-destination.tsx b/src/create-destination.tsx new file mode 100644 --- /dev/null +++ b/src/create-destination.tsx @@ -0,0 +1,225 @@ +import { useState, useEffect, useRef } from "react"; + +import { Action, ActionPanel, Form, popToRoot, LaunchProps, Toast, showToast, getPreferenceValues } from "@raycast/api"; +import { useForm } from "@raycast/utils"; + +import { getWebDetails } from "@regreso/utils"; + +import { handleAuthStuff } from "./utils/auth"; +import { createDestination } from "./utils/destination"; + +interface DestinationTypeFormValues { + type: string; + location?: string | undefined; +} + +interface DestinationFormValues { + headline: string; + body?: string; + location?: string | undefined; + type: string; + tags?: string; + workspaceId?: string; +} + +interface Workspace { + name: string; + id: number; +} + +export default function CreateDestination( + props: LaunchProps<{ arguments: Arguments.CreateDestination; draftValues: DestinationFormValues }>, +) { + const preferences = getPreferenceValues(); + const [sessionToken, setSessionToken] = useState(""); + const [defaultWorkspace, setDefaultWorkspace] = useState(0); + + const tagsInputRef = useRef(null); + + if (!sessionToken || sessionToken == "") { + handleAuthStuff() + .then((a) => { + setSessionToken(a.sessionToken); + setDefaultWorkspace(a.defaultWorkspace); + }) + .catch((error) => { + showToast({ + style: Toast.Style.Failure, + title: "Oops! Something went wrong with authentication", + message: error.message, + }); + }); + } + + const [workspaces, setWorkspaces] = useState([]); + useEffect(() => { + if (workspaces.length > 0 || sessionToken == "") { + return; + } + + fetch(`${preferences["instance-url"]}/api/v1/workspaces?archived=false`, { + method: "GET", + headers: { + Authorization: sessionToken, + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then((data) => { + return data.items.map((workspace: any) => { + return { name: workspace.name, id: workspace.id }; + }); + }) + .then((its: Workspace[]) => { + setWorkspaces(its); + }); + }, [setWorkspaces, sessionToken]); + const { title, url, trunk } = props.arguments ?? { title: "", url: "", trunk: undefined }; + + const { itemProps: destinationTypeProps } = useForm({ + initialValues: props.draftValues || { + type: "location", + location: url || "", + }, + onSubmit: () => {}, + }); + + const { itemProps: destinationProps } = useForm({ + initialValues: props.draftValues || { + type: "location", + location: url || undefined, + headline: title || "", + body: "", + tags: "", + workspaceId: trunk ?? defaultWorkspace ?? "0", + }, + onSubmit: () => {}, + }); + + return ( + <> +
+ {(destinationProps.type.value == "note" && destinationProps.headline.value != "") || + (destinationProps.type.value == "location" && + destinationProps.location.value != "" && + destinationProps.location.value != undefined) ? ( + { + createDestination( + destinationProps.headline.value ?? "New Destination", + destinationTypeProps.type.value == "location" ? "location" : "note", + destinationProps.body.value, + destinationTypeProps.location.value, + destinationProps.tags.value ?? "", + destinationProps.workspaceId.value ?? defaultWorkspace.toString(), + sessionToken, + ) + .then(() => { + showToast({ + style: Toast.Style.Success, + title: "Success!", + message: "Destination created successfully", + }); + }) + .catch((error) => { + showToast({ + style: Toast.Style.Failure, + title: "Oops! Something went wrong while creating the destination", + message: error.message, + }); + }); + popToRoot(); + }} + /> + ) : null} + {destinationTypeProps.type.value == "location" && + (destinationProps.location.value == "" || + !destinationProps.location.value || + destinationProps.location.value != destinationTypeProps.location.value) ? ( + { + if ( + destinationTypeProps.type.value == "location" && + destinationTypeProps.location.value != destinationProps.location.value && + destinationTypeProps.location.value != undefined && + destinationTypeProps.location.value != "" + ) { + const webDetails = await getWebDetails(destinationTypeProps.location.value); + if ( + !destinationProps.location.onChange || + !destinationTypeProps.location.onChange || + !destinationProps.headline.onChange || + !destinationProps.body.onChange || + !destinationProps.type.onChange || + !destinationTypeProps.type.onChange + ) { + return; + } + destinationProps.type.onChange("location"); + destinationProps.headline.onChange( + webDetails.title[0] ?? + webDetails.title[1] ?? + webDetails.title[2] ?? + destinationTypeProps.location.value ?? + "", + ); + destinationProps.body.onChange( + webDetails.description[0] ?? webDetails.description[1] ?? (webDetails.description[2] || ""), + ); + destinationProps.location.onChange(webDetails.url); + destinationTypeProps.location.onChange(webDetails.url); + tagsInputRef.current?.focus(); + } + }} + /> + ) : null} + + } + > + + + + + {destinationTypeProps.type.value == "location" && ( + + )} + + {((destinationProps.type.value == "location" && + destinationProps.location.value && + destinationProps.location.value != "") || + destinationTypeProps.type.value == "note") && ( + <> + + + + + + + + {workspaces.map((workspace) => ( + + ))} + + + )} + + + ); +} diff --git a/src/list-destinations.tsx b/src/list-destinations.tsx new file mode 100644 --- /dev/null +++ b/src/list-destinations.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { Icon, Action, ActionPanel, List, useNavigation } from "@raycast/api"; +import { usePromise } from "@raycast/utils"; + +import { fetchDestinations } from "./utils/destination"; +import { handleAuthStuff, setDefaultWorkspace } from "./utils/auth"; +import CreateDestination from "./create-destination"; + +export default function ListDestinations() { + const [searchText, setSearchText] = useState(""); + + const [sessionToken, setSessionToken] = useState(""); + const { push } = useNavigation(); + + const { isLoading, data, pagination } = usePromise( + (searchText: string) => async (options: { page: number }) => { + if (!sessionToken || sessionToken == "") { + return { data: [], hasMore: false }; + } + const newData = await fetchDestinations(searchText, options.page * 10, sessionToken); + return { + data: newData.items.map((it, i) => { + it.page = options.page; + it.text = it.name; + it.index = i; + return it; + }), + hasMore: options.page * 10 + 10 < newData.count, + }; + }, + [searchText], + ); + + if (!sessionToken || sessionToken == "") { + handleAuthStuff() + .then(async (a) => { + setSessionToken(a.sessionToken); + setSearchText("e"); + await new Promise((r) => setTimeout(r, 100)); + setSearchText(""); + }) + .catch((error) => { + showToast({ + style: Toast.Style.Failure, + title: "Oops! Something went wrong with authentication", + message: error.message, + }); + }); + } + + return ( + + push()} /> + + } + > + {data?.length === 0 ? ( + + ) : ( + data?.map((item) => ( + + )) + )} + + ); +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,55 @@ +// @ts-nocheck +import { getPreferenceValues, LocalStorage } from "@raycast/api"; + +export async function handleAuthStuff() { + const preferences = getPreferenceValues(); + + let session = await LocalStorage.getItem("session-token"); + + if (preferences["session-token"] && preferences["session-token"] != "") { + session = preferences["session-token"]; + } + let sessionInfo = { + id: false, + }; + + if (session && session != "") { + sessionInfo = await fetch(`${preferences["instance-url"]}/api/v1/session`, { + headers: { + Authorization: session, + "Content-Type": "application/json", + }, + }).then((res) => res.json()); + } + + if ( + !sessionInfo.id || + (sessionInfo.user.email != preferences["email"] && !sessionInfo.user.githubId && !sessionInfo.user.googleId) + ) { + await LocalStorage.setItem("session-token", ""); + + let newSessionInfo = { success: false }; + if (!preferences["session-token"]) { + newSessionInfo = await fetch(`${preferences["instance-url"]}/api/v1/session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: preferences["email"], + password: preferences["password"], + }), + }).then((res) => res.json()); + } + + if (!newSessionInfo.success) { + await LocalStorage.setItem("session-token", ""); + throw new Error("Failed to authenticate with the provided credentials."); + } + + await LocalStorage.setItem("session-token", newSessionInfo.token); + return { sessionToken: newSessionInfo.token, defaultWorkspace: newSessionInfo.user.workspace }; + } + + return { sessionToken: session, defaultWorkspace: sessionInfo.user.workspace }; +} diff --git a/src/utils/destination.ts b/src/utils/destination.ts new file mode 100644 --- /dev/null +++ b/src/utils/destination.ts @@ -0,0 +1,52 @@ +import { getPreferenceValues } from "@raycast/api"; + +export async function fetchDestinations(searchText: string, offset: number, sessionToken: string): Promise { + const preferences = getPreferenceValues(); + const response = await fetch( + `${preferences["instance-url"]}/api/v1/destinations?searchString=${searchText}&offset=${offset}&limit=12&archived=false`, + { + method: "GET", + headers: { + Authorization: sessionToken, + "Content-Type": "application/json", + }, + }, + ) + .then((res) => res.json()) + .catch((e) => { + throw new Error(e.message); + }); + + return { items: response.items, count: response.count }; +} + +export async function createDestination( + title: string, + type: "note" | "location", + body: string | undefined, + url: string | undefined, + tags: string, + workspaceId: string, + sessionToken: string, +): Promise { + const preferences = getPreferenceValues(); + const response = await fetch(`${preferences["instance-url"]}/api/v1/destinations`, { + method: "POST", + headers: { + Authorization: sessionToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: title, + body, + type, + location: url, + tags: tags.split(",").map((tag) => tag.trim()), + workspaceId: parseInt(workspaceId, 10), + }), + }).then((res) => res.json()); + + if (!response.success) { + throw new Error(`${response.statusText}`); + } +} -- tangled.sh