diff --git a/extensions/anytype/CHANGELOG.md b/extensions/anytype/CHANGELOG.md index 991298a7730..b2b3a12b9ad 100644 --- a/extensions/anytype/CHANGELOG.md +++ b/extensions/anytype/CHANGELOG.md @@ -1,6 +1,40 @@ # Anytype Changelog -## [✨ AI Enhancements & Improvements] - 2025-03-XX +## [Properties, Types & Tags] - 2025-05-07 + +#### New Creation Options + +When browsing spaces, press `CMD+N` to create new objects, types, properties or tags. + +- Add ability to create new spaces +- Add ability to create new types +- Add ability to create new properties +- Add ability to create new tags + +#### Edit Form Enhancements + +Use `CMD+E` to quickly edit the currently selected item - whether it's a space, object, type, property, or tag. + +- Add support for editing spaces +- Add support for editing objects +- Add support for editing types +- Add support for editing properties +- Add support for editing tags + +#### New Commands & Navigation + +- Add new command to add objects to lists +- Pop back to list view when deleting object, with automatic refresh +- List properties when browsing space +- Browse tags for select/multi-select properties +- Open bookmarks directly in browser + +#### Form Improvements + +- Allow custom properties (inherited from type) for object creation +- Improve number and emoji validation logic in create form + +## [✨ AI Enhancements & Improvements] - 2025-04-22 #### AI Extension diff --git a/extensions/anytype/assets/icons/object/action.svg b/extensions/anytype/assets/icons/object/action.svg new file mode 100644 index 00000000000..d7ef6e1b35b --- /dev/null +++ b/extensions/anytype/assets/icons/object/action.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/anytype/assets/icons/object/basic.svg b/extensions/anytype/assets/icons/object/basic.svg new file mode 100644 index 00000000000..525ccd9378e --- /dev/null +++ b/extensions/anytype/assets/icons/object/basic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extensions/anytype/assets/icons/object/note.svg b/extensions/anytype/assets/icons/object/note.svg new file mode 100644 index 00000000000..b50e453c717 --- /dev/null +++ b/extensions/anytype/assets/icons/object/note.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/extensions/anytype/assets/icons/object/profile.svg b/extensions/anytype/assets/icons/object/profile.svg new file mode 100644 index 00000000000..5acd840004e --- /dev/null +++ b/extensions/anytype/assets/icons/object/profile.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/extensions/anytype/assets/icons/property/checkbox.svg b/extensions/anytype/assets/icons/property/checkbox.svg new file mode 100644 index 00000000000..e8e79eac89f --- /dev/null +++ b/extensions/anytype/assets/icons/property/checkbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/anytype/assets/icons/property/files.svg b/extensions/anytype/assets/icons/property/files.svg new file mode 100644 index 00000000000..5f07e8f48f6 --- /dev/null +++ b/extensions/anytype/assets/icons/property/files.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/extensions/anytype/assets/icons/property/multi_select.svg b/extensions/anytype/assets/icons/property/multi_select.svg new file mode 100644 index 00000000000..ecc7b580678 --- /dev/null +++ b/extensions/anytype/assets/icons/property/multi_select.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/extensions/anytype/assets/icons/property/objects.svg b/extensions/anytype/assets/icons/property/objects.svg new file mode 100644 index 00000000000..f0be3d3fd76 --- /dev/null +++ b/extensions/anytype/assets/icons/property/objects.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/extensions/anytype/assets/icons/property/tag.svg b/extensions/anytype/assets/icons/property/tag.svg new file mode 100644 index 00000000000..daaaa6d01c6 --- /dev/null +++ b/extensions/anytype/assets/icons/property/tag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/anytype/package-lock.json b/extensions/anytype/package-lock.json index f8f00f98b5a..63852409b88 100644 --- a/extensions/anytype/package-lock.json +++ b/extensions/anytype/package-lock.json @@ -10,6 +10,7 @@ "@raycast/api": "^1.93.1", "@raycast/utils": "^1.17.0", "date-fns": "^4.1.0", + "emoji-regex": "^10.4.0", "node-fetch": "^3.3.2" }, "devDependencies": { @@ -2164,13 +2165,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, "node_modules/cli-truncate/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -2593,9 +2587,9 @@ "license": "MIT" }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/end-of-stream": { @@ -4077,13 +4071,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, "node_modules/listr2/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -4221,13 +4208,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", @@ -5513,6 +5493,12 @@ "node": ">=8" } }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string-width/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", diff --git a/extensions/anytype/package.json b/extensions/anytype/package.json index 25053cd775f..5343c144f30 100644 --- a/extensions/anytype/package.json +++ b/extensions/anytype/package.json @@ -33,6 +33,13 @@ "subtitle": "Anytype", "description": "Search objects globally in all spaces.", "mode": "view" + }, + { + "name": "add-to-list", + "title": "Add to List", + "subtitle": "Anytype", + "description": "Add an existing object to a list.", + "mode": "view" } ], "tools": [ @@ -114,8 +121,8 @@ "object": "type", "name": "Task", "id": "bafyreigs5crqbryk2sq45ig4p6uqkcbmntuyosc1djvdeqoydj23b6d1mm", - "type_key": "ot-task", - "recommended_layout": "todo" + "type_key": "task", + "layout": "action" } ], "search-space": [ @@ -173,15 +180,15 @@ "types": { "or": [ { - "includes": "ot-task" + "includes": "task" }, { "includes": "bafyreigs5crqbryk2sq45ig4p6uqkcbmntuyosc1djvdeqoydj23b6d1mm" }, { "includes": [ - "ot-collection", - "ot-set" + "collection", + "set" ] } ] @@ -225,8 +232,8 @@ "object": "type", "name": "Page", "id": "bafyreigs5crqbryk2sq45ig4p6uqkcbmntuyosc1djvdeqoydj23b6d1pp", - "type_key": "ot-page", - "recommended_layout": "basic" + "type_key": "page", + "layout": "basic" } ], "create-object": [ @@ -238,7 +245,7 @@ "type": { "id": "bafyreigs5crqbryk2sq45ig4p6uqkcbmntuyosc1djvdeqoydj23b6d1mm", "name": "Page", - "type_key": "ot-page" + "type_key": "page" }, "snippet": "This is the snippet of the object." } @@ -264,7 +271,7 @@ "name": "create-object", "arguments": { "spaceId": "bafyreihliylimyqct7vbyc2jqsoanibku656jj4uwwhulx4tctb4qw346q.2lcu0r85yg10d", - "type_key": "ot-page", + "type_key": "page", "name": { "includes": "Privacy" } @@ -481,8 +488,8 @@ "object": "type", "name": "Page", "id": "bafyreigs5crqbryk2sq45ig4p6uqkcbmntuyosc1djvdeqoydj23b6d1pp", - "type_key": "ot-page", - "recommended_layout": "basic" + "type_key": "page", + "layout": "basic" } ] }, @@ -640,7 +647,7 @@ "arguments": { "spaceId": "bafyreihliylimyqct7vbyc2jqsoanibku656jj4uwwhulx4tctb4qw346q.2lcu0r85yg10d", "query": "", - "sort.property": "last_modified_date", + "sort.propertyKey": "last_modified_date", "sort.direction": "desc" } } @@ -684,7 +691,7 @@ "type": "dropdown", "data": [ { - "title": "Created Date", + "title": "Creation Date", "value": "created_date" }, { @@ -709,10 +716,6 @@ "description": "The maximum number of objects to fetch from the Anytype API at once. Be careful when changing this, as a large number can cause performance issues.", "type": "dropdown", "data": [ - { - "title": "25", - "value": "25" - }, { "title": "50", "value": "50" @@ -720,9 +723,13 @@ { "title": "100", "value": "100" + }, + { + "title": "200", + "value": "200" } ], - "default": "50", + "default": "100", "required": false }, { @@ -748,6 +755,7 @@ "@raycast/api": "^1.93.1", "@raycast/utils": "^1.17.0", "date-fns": "^4.1.0", + "emoji-regex": "^10.4.0", "node-fetch": "^3.3.2" }, "devDependencies": { diff --git a/extensions/anytype/src/add-to-list.tsx b/extensions/anytype/src/add-to-list.tsx new file mode 100644 index 00000000000..82547908053 --- /dev/null +++ b/extensions/anytype/src/add-to-list.tsx @@ -0,0 +1,148 @@ +import { Action, ActionPanel, Form, popToRoot, showToast, Toast } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { useEffect, useState } from "react"; +import { addObjectsToList } from "./api"; +import { EnsureAuthenticated } from "./components/EnsureAuthenticated"; +import { useObjectsInList, useSearch, useSpaces } from "./hooks"; +import { bundledTypeKeys } from "./utils"; + +export interface AddToListValues { + space: string; + list: string; + object: string; +} + +export default function Command() { + return ( + + + + ); +} + +export function AddToList() { + const [loading, setLoading] = useState(false); + const [listSearchText, setListSearchText] = useState(""); + const [objectSearchText, setObjectSearchText] = useState(""); + const [selectedSpace, setSelectedSpace] = useState(""); + const [selectedList, setSelectedList] = useState(""); + const [selectedObject, setSelectedObject] = useState(""); + + const { spaces, spacesError, isLoadingSpaces } = useSpaces(); + const { + objects: lists, + objectsError: listsError, + isLoadingObjects: isLoadingLists, + } = useSearch(selectedSpace, listSearchText, [bundledTypeKeys.collection]); + const { objects, objectsError, isLoadingObjects } = useSearch(selectedSpace, objectSearchText, []); + const { + objects: listItems, + objectsError: listItemsError, + isLoadingObjects: isLoadingListItems, + } = useObjectsInList(selectedSpace, selectedList, ""); + + useEffect(() => { + if (spacesError || objectsError || listsError || listItemsError) { + showFailureToast(spacesError || objectsError || listsError || listItemsError, { + title: "Failed to fetch latest data", + }); + } + }, [spacesError, objectsError, listsError]); + + const { handleSubmit, itemProps } = useForm({ + onSubmit: async (values) => { + setLoading(true); + try { + await showToast(Toast.Style.Animated, "Adding object to list..."); + const response = await addObjectsToList(values.space, values.list, [values.object]); + if (response.payload) { + await showToast(Toast.Style.Success, "Object added to list successfully", response.payload); + popToRoot(); + } else { + await showToast(Toast.Style.Failure, "Failed to add object to list"); + } + } catch (error) { + await showFailureToast(error, { title: "Failed to add object to list" }); + } finally { + setLoading(false); + } + }, + validation: { + space: (value) => { + if (!value) { + return "Space is required"; + } + }, + list: (value) => { + if (!value) { + return "List is required"; + } + }, + object: (value) => { + if (!value) { + return "Object is required"; + } + }, + }, + }); + + return ( +
+ + + } + > + + {spaces.map((space) => ( + + ))} + + + + {lists.map((list) => ( + + ))} + + + {selectedList && ( + + {objects + .filter((object) => !listItems.some((item) => item.id === object.id) && object.id !== selectedList) + .map((object) => ( + + ))} + + )} + + ); +} diff --git a/extensions/anytype/src/api/auth/validateToken.ts b/extensions/anytype/src/api/auth/validateToken.ts index 5ddb3ec5f4f..4a7ddf2b63a 100644 --- a/extensions/anytype/src/api/auth/validateToken.ts +++ b/extensions/anytype/src/api/auth/validateToken.ts @@ -1,4 +1,4 @@ -import { showToast, Toast } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; import { PaginatedResponse, RawSpace } from "../../models"; import { apiEndpoints, apiFetch, currentApiVersion, errorConnectionMessage } from "../../utils"; @@ -10,17 +10,15 @@ export async function checkApiTokenValidity(): Promise { const apiVersion = response.headers.get("Anytype-Version"); if (!apiVersion || apiVersion < currentApiVersion) { - showToast( - Toast.Style.Failure, - "App Update Required", - `Please update the Anytype app to match the extension's API version ${currentApiVersion}.`, - ); + await showFailureToast({ + title: "App Update Required", + message: `Please update the Anytype app to match the extension's API version ${currentApiVersion}.`, + }); } else if (apiVersion > currentApiVersion) { - showToast( - Toast.Style.Failure, - "Extension Update Required", - `Please update the extension to match the Anytype app's API version ${apiVersion}.`, - ); + await showFailureToast({ + title: "Extension Update Required", + message: `Please update the extension to match the Anytype app's API version ${apiVersion}.`, + }); } return true; } catch (error) { diff --git a/extensions/anytype/src/api/export/getExport.ts b/extensions/anytype/src/api/export/getExport.ts deleted file mode 100644 index d484c9635bc..00000000000 --- a/extensions/anytype/src/api/export/getExport.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Export, ExportFormat } from "../../models"; -import { apiEndpoints, apiFetch } from "../../utils"; - -export async function getExport(spaceId: string, objectId: string, format: ExportFormat): Promise { - const { url, method } = apiEndpoints.getExport(spaceId, objectId, format); - const response = await apiFetch(url, { method: method }); - return response.payload; -} diff --git a/extensions/anytype/src/api/index.ts b/extensions/anytype/src/api/index.ts index 0f9828ee7a6..d3d385c5df6 100644 --- a/extensions/anytype/src/api/index.ts +++ b/extensions/anytype/src/api/index.ts @@ -1,7 +1,6 @@ export * from "./auth/displayCode"; export * from "./auth/getToken"; export * from "./auth/validateToken"; -export * from "./export/getExport"; export * from "./lists/addObjectsToList"; export * from "./lists/getListViews"; export * from "./lists/getObjectsInList"; @@ -13,11 +12,26 @@ export * from "./objects/createObject"; export * from "./objects/deleteObject"; export * from "./objects/getObject"; export * from "./objects/getObjects"; +export * from "./objects/updateObject"; +export * from "./properties/createProperty"; +export * from "./properties/deleteProperty"; +export * from "./properties/getProperties"; +export * from "./properties/getProperty"; +export * from "./properties/updateProperty"; export * from "./search/globalSearch"; export * from "./search/search"; export * from "./spaces/createSpace"; export * from "./spaces/getSpace"; export * from "./spaces/getSpaces"; +export * from "./spaces/updateSpace"; +export * from "./tags/createTag"; +export * from "./tags/deleteTag"; +export * from "./tags/getTag"; +export * from "./tags/getTags"; +export * from "./tags/updateTag"; export * from "./templates/getTemplates"; +export * from "./types/createType"; +export * from "./types/deleteType"; export * from "./types/getType"; export * from "./types/getTypes"; +export * from "./types/updateType"; diff --git a/extensions/anytype/src/api/lists/addObjectsToList.ts b/extensions/anytype/src/api/lists/addObjectsToList.ts index 1a24a600b97..8f43c871214 100644 --- a/extensions/anytype/src/api/lists/addObjectsToList.ts +++ b/extensions/anytype/src/api/lists/addObjectsToList.ts @@ -1,9 +1,14 @@ import { apiEndpoints, apiFetch } from "../../utils"; +import { ApiResponse } from "../../utils/api"; -export async function addObjectsToList(spaceId: string, listId: string, objectIds: string[]): Promise { +export async function addObjectsToList( + spaceId: string, + listId: string, + objectIds: string[], +): Promise> { const { url, method } = apiEndpoints.addObjectsToList(spaceId, listId); - await apiFetch(url, { + return await apiFetch(url, { method: method, body: JSON.stringify(objectIds), }); diff --git a/extensions/anytype/src/api/members/getMember.ts b/extensions/anytype/src/api/members/getMember.ts index 5041102b194..58be9ba91f2 100644 --- a/extensions/anytype/src/api/members/getMember.ts +++ b/extensions/anytype/src/api/members/getMember.ts @@ -2,15 +2,8 @@ import { mapMember } from "../../mappers/members"; import { Member, RawMember } from "../../models"; import { apiEndpoints, apiFetch } from "../../utils"; -export async function getMember( - spaceId: string, - objectId: string, -): Promise<{ - member: Member | null; -}> { +export async function getMember(spaceId: string, objectId: string): Promise<{ member: Member }> { const { url, method } = apiEndpoints.getMember(spaceId, objectId); const response = await apiFetch<{ member: RawMember }>(url, { method: method }); - return { - member: response ? await mapMember(response.payload.member) : null, - }; + return { member: await mapMember(response.payload.member) }; } diff --git a/extensions/anytype/src/api/members/updateMember.ts b/extensions/anytype/src/api/members/updateMember.ts index 696a8c20da5..21efbeea06d 100644 --- a/extensions/anytype/src/api/members/updateMember.ts +++ b/extensions/anytype/src/api/members/updateMember.ts @@ -7,9 +7,7 @@ export async function updateMember( spaceId: string, memberId: string, data: UpdateMemberRequest, -): Promise<{ - member: Member | null; -}> { +): Promise<{ member: Member }> { const { url, method } = apiEndpoints.updateMember(spaceId, memberId); const response = await apiFetch<{ member: RawMember }>(url, { @@ -17,7 +15,5 @@ export async function updateMember( body: JSON.stringify(data), }); - return { - member: response ? await mapMember(response.payload.member) : null, - }; + return { member: await mapMember(response.payload.member) }; } diff --git a/extensions/anytype/src/api/objects/deleteObject.ts b/extensions/anytype/src/api/objects/deleteObject.ts index a65dafb2460..4d68a92ba27 100644 --- a/extensions/anytype/src/api/objects/deleteObject.ts +++ b/extensions/anytype/src/api/objects/deleteObject.ts @@ -1,10 +1,13 @@ -import { RawSpaceObject } from "../../models"; +import { mapObject } from "../../mappers/objects"; +import { RawSpaceObject, SpaceObject } from "../../models"; import { apiEndpoints, apiFetch } from "../../utils"; -export async function deleteObject(spaceId: string, objectId: string): Promise { +export async function deleteObject(spaceId: string, objectId: string): Promise { const { url, method } = apiEndpoints.deleteObject(spaceId, objectId); - await apiFetch(url, { + const response = await apiFetch<{ object: RawSpaceObject }>(url, { method: method, }); + + return await mapObject(response.payload.object); } diff --git a/extensions/anytype/src/api/objects/getObject.ts b/extensions/anytype/src/api/objects/getObject.ts index 5c992c6caca..819cf442ed0 100644 --- a/extensions/anytype/src/api/objects/getObject.ts +++ b/extensions/anytype/src/api/objects/getObject.ts @@ -1,27 +1,35 @@ import { mapObject } from "../../mappers/objects"; import { mapType } from "../../mappers/types"; -import { RawSpaceObjectWithBlocks, SpaceObject } from "../../models"; +import { BodyFormat, RawSpaceObjectWithBody, SpaceObjectWithBody } from "../../models"; import { apiEndpoints, apiFetch, getIconWithFallback } from "../../utils"; export async function getObject( spaceId: string, objectId: string, -): Promise<{ - object: SpaceObject | null; -}> { - const { url, method } = apiEndpoints.getObject(spaceId, objectId); - const response = await apiFetch<{ object: RawSpaceObjectWithBlocks }>(url, { method: method }); - return { - object: response ? await mapObject(response.payload.object) : null, - }; + format: BodyFormat, +): Promise<{ object: SpaceObjectWithBody }> { + const { url, method } = apiEndpoints.getObject(spaceId, objectId, format); + const response = await apiFetch<{ object: RawSpaceObjectWithBody }>(url, { method: method }); + return { object: (await mapObject(response.payload.object)) as SpaceObjectWithBody }; +} + +export async function getRawObject( + spaceId: string, + objectId: string, + format: BodyFormat, +): Promise<{ object: RawSpaceObjectWithBody }> { + const { url, method } = apiEndpoints.getObject(spaceId, objectId, format); + const response = await apiFetch<{ object: RawSpaceObjectWithBody }>(url, { method }); + return { object: response.payload.object }; } -export async function getObjectWithoutMappedDetails(spaceId: string, objectId: string): Promise { - const { url, method } = apiEndpoints.getObject(spaceId, objectId); - const response = await apiFetch<{ object: RawSpaceObjectWithBlocks }>(url, { method }); - if (!response) { - return null; - } +export async function getObjectWithoutMappedProperties( + spaceId: string, + objectId: string, + format: BodyFormat, +): Promise { + const { url, method } = apiEndpoints.getObject(spaceId, objectId, format); + const response = await apiFetch<{ object: RawSpaceObjectWithBody }>(url, { method }); const { object } = response.payload; const icon = await getIconWithFallback(object.icon, object.layout, object.type); @@ -29,7 +37,9 @@ export async function getObjectWithoutMappedDetails(spaceId: string, objectId: s return { ...object, icon, - name: object.name || "Untitled", + name: object.name?.trim() || "Untitled", type: await mapType(object.type), + properties: [], // performance optimization + markdown: "", // performance optimization }; } diff --git a/extensions/anytype/src/api/objects/updateObject.ts b/extensions/anytype/src/api/objects/updateObject.ts new file mode 100644 index 00000000000..339d5a39b45 --- /dev/null +++ b/extensions/anytype/src/api/objects/updateObject.ts @@ -0,0 +1,18 @@ +import { mapObject } from "../../mappers/objects"; +import { RawSpaceObject, SpaceObject, UpdateObjectRequest } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function updateObject( + spaceId: string, + objectId: string, + data: UpdateObjectRequest, +): Promise<{ object: SpaceObject }> { + const { url, method } = apiEndpoints.updateObject(spaceId, objectId); + + const response = await apiFetch<{ object: RawSpaceObject }>(url, { + method: method, + body: JSON.stringify(data), + }); + + return { object: await mapObject(response.payload.object) }; +} diff --git a/extensions/anytype/src/api/properties/createProperty.ts b/extensions/anytype/src/api/properties/createProperty.ts new file mode 100644 index 00000000000..cbc9bbb9924 --- /dev/null +++ b/extensions/anytype/src/api/properties/createProperty.ts @@ -0,0 +1,19 @@ +import { mapProperty } from "../../mappers/properties"; +import { CreatePropertyRequest, Property, RawProperty } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function createProperty( + spaceId: string, + propertyData: CreatePropertyRequest, +): Promise<{ property: Property | null }> { + const { url, method } = apiEndpoints.createProperty(spaceId); + + const response = await apiFetch<{ property: RawProperty }>(url, { + method: method, + body: JSON.stringify(propertyData), + }); + + return { + property: response ? mapProperty(response.payload.property) : null, + }; +} diff --git a/extensions/anytype/src/api/properties/deleteProperty.ts b/extensions/anytype/src/api/properties/deleteProperty.ts new file mode 100644 index 00000000000..347a62dd2f2 --- /dev/null +++ b/extensions/anytype/src/api/properties/deleteProperty.ts @@ -0,0 +1,13 @@ +import { mapProperty } from "../../mappers/properties"; +import { Property, RawProperty } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function deleteProperty(spaceId: string, propertyId: string): Promise { + const { url, method } = apiEndpoints.deleteProperty(spaceId, propertyId); + + const response = await apiFetch<{ property: RawProperty }>(url, { + method: method, + }); + + return mapProperty(response.payload.property); +} diff --git a/extensions/anytype/src/api/properties/getProperties.ts b/extensions/anytype/src/api/properties/getProperties.ts new file mode 100644 index 00000000000..c1843051571 --- /dev/null +++ b/extensions/anytype/src/api/properties/getProperties.ts @@ -0,0 +1,19 @@ +import { mapProperties } from "../../mappers/properties"; +import { PaginatedResponse, Pagination, Property, RawProperty } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function getProperties( + spaceId: string, + options: { offset: number; limit: number }, +): Promise<{ + properties: Property[]; + pagination: Pagination; +}> { + const { url, method } = apiEndpoints.getProperties(spaceId, options); + const response = await apiFetch>(url, { method: method }); + + return { + properties: response.payload.data ? mapProperties(response.payload.data) : [], + pagination: response.payload.pagination, + }; +} diff --git a/extensions/anytype/src/api/properties/getProperty.ts b/extensions/anytype/src/api/properties/getProperty.ts new file mode 100644 index 00000000000..032c5fce9f9 --- /dev/null +++ b/extensions/anytype/src/api/properties/getProperty.ts @@ -0,0 +1,11 @@ +import { mapProperty } from "../../mappers/properties"; +import { Property, RawProperty } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function getProperty(spaceId: string, propertyId: string): Promise<{ property: Property }> { + const { url, method } = apiEndpoints.getProperty(spaceId, propertyId); + const response = await apiFetch<{ property: RawProperty }>(url, { method: method }); + return { + property: mapProperty(response.payload.property), + }; +} diff --git a/extensions/anytype/src/api/properties/updateProperty.ts b/extensions/anytype/src/api/properties/updateProperty.ts new file mode 100644 index 00000000000..087c612d7bc --- /dev/null +++ b/extensions/anytype/src/api/properties/updateProperty.ts @@ -0,0 +1,18 @@ +import { mapProperty } from "../../mappers/properties"; +import { Property, RawProperty, UpdatePropertyRequest } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function updateProperty( + spaceId: string, + propertyId: string, + data: UpdatePropertyRequest, +): Promise<{ property: Property }> { + const { url, method } = apiEndpoints.updateProperty(spaceId, propertyId); + + const response = await apiFetch<{ property: RawProperty }>(url, { + method: method, + body: JSON.stringify(data), + }); + + return { property: mapProperty(response.payload.property) }; +} diff --git a/extensions/anytype/src/api/spaces/getSpace.ts b/extensions/anytype/src/api/spaces/getSpace.ts index b8ddacf6cea..dc0637845ed 100644 --- a/extensions/anytype/src/api/spaces/getSpace.ts +++ b/extensions/anytype/src/api/spaces/getSpace.ts @@ -2,12 +2,8 @@ import { mapSpace } from "../../mappers/spaces"; import { RawSpace, Space } from "../../models"; import { apiEndpoints, apiFetch } from "../../utils"; -export async function getSpace(spaceId: string): Promise<{ - space: Space | null; -}> { +export async function getSpace(spaceId: string): Promise<{ space: Space }> { const { url, method } = apiEndpoints.getSpace(spaceId); const response = await apiFetch<{ space: RawSpace }>(url, { method: method }); - return { - space: response ? await mapSpace(response.payload.space) : null, - }; + return { space: await mapSpace(response.payload.space) }; } diff --git a/extensions/anytype/src/api/spaces/updateSpace.ts b/extensions/anytype/src/api/spaces/updateSpace.ts new file mode 100644 index 00000000000..0eb94245cb7 --- /dev/null +++ b/extensions/anytype/src/api/spaces/updateSpace.ts @@ -0,0 +1,14 @@ +import { mapSpace } from "../../mappers/spaces"; +import { RawSpace, Space, UpdateSpaceRequest } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function updateSpace(spaceId: string, data: UpdateSpaceRequest): Promise<{ space: Space }> { + const { url, method } = apiEndpoints.updateSpace(spaceId); + + const response = await apiFetch<{ space: RawSpace }>(url, { + method: method, + body: JSON.stringify(data), + }); + + return { space: await mapSpace(response.payload.space) }; +} diff --git a/extensions/anytype/src/api/tags/createTag.ts b/extensions/anytype/src/api/tags/createTag.ts new file mode 100644 index 00000000000..9adeff91a45 --- /dev/null +++ b/extensions/anytype/src/api/tags/createTag.ts @@ -0,0 +1,20 @@ +import { mapTag } from "../../mappers/properties"; +import { CreateTagRequest, RawTag, Tag } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function createTag( + spaceId: string, + propertyId: string, + tagData: CreateTagRequest, +): Promise<{ tag: Tag | null }> { + const { url, method } = apiEndpoints.createTag(spaceId, propertyId); + + const response = await apiFetch<{ tag: RawTag }>(url, { + method: method, + body: JSON.stringify(tagData), + }); + + return { + tag: response ? mapTag(response.payload.tag) : null, + }; +} diff --git a/extensions/anytype/src/api/tags/deleteTag.ts b/extensions/anytype/src/api/tags/deleteTag.ts new file mode 100644 index 00000000000..7a853ea3fe4 --- /dev/null +++ b/extensions/anytype/src/api/tags/deleteTag.ts @@ -0,0 +1,13 @@ +import { mapTag } from "../../mappers/properties"; +import { RawTag, Tag } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function deleteTag(spaceId: string, propertyId: string, tagId: string): Promise { + const { url, method } = apiEndpoints.deleteTag(spaceId, propertyId, tagId); + + const response = await apiFetch<{ tag: RawTag }>(url, { + method: method, + }); + + return mapTag(response.payload.tag); +} diff --git a/extensions/anytype/src/api/tags/getTag.ts b/extensions/anytype/src/api/tags/getTag.ts new file mode 100644 index 00000000000..ccbd5c9f0f6 --- /dev/null +++ b/extensions/anytype/src/api/tags/getTag.ts @@ -0,0 +1,11 @@ +import { mapTag } from "../../mappers/properties"; +import { RawTag, Tag } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function getTag(spaceId: string, propertyId: string, tagId: string): Promise<{ tag: Tag }> { + const { url, method } = apiEndpoints.getTag(spaceId, propertyId, tagId); + const response = await apiFetch<{ tag: RawTag }>(url, { method: method }); + return { + tag: mapTag(response.payload.tag), + }; +} diff --git a/extensions/anytype/src/api/tags/getTags.ts b/extensions/anytype/src/api/tags/getTags.ts new file mode 100644 index 00000000000..62df7d08fdc --- /dev/null +++ b/extensions/anytype/src/api/tags/getTags.ts @@ -0,0 +1,20 @@ +import { mapTags } from "../../mappers/properties"; +import { PaginatedResponse, Pagination, RawTag, Tag } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function getTags( + spaceId: string, + propertyId: string, + options: { offset: number; limit: number }, +): Promise<{ + tags: Tag[]; + pagination: Pagination; +}> { + const { url, method } = apiEndpoints.getTags(spaceId, propertyId, options); + const response = await apiFetch>(url, { method: method }); + + return { + tags: response.payload.data ? mapTags(response.payload.data) : [], + pagination: response.payload.pagination, + }; +} diff --git a/extensions/anytype/src/api/tags/updateTag.ts b/extensions/anytype/src/api/tags/updateTag.ts new file mode 100644 index 00000000000..6047b32b72d --- /dev/null +++ b/extensions/anytype/src/api/tags/updateTag.ts @@ -0,0 +1,19 @@ +import { mapTag } from "../../mappers/properties"; +import { RawTag, Tag, UpdateTagRequest } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function updateTag( + spaceId: string, + propertyId: string, + tagId: string, + data: UpdateTagRequest, +): Promise<{ tag: Tag }> { + const { url, method } = apiEndpoints.updateTag(spaceId, propertyId, tagId); + + const response = await apiFetch<{ tag: RawTag }>(url, { + method: method, + body: JSON.stringify(data), + }); + + return { tag: mapTag(response.payload.tag) }; +} diff --git a/extensions/anytype/src/api/templates/getTemplates.ts b/extensions/anytype/src/api/templates/getTemplates.ts index 146cc68dc94..b6680c92d93 100644 --- a/extensions/anytype/src/api/templates/getTemplates.ts +++ b/extensions/anytype/src/api/templates/getTemplates.ts @@ -1,5 +1,5 @@ -import { mapTemplates } from "../../mappers/templates"; -import { PaginatedResponse, Pagination, RawTemplate, Template } from "../../models"; +import { mapObjects } from "../../mappers/objects"; +import { PaginatedResponse, Pagination, RawSpaceObject, SpaceObject } from "../../models"; import { apiEndpoints, apiFetch } from "../../utils"; export async function getTemplates( @@ -10,14 +10,14 @@ export async function getTemplates( limit: number; }, ): Promise<{ - templates: Template[]; + templates: SpaceObject[]; pagination: Pagination; }> { const { url, method } = apiEndpoints.getTemplates(spaceId, typeId, options); - const response = await apiFetch>(url, { method: method }); + const response = await apiFetch>(url, { method: method }); return { - templates: response.payload.data ? await mapTemplates(response.payload.data) : [], + templates: response.payload.data ? await mapObjects(response.payload.data) : [], pagination: response.payload.pagination, }; } diff --git a/extensions/anytype/src/api/types/createType.ts b/extensions/anytype/src/api/types/createType.ts new file mode 100644 index 00000000000..fd3c3dcb76d --- /dev/null +++ b/extensions/anytype/src/api/types/createType.ts @@ -0,0 +1,16 @@ +import { mapType } from "../../mappers/types"; +import { CreateTypeRequest, RawType, Type } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function createType(spaceId: string, typeData: CreateTypeRequest): Promise<{ type: Type | null }> { + const { url, method } = apiEndpoints.createType(spaceId); + + const response = await apiFetch<{ type: RawType }>(url, { + method: method, + body: JSON.stringify(typeData), + }); + + return { + type: response ? await mapType(response.payload.type) : null, + }; +} diff --git a/extensions/anytype/src/api/types/deleteType.ts b/extensions/anytype/src/api/types/deleteType.ts new file mode 100644 index 00000000000..90e9bafda21 --- /dev/null +++ b/extensions/anytype/src/api/types/deleteType.ts @@ -0,0 +1,13 @@ +import { mapType } from "../../mappers/types"; +import { RawType, Type } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function deleteType(spaceId: string, typeId: string): Promise { + const { url, method } = apiEndpoints.deleteType(spaceId, typeId); + + const response = await apiFetch<{ type: RawType }>(url, { + method: method, + }); + + return mapType(response.payload.type); +} diff --git a/extensions/anytype/src/api/types/getType.ts b/extensions/anytype/src/api/types/getType.ts index 655a4779eed..e57abe00447 100644 --- a/extensions/anytype/src/api/types/getType.ts +++ b/extensions/anytype/src/api/types/getType.ts @@ -2,15 +2,14 @@ import { mapType } from "../../mappers/types"; import { RawType, Type } from "../../models"; import { apiEndpoints, apiFetch } from "../../utils"; -export async function getType( - spaceId: string, - typeId: string, -): Promise<{ - type: Type | null; -}> { +export async function getType(spaceId: string, typeId: string): Promise<{ type: Type }> { const { url, method } = apiEndpoints.getType(spaceId, typeId); const response = await apiFetch<{ type: RawType }>(url, { method: method }); - return { - type: response ? await mapType(response.payload.type) : null, - }; + return { type: await mapType(response.payload.type) }; +} + +export async function getRawType(spaceId: string, typeId: string): Promise<{ type: RawType }> { + const { url, method } = apiEndpoints.getType(spaceId, typeId); + const response = await apiFetch<{ type: RawType }>(url, { method: method }); + return { type: response.payload.type }; } diff --git a/extensions/anytype/src/api/types/updateType.ts b/extensions/anytype/src/api/types/updateType.ts new file mode 100644 index 00000000000..e60fbc5ad33 --- /dev/null +++ b/extensions/anytype/src/api/types/updateType.ts @@ -0,0 +1,14 @@ +import { mapType } from "../../mappers/types"; +import { RawType, Type, UpdateTypeRequest } from "../../models"; +import { apiEndpoints, apiFetch } from "../../utils"; + +export async function updateType(spaceId: string, typeId: string, data: UpdateTypeRequest): Promise<{ type: Type }> { + const { url, method } = apiEndpoints.updateType(spaceId, typeId); + + const response = await apiFetch<{ type: RawType }>(url, { + method: method, + body: JSON.stringify(data), + }); + + return { type: await mapType(response.payload.type) }; +} diff --git a/extensions/anytype/src/browse-spaces.tsx b/extensions/anytype/src/browse-spaces.tsx index 21431f5f980..0cb7cd7f099 100644 --- a/extensions/anytype/src/browse-spaces.tsx +++ b/extensions/anytype/src/browse-spaces.tsx @@ -1,142 +1,11 @@ -import { Icon, List, Toast, showToast } from "@raycast/api"; -import { useEffect, useMemo, useState } from "react"; -import { getMembers } from "./api"; -import { EmptyViewSpace, EnsureAuthenticated, SpaceListItem } from "./components"; -import { usePinnedSpaces, useSpaces } from "./hooks"; -import { Space } from "./models"; -import { defaultTintColor, pluralize } from "./utils"; +import { EnsureAuthenticated, SpaceList } from "./components"; const searchPlaceholder = "Search spaces..."; export default function Command() { return ( - + ); } - -function BrowseSpaces() { - const { spaces, spacesError, mutateSpaces, isLoadingSpaces, spacesPagination } = useSpaces(); - const { pinnedSpaces, pinnedSpacesError, isLoadingPinnedSpaces, mutatePinnedSpaces } = usePinnedSpaces(); - const [searchText, setSearchText] = useState(""); - const [membersData, setMembersData] = useState<{ [spaceId: string]: number }>({}); - - useEffect(() => { - if (!spaces) return; - - const fetchMembersData = async () => { - const newData: { [key: string]: number } = {}; - - const spaceIdsToFetch = spaces.map((space) => space.id).filter((id) => !(id in membersData)); - - try { - await Promise.all( - spaceIdsToFetch.map(async (id) => { - const response = await getMembers(id, { offset: 0, limit: 1 }); - newData[id] = response.pagination.total; - }), - ); - setMembersData((prev) => ({ ...prev, ...newData })); - } catch (error) { - showToast( - Toast.Style.Failure, - "Failed to fetch members", - error instanceof Error ? error.message : "An unknown error occurred.", - ); - } - }; - - fetchMembersData(); - }, [spaces]); - - const isLoadingMembers = useMemo(() => { - if (!spaces) return true; - return Object.keys(membersData).length !== spaces.length; - }, [spaces, membersData]); - - useEffect(() => { - if (spacesError) { - showToast(Toast.Style.Failure, "Failed to fetch spaces", spacesError.message); - } - }, [spacesError]); - - useEffect(() => { - if (pinnedSpacesError) { - showToast(Toast.Style.Failure, "Failed to fetch pinned spaces", pinnedSpacesError.message); - } - }, [pinnedSpacesError]); - - const filteredSpaces = spaces?.filter((space) => space.name.toLowerCase().includes(searchText.toLowerCase())); - const pinnedFiltered = pinnedSpaces - ?.map((pin) => filteredSpaces.find((space) => space.id === pin.id)) - .filter(Boolean) as Space[]; - const regularFiltered = filteredSpaces?.filter((space) => !pinnedFiltered?.includes(space)); - - return ( - - {pinnedFiltered.length > 0 && ( - - {pinnedFiltered.map((space) => { - const memberCount = membersData[space.id] || 0; - return ( - - ); - })} - - )} - {regularFiltered.length > 0 ? ( - - {regularFiltered.map((space) => { - const memberCount = membersData[space.id] || 0; - return ( - - ); - })} - - ) : ( - - )} - - ); -} diff --git a/extensions/anytype/src/components/Actions/ListSubmenu.tsx b/extensions/anytype/src/components/Actions/ListSubmenu.tsx new file mode 100644 index 00000000000..daa2dfee78c --- /dev/null +++ b/extensions/anytype/src/components/Actions/ListSubmenu.tsx @@ -0,0 +1,44 @@ +import { Action, ActionPanel, Icon, Toast, showToast } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; +import { useState } from "react"; +import { addObjectsToList } from "../../api"; +import { useSearch } from "../../hooks"; +import { bundledTypeKeys } from "../../utils"; + +interface ListSubmenuProps { + spaceId: string; + objectId: string; +} + +export function ListSubmenu({ spaceId, objectId }: ListSubmenuProps) { + const [load, setLoad] = useState(false); + const { objects: lists, isLoadingObjects } = useSearch(spaceId, "", [bundledTypeKeys.collection], { execute: load }); + const filteredLists = lists.filter((list) => list.id !== objectId); + + async function handleAddToList(listId: string) { + await showToast({ style: Toast.Style.Animated, title: `Adding to list…` }); + try { + await addObjectsToList(spaceId, listId, [objectId]); + await showToast({ style: Toast.Style.Success, title: "Added to list" }); + } catch (error) { + await showFailureToast(error, { title: "Failed to add to list" }); + } + } + + return ( + setLoad(true)} + > + {filteredLists.length === 0 && isLoadingObjects ? ( + + ) : ( + filteredLists.map((list) => ( + handleAddToList(list.id)} /> + )) + )} + + ); +} diff --git a/extensions/anytype/src/components/ObjectActions.tsx b/extensions/anytype/src/components/Actions/ObjectActions.tsx similarity index 65% rename from extensions/anytype/src/components/ObjectActions.tsx rename to extensions/anytype/src/components/Actions/ObjectActions.tsx index 064e930a473..8b5d300b627 100644 --- a/extensions/anytype/src/components/ObjectActions.tsx +++ b/extensions/anytype/src/components/Actions/ObjectActions.tsx @@ -7,41 +7,66 @@ import { getPreferenceValues, Icon, Keyboard, + open, showToast, Toast, + useNavigation, } from "@raycast/api"; -import { MutatePromise } from "@raycast/utils"; -import { CollectionList, ObjectDetail, TemplateList, ViewType } from "."; -import { deleteObject } from "../api"; -import { Export, Member, Space, SpaceObject, Template, Type, View } from "../models"; +import { MutatePromise, showFailureToast } from "@raycast/utils"; +import { + CollectionList, + CreateObjectForm, + CreatePropertyForm, + CreateTypeForm, + ListSubmenu, + ObjectDetail, + TagList, + TemplateList, + UpdateObjectForm, + UpdatePropertyForm, + UpdateTypeForm, + ViewType, +} from ".."; +import { deleteObject, deleteProperty, deleteTag, deleteType, getRawObject, getRawType } from "../../api"; +import { + BodyFormat, + Member, + ObjectLayout, + Property, + Space, + SpaceObject, + SpaceObjectWithBody, + Type, + View, +} from "../../models"; import { addPinned, + bundledPropKeys, localStorageKeys, moveDownInPinned, moveUpInPinned, pluralize, removePinned, - typeIsList, -} from "../utils"; +} from "../../utils"; type ObjectActionsProps = { space: Space; objectId: string; title: string; - objectExport?: Export; - mutate?: MutatePromise[]; - mutateTemplates?: MutatePromise; - mutateObject?: MutatePromise; - mutateExport?: MutatePromise; + mutate?: MutatePromise[]; + mutateTemplates?: MutatePromise; + mutateObject?: MutatePromise; mutateViews?: MutatePromise; - layout: string; - member?: Member | undefined; + layout: ObjectLayout | undefined; + object?: SpaceObject | SpaceObjectWithBody | Type | Property | Member; viewType: ViewType; isGlobalSearch: boolean; isNoPinView: boolean; isPinned: boolean; + isDetailView?: boolean; showDetails?: boolean; onToggleDetails?: () => void; + searchText?: string; }; export function ObjectActions({ @@ -49,29 +74,38 @@ export function ObjectActions({ objectId, title, mutate, - objectExport, mutateTemplates, mutateObject, - mutateExport, mutateViews, layout, + object, viewType, isGlobalSearch, isNoPinView, isPinned, + isDetailView, showDetails, onToggleDetails, + searchText, }: ObjectActionsProps) { + const { pop, push } = useNavigation(); const { primaryAction } = getPreferenceValues(); const objectUrl = `anytype://object?objectId=${objectId}&spaceId=${space?.id}`; const pinSuffixForView = isGlobalSearch ? localStorageKeys.suffixForGlobalSearch : localStorageKeys.suffixForViewsPerSpace(space?.id, viewType); - const isDetailView = objectExport !== undefined; - const isList = typeIsList(layout); + + const isObject = viewType === ViewType.objects; const isType = viewType === ViewType.types; + const isProperty = viewType === ViewType.properties; + const isTag = viewType === ViewType.tags; const isMember = viewType === ViewType.members; + const isList = layout === ObjectLayout.Set || layout === ObjectLayout.Collection; + const isBookmark = layout === ObjectLayout.Bookmark; + const hasTags = + isProperty && ((object as Property).format === "select" || (object as Property).format === "multi_select"); + const getContextLabel = (isSingular = true) => (isDetailView || isSingular ? viewType : pluralize(2, viewType)); async function handleCopyLink() { @@ -91,8 +125,21 @@ export function ObjectActions({ }); if (confirm) { + if (isDetailView) { + pop(); // pop back to list view + } try { - await deleteObject(space.id, objectId); + if (isObject) { + await deleteObject(space.id, objectId); + } else if (isType) { + await deleteType(space.id, objectId); + } else if (isProperty) { + await deleteProperty(space.id, objectId); + } else if (isTag) { + await deleteTag(space.id, "", objectId); // TODO: fix property Id + } else { + await deleteObject(space.id, objectId); + } if (mutate) { for (const m of mutate) { await m(); @@ -104,9 +151,6 @@ export function ObjectActions({ if (mutateObject) { await mutateObject(); } - if (mutateExport) { - await mutateExport(); - } if (mutateViews) { await mutateViews(); } @@ -116,11 +160,7 @@ export function ObjectActions({ message: `"${title}" has been deleted.`, }); } catch (error) { - await showToast({ - style: Toast.Style.Failure, - title: `Failed to delete ${getContextLabel()}`, - message: error instanceof Error ? error.message : "An unknown error occurred.", - }); + await showFailureToast(error, { title: `Failed to delete ${getContextLabel()}` }); } } } @@ -183,20 +223,13 @@ export function ObjectActions({ if (mutateObject) { await mutateObject(); } - if (mutateExport) { - await mutateExport(); - } await showToast({ style: Toast.Style.Success, title: `${label} refreshed`, }); } catch (error) { - await showToast({ - style: Toast.Style.Failure, - title: `Failed to refresh ${label}`, - message: error instanceof Error ? error.message : "An unknown error occurred.", - }); + await showFailureToast(error, { title: `Failed to refresh ${label}` }); } } @@ -215,11 +248,7 @@ export function ObjectActions({ // message: `${name} has been approved as ${formatMemberRole(role)}.`, // }); // } catch (error) { - // await showToast({ - // style: Toast.Style.Failure, - // title: `Failed to approve member`, - // message: error instanceof Error ? error.message : "An unknown error occurred.", - // }); + // await showFailureToast(error, { title: `Failed to approve member` }); // } // } @@ -244,11 +273,7 @@ export function ObjectActions({ // message: `${name} has been rejected.`, // }); // } catch (error) { - // await showToast({ - // style: Toast.Style.Failure, - // title: `Failed to reject member`, - // message: error instanceof Error ? error.message : "An unknown error occurred.", - // }); + // await showFailureToast(error, { title: `Failed to reject member` }); // } // } // } @@ -274,11 +299,7 @@ export function ObjectActions({ // message: `${memberName} has been removed from ${spaceName}.`, // }); // } catch (error) { - // await showToast({ - // style: Toast.Style.Failure, - // title: `Failed to remove member`, - // message: error instanceof Error ? error.message : "An unknown error occurred.", - // }); + // await showFailureToast(error, { title: `Failed to remove member` }); // } // } // } @@ -297,15 +318,11 @@ export function ObjectActions({ // message: `${name} has been changed to ${formatMemberRole(role)}.`, // }); // } catch (error) { - // await showToast({ - // style: Toast.Style.Failure, - // title: `Failed to change member role`, - // message: error instanceof Error ? error.message : "An unknown error occurred.", - // }); + // await showFailureToast(error, { title: `Failed to change member role` }); // } // } - const canShowDetails = !isType && !isList && !isDetailView; + const canShowDetails = !isType && !isProperty && !isList && !isBookmark && !isDetailView; const showDetailsAction = canShowDetails && ( )} + {hasTags && ( + } /> + )} + {isBookmark && ( + { + const url = (object as SpaceObject).properties.find((p) => p.key === bundledPropKeys.source)?.url; + if (url) { + open(url); + } else { + await showToast({ + title: "Missing URL", + message: "Cannot open bookmark without a source URL.", + style: Toast.Style.Failure, + }); + } + }} + /> + )} {secondPrimaryAction} + - {objectExport && ( + {!isType && !isProperty && !isTag && !isMember && ( + { + const { object } = await getRawObject(space.id, objectId, BodyFormat.Markdown); + push( + []} + mutateObject={mutateObject} + />, + ); + }} + /> + )} + {isType && ( + { + const { type } = await getRawType(space.id, objectId); + push([]} />); + }} + /> + )} + {isProperty && ( + []} + /> + } + /> + )} + {isDetailView && ( )} + {!isType && !isProperty && } - {!isMember && ( - - )} {!isDetailView && !isNoPinView && ( <> )} + {!isMember && ( + + )} + {!isMember && ( + { + if (isType) { + push(); + } else if (isProperty) { + push(); + } else { + push(); + } + }} + /> + )} {isDetailView && ( []; isPinned: boolean; + searchText: string; }; -export function SpaceActions({ space, mutate, isPinned }: SpaceActionsProps) { +export function SpaceActions({ space, mutate, isPinned, searchText }: SpaceActionsProps) { const spaceDeeplink = anytypeSpaceDeeplink(space.id); const pinSuffix = localStorageKeys.suffixForSpaces; @@ -64,11 +65,7 @@ export function SpaceActions({ space, mutate, isPinned }: SpaceActionsProps) { await Promise.all(mutate.map((mutateFunc) => mutateFunc())); await showToast({ style: Toast.Style.Success, title: "Spaces refreshed" }); } catch (error) { - await showToast({ - style: Toast.Style.Failure, - title: "Failed to refresh spaces", - message: error instanceof Error ? error.message : "An unknown error occurred.", - }); + await showFailureToast(error, { title: "Failed to refresh spaces" }); } } } @@ -84,6 +81,13 @@ export function SpaceActions({ space, mutate, isPinned }: SpaceActionsProps) { /> + } + /> + + } + /> ([]); + + const selectedTypeDef = types.find((type) => type.id === selectedTypeId); + const selectedTypeKey = selectedTypeDef?.key ?? ""; + const hasselectedSpaceIdAndType = Boolean(selectedSpaceId && selectedTypeKey); + + const properties = selectedTypeDef?.properties.filter((p) => !Object.values(bundledPropKeys).includes(p.key)) || []; + const { tagsMap } = useTagsMap( + selectedSpaceId, + properties + .filter((prop) => prop.format === PropertyFormat.Select || prop.format === PropertyFormat.MultiSelect) + .map((prop) => prop.id), + ); + + const numberFieldValidations = useMemo(() => getNumberFieldValidations(properties), [properties]); + + useEffect(() => { + const fetchTypesForLists = async () => { + if (spaces) { + const listsTypes = await fetchTypeKeysForLists(spaces); + setTypeKeysForLists(listsTypes); + } + }; + fetchTypesForLists(); + }, [spaces]); + + const { handleSubmit, itemProps } = useForm({ + initialValues: draftValues, + onSubmit: async (values) => { + setLoading(true); + try { + await showToast({ style: Toast.Style.Animated, title: "Creating object..." }); + const propertiesEntries: PropertyLinkWithValue[] = []; + properties.forEach((prop) => { + const raw = itemProps[prop.key]?.value; + if (raw !== undefined && raw !== null && raw !== "" && raw !== false) { + const entry: PropertyLinkWithValue = { key: prop.key, format: prop.format }; + switch (prop.format) { + case PropertyFormat.Text: + entry.text = String(raw); + break; + case PropertyFormat.Select: + entry.select = String(raw); + break; + case PropertyFormat.Url: + entry.url = String(raw); + break; + case PropertyFormat.Email: + entry.email = String(raw); + break; + case PropertyFormat.Phone: + entry.phone = String(raw); + break; + case PropertyFormat.Number: + entry.number = Number(raw); + break; + case PropertyFormat.MultiSelect: + entry.multi_select = raw as string[]; + break; + case PropertyFormat.Date: + { + const date = raw instanceof Date ? raw : new Date(String(raw)); + if (!isNaN(date.getTime())) { + entry.date = formatRFC3339(date); + } else { + console.warn(`Invalid date value for property ${prop.key}:`, raw); + } + } + break; + case PropertyFormat.Checkbox: + entry.checkbox = Boolean(raw); + break; + case PropertyFormat.Files: + entry.files = Array.isArray(raw) ? (raw as string[]) : [String(raw)]; + break; + case PropertyFormat.Objects: + entry.objects = Array.isArray(raw) ? (raw as string[]) : [String(raw)]; + break; + default: + console.warn(`Unsupported property format: ${prop.format}`); + break; + } + propertiesEntries.push(entry); + } + }); + + const descriptionValue = itemProps[bundledPropKeys.description]?.value; + if (descriptionValue !== undefined && descriptionValue !== null && descriptionValue !== "") { + propertiesEntries.push({ + key: bundledPropKeys.description, + format: PropertyFormat.Text, + text: String(descriptionValue), + }); + } + + const sourceValue = itemProps[bundledPropKeys.source]?.value; + if (sourceValue !== undefined && sourceValue !== null && sourceValue !== "") { + propertiesEntries.push({ + key: bundledPropKeys.source, + format: PropertyFormat.Url, + url: String(sourceValue), + }); + } + + const objectData: CreateObjectRequest = { + name: values.name || "", + icon: { format: IconFormat.Emoji, emoji: values.icon || "" }, + body: values.body || "", + template_id: values.templateId || "", + type_key: selectedTypeKey, + properties: propertiesEntries, + }; + + const response = await createObject(selectedSpaceId, objectData); + + if (response.object?.id) { + if (selectedListId) { + await addObjectsToList(selectedSpaceId, selectedListId, [response.object.id]); + await showToast(Toast.Style.Success, "Object created and added to collection"); + } else { + await showToast(Toast.Style.Success, "Object created successfully"); + } + popToRoot(); + } else { + await showToast(Toast.Style.Failure, "Failed to create object"); + } + } catch (error) { + await showFailureToast(error, { title: "Failed to create object" }); + } finally { + setLoading(false); + } + }, + validation: { + name: (v: PropertyFieldValue) => { + const s = typeof v === "string" ? v.trim() : undefined; + if (![bundledTypeKeys.bookmark, bundledTypeKeys.note].includes(selectedTypeKey) && !s) { + return "Name is required"; + } + }, + icon: (v: PropertyFieldValue) => { + if (typeof v === "string" && v && !isEmoji(v)) { + return "Icon must be single emoji"; + } + }, + source: (v: PropertyFieldValue) => { + const s = typeof v === "string" ? v.trim() : undefined; + if (selectedTypeId === bundledTypeKeys.bookmark && !s) { + return "Source is required for Bookmarks"; + } + }, + ...numberFieldValidations, + }, + }); + + function getQuicklink(): { name: string; link: string } { + const url = "raycast://extensions/any/anytype/create-object"; + + const defaults: Record = { + space: selectedSpaceId, + type: selectedTypeId, + list: selectedListId, + name: itemProps.name.value, + icon: itemProps.icon.value, + description: itemProps.description.value, + body: itemProps.body.value, + source: itemProps.source.value, + }; + + properties.forEach((prop) => { + const raw = itemProps[prop.key]?.value; + if (raw !== undefined && raw !== null && raw !== "" && raw !== false) { + defaults[prop.key] = raw; + } + }); + + const launchContext = { defaults }; + + return { + name: `Create ${types.find((type) => type.id === selectedTypeId)?.name} in ${spaces.find((space) => space.id === selectedSpaceId)?.name}`, + link: url + "?launchContext=" + encodeURIComponent(JSON.stringify(launchContext)), + }; + } + + const textFieldPlaceholderMap: Partial> = { + [PropertyFormat.Text]: "Add text", + [PropertyFormat.Url]: "Add URL", + [PropertyFormat.Email]: "Add email address", + [PropertyFormat.Phone]: "Add phone number", + }; + + return ( +
+ + {hasselectedSpaceIdAndType && ( + type.id === selectedTypeId)?.name}`} + quicklink={getQuicklink()} + /> + )} + + } + > + { + setSelectedSpaceId(v); + setSelectedTypeId(""); + setSelectedTemplateId(""); + setSelectedListId(""); + setListSearchText(""); + setObjectSearchText(""); + }} + storeValue={true} + placeholder="Search spaces..." + info="Select the space where the object will be created" + > + {spaces?.map((space) => ( + + ))} + + + space.id === selectedSpaceId)?.name}'...`} + info="Select the type of object to create" + > + {types.map((type) => ( + + ))} + + + type.id === selectedTypeId)?.name}'...`} + info="Select the template to use for the object" + > + + {templates.map((template) => ( + + ))} + + + space.id === selectedSpaceId)?.name}'...`} + info="Select the collection where the object will be added" + > + {!listSearchText && ( + + )} + {lists.map((list) => ( + + ))} + + + + + {hasselectedSpaceIdAndType && ( + <> + {![bundledTypeKeys.bookmark, bundledTypeKeys.note].includes(selectedTypeKey) && ( + + )} + {![bundledTypeKeys.bookmark, bundledTypeKeys.task, bundledTypeKeys.note, bundledTypeKeys.profile].includes( + selectedTypeKey, + ) && ( + + )} + {!bundledTypeKeys.bookmark.includes(selectedTypeKey) ? ( + <> + + {!typeKeysForLists.includes(selectedTypeKey) && ( + + )} + + ) : ( + + )} + + + {properties.map((prop) => { + const tags = (tagsMap && tagsMap[prop.id]) ?? []; + const id = prop.key; + const title = prop.name; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { value, defaultValue, ...restItemProps } = itemProps[id]; + + switch (prop.format) { + case PropertyFormat.Text: + case PropertyFormat.Url: + case PropertyFormat.Email: + case PropertyFormat.Phone: + return ( + + ); + case PropertyFormat.Number: + return ( + + ); + case PropertyFormat.Select: + return ( + + + {tags.map((tag) => ( + + ))} + + ); + case PropertyFormat.MultiSelect: + return ( + + {tags.map((tag) => ( + + ))} + + ); + case PropertyFormat.Date: + return ( + + ); + case PropertyFormat.Files: + // TODO: implement + return null; + case PropertyFormat.Checkbox: + return ( + + ); + case PropertyFormat.Objects: + return ( + // TODO: TagPicker would be the more appropriate component, but it does not support onSearchTextChange + space.id === selectedSpaceId)?.name}'...`} + > + {!objectSearchText && ( + + )} + {objects.map((object) => ( + + ))} + + ); + default: + console.warn(`Unsupported property format: ${prop.format}`); + return null; + } + })} + + )} + + ); +} diff --git a/extensions/anytype/src/components/CreateForm/CreatePropertyForm.tsx b/extensions/anytype/src/components/CreateForm/CreatePropertyForm.tsx new file mode 100644 index 00000000000..60e6a6349ea --- /dev/null +++ b/extensions/anytype/src/components/CreateForm/CreatePropertyForm.tsx @@ -0,0 +1,66 @@ +import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { createProperty } from "../../api"; +import { PropertyFormat } from "../../models"; + +export interface CreatePropertyFormValues { + name: string; + format?: string; +} + +interface CreatePropertyFormProps { + spaceId: string; + draftValues: CreatePropertyFormValues; +} + +export function CreatePropertyForm({ spaceId, draftValues }: CreatePropertyFormProps) { + const { handleSubmit, itemProps } = useForm({ + initialValues: { ...draftValues, format: draftValues.format as PropertyFormat }, + onSubmit: async (values) => { + try { + await showToast({ style: Toast.Style.Animated, title: "Creating property..." }); + + await createProperty(spaceId, { + name: values.name || "", + format: values.format as PropertyFormat, + }); + + showToast(Toast.Style.Success, "Property created successfully"); + popToRoot(); + } catch (error) { + await showFailureToast(error, { title: "Failed to create property" }); + } + }, + validation: { + name: (v) => (!v ? "Name is required" : undefined), + }, + }); + + const propertyFormatKeys = Object.keys(PropertyFormat) as Array; + + return ( +
+ + + } + > + + + {propertyFormatKeys.map((key) => { + const value = PropertyFormat[key]; + return ( + + ); + })} + + + ); +} diff --git a/extensions/anytype/src/components/CreateSpaceForm.tsx b/extensions/anytype/src/components/CreateForm/CreateSpaceForm.tsx similarity index 82% rename from extensions/anytype/src/components/CreateSpaceForm.tsx rename to extensions/anytype/src/components/CreateForm/CreateSpaceForm.tsx index cfad4636541..f196484f297 100644 --- a/extensions/anytype/src/components/CreateSpaceForm.tsx +++ b/extensions/anytype/src/components/CreateForm/CreateSpaceForm.tsx @@ -1,6 +1,6 @@ import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; -import { useForm } from "@raycast/utils"; -import { createSpace } from "../api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { createSpace } from "../../api"; export interface CreateSpaceFormValues { name?: string; @@ -25,11 +25,7 @@ export function CreateSpaceForm({ draftValues }: CreateSpaceFormProps) { showToast(Toast.Style.Success, "Space created successfully"); popToRoot(); } catch (error) { - if (error instanceof Error) { - showToast(Toast.Style.Failure, "Failed to create space", error.message); - } else { - showToast(Toast.Style.Failure, "Failed to create space", "Unknown error"); - } + await showFailureToast(error, { title: "Failed to create space" }); } }, validation: { diff --git a/extensions/anytype/src/components/CreateForm/CreateTagForm.tsx b/extensions/anytype/src/components/CreateForm/CreateTagForm.tsx new file mode 100644 index 00000000000..7f9d72064bc --- /dev/null +++ b/extensions/anytype/src/components/CreateForm/CreateTagForm.tsx @@ -0,0 +1,77 @@ +import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { createTag } from "../../api"; +import { Color } from "../../models"; +import { colorToHex } from "../../utils"; + +export interface CreateTagFormValues { + name: string; + color?: string; +} + +interface CreateTagFormProps { + spaceId: string; + propertyId: string; + draftValues: CreateTagFormValues; +} + +export function CreateTagForm({ spaceId, propertyId, draftValues }: CreateTagFormProps) { + const { handleSubmit, itemProps } = useForm({ + initialValues: { ...draftValues, name: draftValues.name, color: draftValues.color as Color }, + onSubmit: async (values) => { + try { + await showToast({ style: Toast.Style.Animated, title: "Creating tag..." }); + + await createTag(spaceId, propertyId, { + name: values.name || "", + color: values.color as Color, + }); + + showToast(Toast.Style.Success, "Tag created successfully"); + popToRoot(); + } catch (error) { + await showFailureToast(error, { title: "Failed to create tag" }); + } + }, + validation: { + name: (value) => { + if (!value) { + return "Name is required"; + } + }, + color: (value) => { + if (!value) { + return "Color is required"; + } + }, + }, + }); + + const tagColorKeys = Object.keys(Color) as Array; + + return ( +
+ + + } + > + + + {tagColorKeys.map((key) => { + const value = Color[key]; + return ( + + ); + })} + + + ); +} diff --git a/extensions/anytype/src/components/CreateForm/CreateTypeForm.tsx b/extensions/anytype/src/components/CreateForm/CreateTypeForm.tsx new file mode 100644 index 00000000000..deea61f4411 --- /dev/null +++ b/extensions/anytype/src/components/CreateForm/CreateTypeForm.tsx @@ -0,0 +1,102 @@ +import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { useState } from "react"; +import { createType } from "../../api"; +import { useProperties, useSpaces } from "../../hooks"; +import { CreateTypeRequest, IconFormat, PropertyLink, TypeLayout } from "../../models"; +import { isEmoji } from "../../utils"; + +export interface CreateTypeFormValues { + space: string; + name: string; + plural_name?: string; + icon?: string; + layout?: TypeLayout; + properties?: string[]; +} + +export interface CreateTypeFormProps { + draftValues: CreateTypeFormValues; +} + +export function CreateTypeForm({ draftValues }: CreateTypeFormProps) { + const [loading, setLoading] = useState(false); + const [selectedSpace, setSelectedSpace] = useState(draftValues.space); + + const { spaces } = useSpaces(); + const { properties } = useProperties(selectedSpace); + + const { handleSubmit, itemProps } = useForm({ + initialValues: draftValues, + onSubmit: async (values) => { + setLoading(true); + try { + await showToast({ style: Toast.Style.Animated, title: "Creating type..." }); + + const propertyLinks: PropertyLink[] = + values.properties?.map((key) => { + const prop = properties.find((p) => p.key === key)!; + return { key: prop.key, format: prop.format, name: prop.name }; + }) || []; + + const request: CreateTypeRequest = { + name: values.name || "", + plural_name: values.plural_name || "", + icon: { format: IconFormat.Emoji, emoji: values.icon || "" }, + Layout: values.layout || TypeLayout.Basic, + Properties: propertyLinks, + }; + const response = await createType(selectedSpace, request); + if (response.type?.key) { + await showToast(Toast.Style.Success, "Type created successfully"); + popToRoot(); + } else { + await showToast(Toast.Style.Failure, "Failed to create type"); + } + } catch (error) { + await showFailureToast(error, { title: "Failed to create type" }); + } finally { + setLoading(false); + } + }, + validation: { + name: (v) => (!v ? "Name is required" : undefined), + plural_name: (v) => (!v ? "Plural name is required" : undefined), + icon: (v) => (typeof v === "string" && v && !isEmoji(v) ? "Icon must be a single emoji" : undefined), + }, + }); + + const layoutKeys = Object.keys(TypeLayout) as Array; + + return ( +
+ + + } + > + + {spaces.map((space) => ( + + ))} + + + + + + {layoutKeys.map((layout) => { + const value = TypeLayout[layout]; + return ; + })} + + + {properties.map((prop) => ( + + ))} + + + ); +} diff --git a/extensions/anytype/src/components/CreateObjectForm.tsx b/extensions/anytype/src/components/CreateObjectForm.tsx deleted file mode 100644 index cf44dcb1c28..00000000000 --- a/extensions/anytype/src/components/CreateObjectForm.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; -import { useForm } from "@raycast/utils"; -import { useEffect, useState } from "react"; -import { addObjectsToList, createObject } from "../api"; -import { CreateObjectFormValues } from "../create-object"; -import { IconFormat, Space, SpaceObject, Template, Type } from "../models"; -import { fetchTypeKeysForLists } from "../utils"; - -interface CreateObjectFormProps { - spaces: Space[]; - types: Type[]; - templates: Template[]; - lists: SpaceObject[]; - selectedSpace: string; - setSelectedSpace: (spaceId: string) => void; - selectedType: string; - setSelectedType: (type: string) => void; - selectedTemplate: string; - setSelectedTemplate: (templateId: string) => void; - selectedList: string; - setSelectedList: (listId: string) => void; - listSearchText: string; - setListSearchText: (searchText: string) => void; - isLoading: boolean; - draftValues: CreateObjectFormValues; - enableDrafts: boolean; -} - -export function CreateObjectForm({ - spaces, - types, - templates, - lists, - selectedSpace, - setSelectedSpace, - selectedType, - setSelectedType, - selectedTemplate, - setSelectedTemplate, - selectedList, - setSelectedList, - listSearchText, - setListSearchText, - isLoading, - draftValues, - enableDrafts, -}: CreateObjectFormProps) { - const [loading, setLoading] = useState(false); - const [typeKeysForLists, setTypeKeysForLists] = useState([]); - const hasSelectedSpaceAndType = selectedSpace && selectedType; - const selectedTypeUniqueKey = types.reduce((acc, type) => (type.id === selectedType ? type.key : acc), ""); - - useEffect(() => { - const fetchTypesForLists = async () => { - if (spaces) { - const listsTypes = await fetchTypeKeysForLists(spaces); - setTypeKeysForLists(listsTypes); - } - }; - fetchTypesForLists(); - }, [spaces]); - - const { handleSubmit, itemProps } = useForm({ - initialValues: draftValues, - onSubmit: async (values) => { - setLoading(true); - try { - await showToast({ style: Toast.Style.Animated, title: "Creating object..." }); - - const response = await createObject(selectedSpace, { - name: values.name || "", - icon: { format: IconFormat.Emoji, emoji: values.icon || "" }, - description: values.description || "", - body: values.body || "", - source: values.source || "", - template_id: values.template || "", - type_key: selectedTypeUniqueKey, - }); - - if (response.object?.id) { - if (selectedList) { - await addObjectsToList(selectedSpace, selectedList, [response.object.id]); - await showToast(Toast.Style.Success, "Object created and added to collection"); - } else { - await showToast(Toast.Style.Success, "Object created successfully"); - } - popToRoot(); - } else { - await showToast(Toast.Style.Failure, "Failed to create object"); - } - } catch (error) { - await showToast(Toast.Style.Failure, "Failed to create object", String(error)); - } finally { - setLoading(false); - } - }, - validation: { - name: (value) => { - if (!["ot-bookmark", "ot-note"].includes(selectedTypeUniqueKey) && !value) { - return "Name is required"; - } - }, - icon: (value) => { - if (value && value.length > 2) { - return "Icon must be a single character"; - } - }, - source: (value) => { - if (selectedTypeUniqueKey === "ot-bookmark" && !value) { - return "Source is required for Bookmarks"; - } - }, - }, - }); - - function getQuicklink(): { name: string; link: string } { - const url = "raycast://extensions/any/anytype/create-object"; - - const launchContext = { - defaults: { - space: selectedSpace, - type: selectedType, - list: selectedList, - name: itemProps.name.value, - icon: itemProps.icon.value, - description: itemProps.description.value, - body: itemProps.body.value, - source: itemProps.source.value, - }, - }; - - return { - name: `Create ${types.find((type) => type.key === selectedTypeUniqueKey)?.name} in ${spaces.find((space) => space.id === selectedSpace)?.name}`, - link: url + "?launchContext=" + encodeURIComponent(JSON.stringify(launchContext)), - }; - } - - return ( -
- - {hasSelectedSpaceAndType && ( - type.key === selectedTypeUniqueKey)?.name}`} - quicklink={getQuicklink()} - /> - )} - - } - > - - {spaces?.map((space) => ( - - ))} - - - space.id === selectedSpace)?.name}'...`} - info="Select the type of object to create" - > - {types.map((type) => ( - - ))} - - - type.id === selectedType)?.name}'...`} - info="Select the template to use for the object" - > - - {templates.map((template) => ( - - ))} - - - space.id === selectedSpace)?.name}'...`} - info="Select the collection where the object will be added" - > - {!listSearchText && } - {lists.map((list) => ( - - ))} - - - - - {hasSelectedSpaceAndType && ( - <> - {selectedTypeUniqueKey === "ot-bookmark" ? ( - - ) : ( - <> - {!["ot-note"].includes(selectedTypeUniqueKey) && ( - - )} - {!["ot-task", "ot-note", "ot-profile"].includes(selectedTypeUniqueKey) && ( - - )} - - {!typeKeysForLists.includes(selectedTypeUniqueKey) && ( - - )} - - )} - - )} - - ); -} diff --git a/extensions/anytype/src/components/EmptyView/EmptyViewObject.tsx b/extensions/anytype/src/components/EmptyView/EmptyViewObject.tsx new file mode 100644 index 00000000000..b39fc7b57ec --- /dev/null +++ b/extensions/anytype/src/components/EmptyView/EmptyViewObject.tsx @@ -0,0 +1,37 @@ +import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { CreateObjectForm } from ".."; +import { CreateObjectFormValues } from "../../create-object"; + +type EmptyViewObjectProps = { + title: string; + contextValues: CreateObjectFormValues; +}; + +export function EmptyViewObject({ title, contextValues }: EmptyViewObjectProps) { + const draftValues: CreateObjectFormValues = { + spaceId: contextValues.spaceId, + typeId: contextValues.typeId, + listId: contextValues.listId, + name: contextValues.name, + icon: contextValues.icon, + description: contextValues.description, + body: contextValues.body, + source: contextValues.source, + }; + + return ( + + } + icon={Icon.Plus} + /> + + } + /> + ); +} diff --git a/extensions/anytype/src/components/EmptyView/EmptyViewProperty.tsx b/extensions/anytype/src/components/EmptyView/EmptyViewProperty.tsx new file mode 100644 index 00000000000..f560ad7bd53 --- /dev/null +++ b/extensions/anytype/src/components/EmptyView/EmptyViewProperty.tsx @@ -0,0 +1,26 @@ +import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { CreatePropertyForm, CreatePropertyFormValues } from ".."; + +type EmptyViewPropertyProps = { + title: string; + spaceId: string; + contextValues: CreatePropertyFormValues; +}; + +export function EmptyViewProperty({ title, spaceId, contextValues }: EmptyViewPropertyProps) { + return ( + + } + icon={Icon.Plus} + /> + + } + /> + ); +} diff --git a/extensions/anytype/src/components/EmptyViewSpace.tsx b/extensions/anytype/src/components/EmptyView/EmptyViewSpace.tsx similarity index 89% rename from extensions/anytype/src/components/EmptyViewSpace.tsx rename to extensions/anytype/src/components/EmptyView/EmptyViewSpace.tsx index 9f0c75fa70b..ffa202f4836 100644 --- a/extensions/anytype/src/components/EmptyViewSpace.tsx +++ b/extensions/anytype/src/components/EmptyView/EmptyViewSpace.tsx @@ -1,5 +1,5 @@ import { Action, ActionPanel, Icon, List } from "@raycast/api"; -import { CreateSpaceForm, CreateSpaceFormValues } from "."; +import { CreateSpaceForm, CreateSpaceFormValues } from ".."; type EmptyViewSpaceProps = { title: string; diff --git a/extensions/anytype/src/components/EmptyView/EmptyViewTag.tsx b/extensions/anytype/src/components/EmptyView/EmptyViewTag.tsx new file mode 100644 index 00000000000..f55f2237ee7 --- /dev/null +++ b/extensions/anytype/src/components/EmptyView/EmptyViewTag.tsx @@ -0,0 +1,27 @@ +import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { CreateTagForm, CreateTagFormValues } from ".."; + +type EmptyViewTagProps = { + title: string; + spaceId: string; + propertyId: string; + contextValues: CreateTagFormValues; +}; + +export function EmptyViewTag({ title, spaceId, propertyId, contextValues }: EmptyViewTagProps) { + return ( + + } + icon={Icon.Plus} + /> + + } + /> + ); +} diff --git a/extensions/anytype/src/components/EmptyView/EmptyViewType.tsx b/extensions/anytype/src/components/EmptyView/EmptyViewType.tsx new file mode 100644 index 00000000000..ef77d0b28ec --- /dev/null +++ b/extensions/anytype/src/components/EmptyView/EmptyViewType.tsx @@ -0,0 +1,21 @@ +import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { CreateTypeForm, CreateTypeFormValues } from ".."; + +type EmptyViewTypeProps = { + title: string; + contextValues: CreateTypeFormValues; +}; + +export function EmptyViewType({ title, contextValues }: EmptyViewTypeProps) { + return ( + + } icon={Icon.Plus} /> + + } + /> + ); +} diff --git a/extensions/anytype/src/components/EmptyViewObject.tsx b/extensions/anytype/src/components/EmptyViewObject.tsx deleted file mode 100644 index bb9ab1931a0..00000000000 --- a/extensions/anytype/src/components/EmptyViewObject.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Action, ActionPanel, Icon, List } from "@raycast/api"; -import { useEffect } from "react"; -import { CreateObjectForm } from "."; -import { CreateObjectFormValues } from "../create-object"; -import { useCreateObjectData } from "../hooks"; - -type EmptyViewObjectProps = { - title: string; - contextValues: CreateObjectFormValues; -}; - -export function EmptyViewObject({ title, contextValues }: EmptyViewObjectProps) { - const draftValues: CreateObjectFormValues = { - space: contextValues.space, - type: contextValues.type, - list: contextValues.list, - name: contextValues.name, - icon: contextValues.icon, - description: contextValues.description, - body: contextValues.body, - source: contextValues.source, - }; - - const { - spaces, - types, - templates, - lists, - selectedSpace, - setSelectedSpace, - selectedType, - setSelectedType, - selectedTemplate, - setSelectedTemplate, - selectedList, - setSelectedList, - listSearchText, - setListSearchText, - isLoading, - } = useCreateObjectData(draftValues); - - useEffect(() => { - if (spaces.length > 0 && !selectedSpace) { - setSelectedSpace(spaces[0].id); - } - }, [spaces]); - - useEffect(() => { - if (types.length > 0 && !selectedType) { - setSelectedType(types[0].id); - } - }, [types]); - - return ( - - - } - icon={Icon.Plus} - /> - - } - /> - ); -} diff --git a/extensions/anytype/src/components/EnsureAuthenticated.tsx b/extensions/anytype/src/components/EnsureAuthenticated.tsx index 9d5de80d077..f0d51a8351d 100644 --- a/extensions/anytype/src/components/EnsureAuthenticated.tsx +++ b/extensions/anytype/src/components/EnsureAuthenticated.tsx @@ -11,7 +11,7 @@ import { showToast, Toast, } from "@raycast/api"; -import { useForm } from "@raycast/utils"; +import { showFailureToast, useForm } from "@raycast/utils"; import { useEffect, useState } from "react"; import { checkApiTokenValidity, displayCode, getToken } from "../api"; import { apiAppName, downloadUrl, localStorageKeys } from "../utils"; @@ -31,8 +31,7 @@ export function EnsureAuthenticated({ placeholder, viewType, children }: EnsureA const { handleSubmit, itemProps } = useForm<{ userCode: string }>({ onSubmit: async (values) => { if (!challengeId) { - showToast({ - style: Toast.Style.Failure, + await showFailureToast({ title: "Pairing not started", message: "Start the pairing before submitting the code.", }); @@ -47,11 +46,7 @@ export function EnsureAuthenticated({ placeholder, viewType, children }: EnsureA setHasToken(true); setTokenIsValid(true); } catch (error) { - showToast({ - style: Toast.Style.Failure, - title: "Failed to pair", - message: String(error), - }); + await showFailureToast(error, { title: "Failed to pair" }); } finally { setIsLoading(false); } diff --git a/extensions/anytype/src/components/CollectionList.tsx b/extensions/anytype/src/components/Lists/CollectionList.tsx similarity index 75% rename from extensions/anytype/src/components/CollectionList.tsx rename to extensions/anytype/src/components/Lists/CollectionList.tsx index 17ee3d6ca53..0c0b5766f39 100644 --- a/extensions/anytype/src/components/CollectionList.tsx +++ b/extensions/anytype/src/components/Lists/CollectionList.tsx @@ -1,11 +1,12 @@ -import { Icon, List, showToast, Toast } from "@raycast/api"; +import { Icon, List } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; import { useEffect, useState } from "react"; -import { EmptyViewObject, ObjectListItem, ViewType } from "."; -import { useObjectsInList } from "../hooks"; -import { useListViews } from "../hooks/useListViews"; -import { Space, ViewLayout } from "../models"; -import { pluralize, processObject } from "../utils"; -import { defaultTintColor } from "../utils/constant"; +import { EmptyViewObject, ObjectListItem, ViewType } from ".."; +import { useObjectsInList } from "../../hooks"; +import { useListViews } from "../../hooks/useListViews"; +import { Space, ViewLayout } from "../../models"; +import { isEmoji, pluralize, processObject } from "../../utils"; +import { defaultTintColor } from "../../utils/constant"; type CollectionListProps = { space: Space; @@ -25,7 +26,7 @@ export function CollectionList({ space, listId, listName }: CollectionListProps) useEffect(() => { if (viewsError || objectsError) { - showToast(Toast.Style.Failure, "Failed to fetch objects", viewsError?.message || objectsError?.message); + showFailureToast(viewsError || objectsError, { title: "Failed to fetch latest data" }); } }, [viewsError, objectsError]); @@ -35,8 +36,8 @@ export function CollectionList({ space, listId, listName }: CollectionListProps) return processObject(object, false, mutateObjects); }); - const resolveLayoutIcon = (layout: string) => { - switch (layout.toLowerCase()) { + const resolveLayoutIcon = (layout: ViewLayout) => { + switch (layout) { case ViewLayout.Grid: return { source: "icons/dataview/grid.svg" }; case ViewLayout.List: @@ -65,7 +66,12 @@ export function CollectionList({ space, listId, listName }: CollectionListProps) searchBarAccessory={ setViewId(newValue)}> {views.map((view) => ( - + ))} } @@ -85,12 +91,14 @@ export function CollectionList({ space, listId, listName }: CollectionListProps) subtitle={object.subtitle} accessories={object.accessories} mutate={object.mutate} + object={object.object} mutateViews={mutateViews} layout={object.layout} viewType={ViewType.objects} isGlobalSearch={false} isNoPinView={true} isPinned={object.isPinned} + searchText={searchText} /> ))} @@ -98,8 +106,8 @@ export function CollectionList({ space, listId, listName }: CollectionListProps) diff --git a/extensions/anytype/src/components/Lists/SpaceList.tsx b/extensions/anytype/src/components/Lists/SpaceList.tsx new file mode 100644 index 00000000000..7dfd7e114aa --- /dev/null +++ b/extensions/anytype/src/components/Lists/SpaceList.tsx @@ -0,0 +1,135 @@ +import { Icon, List } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; +import { useEffect, useMemo, useState } from "react"; +import { getMembers } from "../../api"; +import { EmptyViewSpace, SpaceListItem } from "../../components"; +import { usePinnedSpaces, useSpaces } from "../../hooks"; +import { Space } from "../../models"; +import { defaultTintColor, pluralize } from "../../utils"; + +type SpacesListProps = { + searchPlaceholder: string; +}; + +export function SpaceList({ searchPlaceholder }: SpacesListProps) { + const { spaces, spacesError, mutateSpaces, isLoadingSpaces, spacesPagination } = useSpaces(); + const { pinnedSpaces, pinnedSpacesError, isLoadingPinnedSpaces, mutatePinnedSpaces } = usePinnedSpaces(); + const [searchText, setSearchText] = useState(""); + const [membersData, setMembersData] = useState<{ [spaceId: string]: number }>({}); + + useEffect(() => { + if (!spaces) return; + + const fetchMembersData = async () => { + const newData: { [key: string]: number } = {}; + const spaceIdsToFetch = spaces.map((space) => space.id).filter((id) => !(id in membersData)); + + try { + await Promise.all( + spaceIdsToFetch.map(async (id) => { + const response = await getMembers(id, { offset: 0, limit: 1 }); + newData[id] = response.pagination.total; + }), + ); + setMembersData((prev) => ({ ...prev, ...newData })); + } catch (error) { + await showFailureToast(error, { title: "Failed to fetch members" }); + } + }; + + fetchMembersData(); + }, [spaces]); + + const isLoadingMembers = useMemo(() => { + if (!spaces) return true; + return Object.keys(membersData).length !== spaces.length; + }, [spaces, membersData]); + + useEffect(() => { + if (spacesError) { + showFailureToast(spacesError, { title: "Failed to fetch spaces" }); + } + }, [spacesError]); + + useEffect(() => { + if (pinnedSpacesError) { + showFailureToast(pinnedSpacesError, { title: "Failed to fetch pinned spaces" }); + } + }, [pinnedSpacesError]); + + const filteredSpaces = spaces?.filter((space) => space.name.toLowerCase().includes(searchText.toLowerCase())); + const pinnedFiltered = pinnedSpaces + ?.map((pin) => filteredSpaces.find((space) => space.id === pin.id)) + .filter(Boolean) as Space[]; + const regularFiltered = filteredSpaces?.filter((space) => !pinnedFiltered?.includes(space)); + + return ( + + {pinnedFiltered.length > 0 && ( + + {pinnedFiltered.map((space) => { + const memberCount = membersData[space.id] || 0; + return ( + + ); + })} + + )} + {regularFiltered.length > 0 ? ( + + {regularFiltered.map((space) => { + const memberCount = membersData[space.id] || 0; + return ( + + ); + })} + + ) : ( + + )} + + ); +} diff --git a/extensions/anytype/src/components/Lists/TagList.tsx b/extensions/anytype/src/components/Lists/TagList.tsx new file mode 100644 index 00000000000..d9bfb140004 --- /dev/null +++ b/extensions/anytype/src/components/Lists/TagList.tsx @@ -0,0 +1,101 @@ +import { Action, ActionPanel, Icon, Keyboard, List, showToast, Toast } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; +import { useEffect, useState } from "react"; +import { useTags } from "../../hooks/useTags"; +import { Space } from "../../models"; +import { hexToColor } from "../../utils"; +import { CreateTagForm } from "../CreateForm/CreateTagForm"; +import { EmptyViewTag } from "../EmptyView/EmptyViewTag"; +import { UpdateTagForm } from "../UpdateForm/UpdateTagForm"; + +interface TagListProps { + space: Space; + propertyId: string; +} + +export function TagList({ space, propertyId }: TagListProps) { + const [searchText, setSearchText] = useState(""); + const { tags, isLoadingTags, tagsError, mutateTags, tagsPagination } = useTags(space.id, propertyId); + + useEffect(() => { + if (tagsError) { + showFailureToast(tagsError, { title: "Failed to fetch tags" }); + } + }, [tagsError]); + + const handleRefresh = async () => { + await showToast({ + style: Toast.Style.Animated, + title: "Refreshing tags...", + }); + try { + await mutateTags(); + await showToast({ + style: Toast.Style.Success, + title: "Tags refreshed", + }); + } catch (error) { + await showFailureToast(error, { title: "Failed to refresh tags" }); + } + }; + + const filteredTags = tags?.filter((tag) => tag.name.toLowerCase().includes(searchText.toLowerCase())); + + return ( + + {filteredTags && filteredTags.length > 0 ? ( + filteredTags.map((tag) => ( + + + } + /> + + + } + /> + + + + } + /> + )) + ) : ( + + )} + + ); +} diff --git a/extensions/anytype/src/components/TemplateList.tsx b/extensions/anytype/src/components/Lists/TemplateList.tsx similarity index 81% rename from extensions/anytype/src/components/TemplateList.tsx rename to extensions/anytype/src/components/Lists/TemplateList.tsx index b62872ce11f..4ca4d051c06 100644 --- a/extensions/anytype/src/components/TemplateList.tsx +++ b/extensions/anytype/src/components/Lists/TemplateList.tsx @@ -1,9 +1,10 @@ -import { List, showToast, Toast } from "@raycast/api"; +import { List } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; import { useEffect, useState } from "react"; -import { EmptyViewObject, ObjectActions, ObjectListItem, ViewType } from "."; -import { useSearch, useTemplates } from "../hooks"; -import { Space, Template } from "../models"; -import { pluralize, processObject } from "../utils"; +import { EmptyViewObject, ObjectActions, ObjectListItem, ViewType } from ".."; +import { useSearch, useTemplates } from "../../hooks"; +import { Space, SpaceObject } from "../../models"; +import { pluralize, processObject } from "../../utils"; type TemplatesListProps = { space: Space; @@ -26,17 +27,17 @@ export function TemplateList({ space, typeId, isGlobalSearch, isPinned }: Templa useEffect(() => { if (templatesError) { - showToast(Toast.Style.Failure, "Failed to fetch templates", templatesError.message); + showFailureToast(templatesError, { title: "Failed to fetch templates" }); } }, [templatesError]); useEffect(() => { if (objectsError) { - showToast(Toast.Style.Failure, "Failed to fetch objects", objectsError.message); + showFailureToast(objectsError, { title: "Failed to fetch objects" }); } }, [objectsError]); - const filteredTemplates = templates?.filter((template: Template) => + const filteredTemplates = templates?.filter((template: SpaceObject) => template.name.toLowerCase().includes(searchText.toLowerCase()), ); @@ -60,7 +61,7 @@ export function TemplateList({ space, typeId, isGlobalSearch, isPinned }: Templa title={searchText ? "Search Results" : "Templates"} subtitle={`${pluralize(filteredTemplates.length, "template", { withNumber: true })}`} > - {filteredTemplates.map((template: Template) => ( + {filteredTemplates.map((template: SpaceObject) => ( } /> @@ -98,11 +100,13 @@ export function TemplateList({ space, typeId, isGlobalSearch, isPinned }: Templa subtitle={object.subtitle} accessories={object.accessories} mutate={object.mutate} + object={object.object} layout={object.layout} viewType={ViewType.objects} isGlobalSearch={isGlobalSearch} isNoPinView={true} isPinned={object.isPinned} + searchText={searchText} /> ))} @@ -112,8 +116,8 @@ export function TemplateList({ space, typeId, isGlobalSearch, isPinned }: Templa diff --git a/extensions/anytype/src/components/ObjectDetail.tsx b/extensions/anytype/src/components/ObjectDetail.tsx index f0cda0296a1..576e8462011 100644 --- a/extensions/anytype/src/components/ObjectDetail.tsx +++ b/extensions/anytype/src/components/ObjectDetail.tsx @@ -1,16 +1,28 @@ -import { Color, Detail, getPreferenceValues, showToast, Toast, useNavigation } from "@raycast/api"; +import { Color, Detail, getPreferenceValues, useNavigation } from "@raycast/api"; +import { MutatePromise, showFailureToast } from "@raycast/utils"; import { format } from "date-fns"; import { useEffect, useState } from "react"; import { ObjectActions, TemplateList, ViewType } from "."; -import { useExport, useObject } from "../hooks"; -import { ExportFormat, Property, Space } from "../models"; -import { injectEmojiIntoHeading } from "../utils"; +import { useObject } from "../hooks"; +import { + BodyFormat, + Member, + ObjectLayout, + Property, + PropertyFormat, + PropertyWithValue, + Space, + SpaceObject, + Type, +} from "../models"; +import { bundledPropKeys, injectEmojiIntoHeading } from "../utils"; type ObjectDetailProps = { space: Space; objectId: string; title: string; - layout: string; + mutate?: MutatePromise[]; + layout: ObjectLayout | undefined; viewType: ViewType; isGlobalSearch: boolean; isPinned: boolean; @@ -20,6 +32,7 @@ export function ObjectDetail({ space, objectId, title, + mutate, layout, viewType, isGlobalSearch, @@ -27,30 +40,25 @@ export function ObjectDetail({ }: ObjectDetailProps) { const { push } = useNavigation(); const { linkDisplay } = getPreferenceValues(); - const { object, objectError, isLoadingObject, mutateObject } = useObject(space.id, objectId); - const { objectExport, objectExportError, isLoadingObjectExport, mutateObjectExport } = useExport( - space.id, - objectId, - ExportFormat.Markdown, - ); + const { object, objectError, isLoadingObject, mutateObject } = useObject(space.id, objectId, BodyFormat.Markdown); const [showDetails, setShowDetails] = useState(true); const properties = object?.properties || []; - const excludedPropertyIds = new Set(["added_date", "last_opened_date", "last_modified_date", "last_modified_by"]); - const additionalProperties = properties.filter((property) => !excludedPropertyIds.has(property.id)); + const excludedPropertyKeys = new Set([ + bundledPropKeys.addedDate, + bundledPropKeys.lastModifiedDate, + bundledPropKeys.lastOpenedDate, + bundledPropKeys.lastModifiedBy, + bundledPropKeys.links, + ]); + const additionalProperties = properties.filter((property) => !excludedPropertyKeys.has(property.key)); useEffect(() => { if (objectError) { - showToast(Toast.Style.Failure, "Failed to fetch object", objectError.message); + showFailureToast(objectError, { title: "Failed to fetch object" }); } }, [objectError]); - useEffect(() => { - if (objectExportError) { - showToast(Toast.Style.Failure, "Failed to fetch object as markdown", objectExportError.message); - } - }, [objectExportError]); - const formatOrder: { [key: string]: number } = { text: 0, number: 1, @@ -76,38 +84,45 @@ export function ObjectDetail({ } // For properties in the 'text' group, ensure that 'description' comes first - if (aGroup === "text" && bGroup === "text") { - if (a.id === "description" && b.id !== "description") return -1; - if (b.id === "description" && a.id !== "description") return 1; + if (aGroup === PropertyFormat.Text && bGroup === PropertyFormat.Text) { + if (a.key === bundledPropKeys.description && b.key !== bundledPropKeys.description) return -1; + if (b.key === bundledPropKeys.description && a.key !== bundledPropKeys.description) return 1; } return a.name.localeCompare(b.name); }); - function renderDetailMetadata(property: Property) { - const titleText = property.name || property.id.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + function renderDetailMetadata(property: PropertyWithValue) { + const titleText = property.name || property.key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - if (property.format === "text") { + if (property.format === PropertyFormat.Text) { return ( ); } - if (property.format === "number") { + if (property.format === PropertyFormat.Number) { return ( + ); } else { return ( 0) { return ( - + {tags.map((tag) => ( ))} @@ -151,19 +166,19 @@ export function ObjectDetail({ } else { return ( ); } } - if (property.format === "date") { + if (property.format === PropertyFormat.Date) { return ( 0) { return ( - + {files.map((file) => ( ))} @@ -187,34 +202,34 @@ export function ObjectDetail({ } else { return ( ); } } - if (property.format === "checkbox") { + if (property.format === PropertyFormat.Checkbox) { return ( ); } - if (property.format === "url") { + if (property.format === PropertyFormat.Url) { if (property.url) { if (linkDisplay === "text") { return ( 0) { + if (property.format === PropertyFormat.Objects) { + if (Array.isArray(property.objects) && property.objects.length > 0) { return ( - - {property.object.map((objectItem, index) => { + + {property.objects.map((objectItem, index) => { const handleAction = () => { push( ); })} ); + } else { + return ( + + ); } } return null; @@ -331,7 +355,7 @@ export function ObjectDetail({ const rendered = renderDetailMetadata(property); if (rendered) { if (previousGroup !== null && currentGroup !== previousGroup) { - renderedDetailComponents.push(); + renderedDetailComponents.push(); } renderedDetailComponents.push(rendered); previousGroup = currentGroup; @@ -359,7 +383,7 @@ export function ObjectDetail({ ); - const descIndex = renderedDetailComponents.findIndex((el) => el.key === "description"); + const descIndex = renderedDetailComponents.findIndex((el) => el.key === bundledPropKeys.description); if (descIndex >= 0) { renderedDetailComponents.splice(descIndex + 1, 0, typeTag); } else { @@ -367,13 +391,13 @@ export function ObjectDetail({ } } - const markdown = objectExport?.markdown ?? ""; + const markdown = object?.markdown ?? ""; const updatedMarkdown = injectEmojiIntoHeading(markdown, object?.icon); return ( 0 ? ( @@ -385,14 +409,15 @@ export function ObjectDetail({ space={space} objectId={objectId} title={title} + mutate={mutate} mutateObject={mutateObject} - mutateExport={mutateObjectExport} - objectExport={objectExport} layout={layout} + object={object} viewType={viewType} isGlobalSearch={isGlobalSearch} isNoPinView={false} isPinned={isPinned} + isDetailView={true} showDetails={showDetails} onToggleDetails={() => setShowDetails((prev) => !prev)} /> diff --git a/extensions/anytype/src/components/ObjectList.tsx b/extensions/anytype/src/components/ObjectList.tsx index c8c8318a039..bd230ad4c29 100644 --- a/extensions/anytype/src/components/ObjectList.tsx +++ b/extensions/anytype/src/components/ObjectList.tsx @@ -1,10 +1,27 @@ -import { Icon, List, showToast, Toast } from "@raycast/api"; -import { MutatePromise } from "@raycast/utils"; +import { Icon, List } from "@raycast/api"; +import { MutatePromise, showFailureToast } from "@raycast/utils"; import { useEffect, useState } from "react"; -import { EmptyViewObject, ObjectListItem } from "."; -import { useMembers, usePinnedMembers, usePinnedObjects, usePinnedTypes, useSearch, useTypes } from "../hooks"; -import { Member, MemberStatus, Space, SpaceObject, Type } from "../models"; -import { defaultTintColor, formatMemberRole, localStorageKeys, pluralize, processObject } from "../utils"; +import { EmptyViewObject, EmptyViewProperty, EmptyViewType, ObjectListItem } from "."; +import { + useMembers, + usePinnedMembers, + usePinnedObjects, + usePinnedProperties, + usePinnedTypes, + useProperties, + useSearch, + useTypes, +} from "../hooks"; +import { Member, MemberStatus, Property, Space, SpaceObject, Type } from "../models"; +import { + defaultTintColor, + formatMemberRole, + isUserProperty, + isUserType, + localStorageKeys, + pluralize, + processObject, +} from "../utils"; type ObjectListProps = { space: Space; @@ -14,6 +31,8 @@ export enum ViewType { // browse objects = "Object", // is "all" view in global search types = "Type", + properties = "Property", + tags = "Tag", members = "Member", templates = "Template", @@ -34,6 +53,9 @@ export function ObjectList({ space }: ObjectListProps) { [], ); const { types, typesError, isLoadingTypes, mutateTypes, typesPagination } = useTypes(space.id); + const { properties, propertiesError, isLoadingProperties, mutateProperties, propertiesPagination } = useProperties( + space.id, + ); const { members, membersError, isLoadingMembers, mutateMembers, membersPagination } = useMembers(space.id); const { pinnedObjects, pinnedObjectsError, isLoadingPinnedObjects, mutatePinnedObjects } = usePinnedObjects( localStorageKeys.suffixForViewsPerSpace(space.id, ViewType.objects), @@ -41,6 +63,8 @@ export function ObjectList({ space }: ObjectListProps) { const { pinnedTypes, pinnedTypesError, isLoadingPinnedTypes, mutatePinnedTypes } = usePinnedTypes( localStorageKeys.suffixForViewsPerSpace(space.id, ViewType.types), ); + const { pinnedProperties, pinnedPropertiesError, isLoadingPinnedProperties, mutatePinnedProperties } = + usePinnedProperties(localStorageKeys.suffixForViewsPerSpace(space.id, ViewType.properties)); const { pinnedMembers, pinnedMembersError, isLoadingPinnedMembers, mutatePinnedMembers } = usePinnedMembers( localStorageKeys.suffixForViewsPerSpace(space.id, ViewType.members), ); @@ -50,28 +74,25 @@ export function ObjectList({ space }: ObjectListProps) { const paginationMap: Partial> = { [ViewType.objects]: objectsPagination, [ViewType.types]: typesPagination, + [ViewType.properties]: propertiesPagination, [ViewType.members]: membersPagination, }; setPagination(paginationMap[currentView]); - }, [currentView, objects, types, members]); + }, [currentView, objects, types, properties, members]); useEffect(() => { - if (objectsError || typesError || membersError) { - showToast( - Toast.Style.Failure, - "Failed to fetch latest data", - objectsError?.message || typesError?.message || membersError?.message, - ); + if (objectsError || typesError || propertiesError || membersError) { + showFailureToast(objectsError || typesError || propertiesError || membersError, { + title: "Failed to fetch latest data", + }); } }, [objectsError, typesError, membersError]); useEffect(() => { - if (pinnedObjectsError || pinnedTypesError || pinnedMembersError) { - showToast( - Toast.Style.Failure, - "Failed to fetch pinned data", - pinnedObjectsError?.message || pinnedTypesError?.message || pinnedMembersError?.message, - ); + if (pinnedObjectsError || pinnedTypesError || pinnedPropertiesError || pinnedMembersError) { + showFailureToast(pinnedObjectsError || pinnedTypesError || pinnedPropertiesError || pinnedMembersError, { + title: "Failed to fetch pinned data", + }); } }, [pinnedObjectsError, pinnedTypesError, pinnedMembersError]); @@ -86,10 +107,34 @@ export function ObjectList({ space }: ObjectListProps) { icon: type.icon, title: type.name, subtitle: { value: "", tooltip: "" }, - accessories: [isPinned ? { icon: Icon.Star, tooltip: "Pinned" } : {}], - mutate: [mutateTypes, mutatePinnedTypes as MutatePromise], - member: undefined, - layout: "", + accessories: [ + ...(isPinned ? [{ icon: Icon.Star, tooltip: "Pinned" }] : []), + ...(!isUserType(type.key) ? [{ icon: Icon.Lock, tooltip: "System" }] : []), + ], + mutate: [mutateTypes, mutatePinnedTypes as MutatePromise], + object: type, + layout: type.layout, + isPinned, + }; + }; + + const processProperty = (property: Property, isPinned: boolean) => { + return { + spaceId: space.id, + id: property.id, + icon: property.icon, + title: property.name, + subtitle: { value: "", tooltip: "" }, + accessories: [ + ...(isPinned ? [{ icon: Icon.Star, tooltip: "Pinned" }] : []), + ...(!isUserProperty(property.key) ? [{ icon: Icon.Lock, tooltip: "System" }] : []), + ], + mutate: [ + mutateProperties, + mutatePinnedProperties as MutatePromise, + ], + object: property, + layout: undefined, isPinned, }; }; @@ -100,7 +145,7 @@ export function ObjectList({ space }: ObjectListProps) { id: member.id, icon: member.icon, title: member.name, - subtitle: { value: member.global_name, tooltip: `Global Name: ${member.global_name}` }, + subtitle: { value: member.global_name, tooltip: `ANY Name: ${member.global_name}` }, accessories: [ ...(isPinned ? [{ icon: Icon.Star, tooltip: "Pinned" }] : []), member.status === MemberStatus.Joining @@ -112,9 +157,9 @@ export function ObjectList({ space }: ObjectListProps) { tooltip: `Role: ${formatMemberRole(member.role)}`, }, ], - mutate: [mutateMembers, mutatePinnedMembers as MutatePromise], - member: member, - layout: "", + mutate: [mutateMembers, mutatePinnedMembers as MutatePromise], + object: member, + layout: undefined, isPinned, }; }; @@ -153,6 +198,20 @@ export function ObjectList({ space }: ObjectListProps) { return { processedPinned, processedRegular }; } + case ViewType.properties: { + const processedPinned = pinnedProperties?.length + ? pinnedProperties + .filter((property) => filterItems([property], searchText).length > 0) + .map((property) => processProperty(property, true)) + : []; + const processedRegular = properties + .filter((property) => !pinnedProperties?.some((pinned) => pinned.id === property.id)) + .filter((property) => filterItems([property], searchText).length > 0) + .map((property) => processProperty(property, false)); + + return { processedPinned, processedRegular }; + } + case ViewType.members: { const processedPinned = pinnedMembers?.length ? pinnedMembers @@ -180,16 +239,18 @@ export function ObjectList({ space }: ObjectListProps) { const isLoading = isLoadingObjects || isLoadingTypes || + isLoadingProperties || isLoadingMembers || isLoadingPinnedObjects || isLoadingPinnedTypes || + isLoadingPinnedProperties || isLoadingPinnedMembers; return ( + } @@ -232,12 +298,13 @@ export function ObjectList({ space }: ObjectListProps) { subtitle={item.subtitle} accessories={item.accessories} mutate={item.mutate} - member={item.member} + object={item.object} layout={item.layout} viewType={currentView} isGlobalSearch={false} isNoPinView={false} isPinned={item.isPinned} + searchText={searchText} /> ))} @@ -257,23 +324,51 @@ export function ObjectList({ space }: ObjectListProps) { subtitle={item.subtitle} accessories={item.accessories} mutate={item.mutate} - member={item.member} + object={item.object} layout={item.layout} viewType={currentView} isGlobalSearch={false} isNoPinView={false} isPinned={item.isPinned} + searchText={searchText} /> ))} ) : ( - + (() => { + switch (currentView) { + case ViewType.types: + return ( + + ); + case ViewType.properties: + return ( + + ); + default: + return ( + + ); + } + })() )} ); diff --git a/extensions/anytype/src/components/ObjectListItem.tsx b/extensions/anytype/src/components/ObjectListItem.tsx index c502021ec7e..7e07e1e1568 100644 --- a/extensions/anytype/src/components/ObjectListItem.tsx +++ b/extensions/anytype/src/components/ObjectListItem.tsx @@ -1,7 +1,7 @@ import { Image, List } from "@raycast/api"; import { MutatePromise } from "@raycast/utils"; import { ObjectActions, ViewType } from "."; -import { Member, Space, SpaceObject, Type, View } from "../models"; +import { Member, ObjectLayout, Property, Space, SpaceObject, Type, View } from "../models"; type ObjectListItemProps = { space: Space; @@ -16,14 +16,15 @@ type ObjectListItemProps = { tooltip?: string; tag?: { value: string; color: string; tooltip: string }; }[]; - mutate: MutatePromise[]; + mutate: MutatePromise[]; mutateViews?: MutatePromise; - member?: Member | undefined; - layout: string; + object: SpaceObject | Type | Property | Member; + layout: ObjectLayout | undefined; viewType: ViewType; isGlobalSearch: boolean; isNoPinView: boolean; isPinned: boolean; + searchText: string; }; export function ObjectListItem({ @@ -35,12 +36,13 @@ export function ObjectListItem({ accessories, mutate, mutateViews, - member, + object, layout, viewType, isGlobalSearch, isNoPinView, isPinned, + searchText, }: ObjectListItemProps) { return ( } /> diff --git a/extensions/anytype/src/components/SpaceListItem.tsx b/extensions/anytype/src/components/SpaceListItem.tsx index 4c8be657c32..cb6069a74f7 100644 --- a/extensions/anytype/src/components/SpaceListItem.tsx +++ b/extensions/anytype/src/components/SpaceListItem.tsx @@ -14,9 +14,10 @@ type SpaceListItemProps = { }[]; mutate: MutatePromise[]; isPinned: boolean; + searchText: string; }; -export function SpaceListItem({ space, icon, accessories, mutate, isPinned }: SpaceListItemProps) { +export function SpaceListItem({ space, icon, accessories, mutate, isPinned, searchText }: SpaceListItemProps) { return ( } + actions={} /> ); } diff --git a/extensions/anytype/src/components/UpdateForm/UpdateObjectForm.tsx b/extensions/anytype/src/components/UpdateForm/UpdateObjectForm.tsx new file mode 100644 index 00000000000..ae793104b9d --- /dev/null +++ b/extensions/anytype/src/components/UpdateForm/UpdateObjectForm.tsx @@ -0,0 +1,374 @@ +import { Action, ActionPanel, Form, Icon, showToast, Toast, useNavigation } from "@raycast/api"; +import { MutatePromise, showFailureToast, useForm } from "@raycast/utils"; +import { formatRFC3339 } from "date-fns"; +import { useEffect, useMemo, useState } from "react"; +import { updateObject } from "../../api"; +import { useSearch, useTagsMap } from "../../hooks"; +import { + IconFormat, + ObjectIcon, + PropertyFieldValue, + PropertyFormat, + PropertyLinkWithValue, + RawSpaceObjectWithBody, + SpaceObject, + SpaceObjectWithBody, + UpdateObjectRequest, +} from "../../models"; +import { bundledPropKeys, bundledTypeKeys, defaultTintColor, getNumberFieldValidations, isEmoji } from "../../utils"; + +interface UpdateObjectFormValues { + name?: string; + icon?: string; + description?: string; + [key: string]: PropertyFieldValue; +} + +interface UpdateObjectFormProps { + spaceId: string; + object: RawSpaceObjectWithBody; + mutateObjects: MutatePromise[]; + mutateObject?: MutatePromise; +} + +export function UpdateObjectForm({ spaceId, object, mutateObjects, mutateObject }: UpdateObjectFormProps) { + const { pop } = useNavigation(); + const [objectSearchText, setObjectSearchText] = useState(""); + + const properties = object.type.properties.filter((p) => !Object.values(bundledPropKeys).includes(p.key)); + const numberFieldValidations = useMemo(() => getNumberFieldValidations(properties), [properties]); + + const { objects, objectsError, isLoadingObjects } = useSearch(spaceId, objectSearchText, []); + const { tagsMap, tagsError, isLoadingTags } = useTagsMap( + spaceId, + properties + .filter((prop) => prop.format === PropertyFormat.Select || prop.format === PropertyFormat.MultiSelect) + .map((prop) => prop.id), + ); + + useEffect(() => { + if (objectsError || tagsError) { + showFailureToast(objectsError || tagsError, { title: "Failed to load data" }); + } + }, [objectsError, tagsError]); + + // Map existing property entries to form field values + const initialPropertyValues: Record = properties.reduce( + (acc, prop) => { + const entry = object.properties.find((p) => p.key === prop.key); + if (entry) { + let v: PropertyFieldValue; + switch (prop.format) { + case PropertyFormat.Text: + v = entry.text ?? ""; + break; + case PropertyFormat.Select: + v = entry.select?.id ?? ""; + break; + case PropertyFormat.Url: + v = entry.url ?? ""; + break; + case PropertyFormat.Email: + v = entry.email ?? ""; + break; + case PropertyFormat.Phone: + v = entry.phone ?? ""; + break; + case PropertyFormat.Number: + v = entry.number ?? ""; + break; + case PropertyFormat.MultiSelect: + v = entry.multi_select?.map((tag) => tag.id) ?? []; + break; + case PropertyFormat.Date: + v = entry.date ? new Date(entry.date) : undefined; + break; + case PropertyFormat.Checkbox: + v = entry.checkbox ?? false; + break; + case PropertyFormat.Files: + v = entry.files ?? []; + break; + case PropertyFormat.Objects: + v = entry.objects ?? []; + break; + default: + v = undefined; + } + acc[prop.key] = v; + } + return acc; + }, + {} as Record, + ); + + const descriptionEntry = object.properties.find((p) => p.key === bundledPropKeys.description); + const initialIconValue = object.icon.format === IconFormat.Emoji ? (object.icon.emoji ?? "") : ""; + + const initialValues: UpdateObjectFormValues = { + name: object.name, + icon: initialIconValue, + description: descriptionEntry?.text ?? "", + ...initialPropertyValues, + }; + + const { handleSubmit, itemProps } = useForm({ + initialValues: initialValues, + onSubmit: async (values) => { + try { + await showToast({ style: Toast.Style.Animated, title: "Updating object…" }); + + const propertiesEntries: PropertyLinkWithValue[] = []; + properties.forEach((prop) => { + const raw = itemProps[prop.key]?.value; + const entry: PropertyLinkWithValue = { key: prop.key, format: prop.format }; + switch (prop.format) { + case PropertyFormat.Text: + entry.text = String(raw); + break; + case PropertyFormat.Select: + entry.select = raw != null && raw !== "" ? String(raw) : null; + break; + case PropertyFormat.Url: + entry.url = String(raw); + break; + case PropertyFormat.Email: + entry.email = String(raw); + break; + case PropertyFormat.Phone: + entry.phone = String(raw); + break; + case PropertyFormat.Number: + entry.number = raw != null && raw !== "" ? Number(raw) : null; + break; + case PropertyFormat.MultiSelect: + entry.multi_select = Array.isArray(raw) ? (raw as string[]) : []; + break; + case PropertyFormat.Date: + if (raw instanceof Date) { + entry.date = formatRFC3339(raw); + } else { + entry.date = null; + } + break; + case PropertyFormat.Checkbox: + entry.checkbox = Boolean(raw); + break; + case PropertyFormat.Files: + entry.files = Array.isArray(raw) ? raw : typeof raw === "string" && raw ? [raw] : []; + break; + case PropertyFormat.Objects: + entry.objects = Array.isArray(raw) ? raw : typeof raw === "string" && raw ? [raw] : []; + break; + default: + console.warn(`Unsupported property format: ${prop.format}`); + break; + } + propertiesEntries.push(entry); + }); + + const descriptionRaw = itemProps[bundledPropKeys.description]?.value; + if (descriptionRaw !== undefined && descriptionRaw !== null) { + propertiesEntries.push({ + key: bundledPropKeys.description, + format: PropertyFormat.Text, + text: String(descriptionRaw), + }); + } + + const iconField = values.icon as string; + let iconPayload: ObjectIcon | undefined; + if (iconField !== initialIconValue) { + iconPayload = { format: IconFormat.Emoji, emoji: iconField }; + } + + const payload: UpdateObjectRequest = { + name: values.name || "", + ...(iconPayload !== undefined && { icon: iconPayload }), + properties: propertiesEntries, + }; + + await updateObject(spaceId, object.id, payload); + + await showToast(Toast.Style.Success, "Object updated"); + mutateObjects.forEach((mutate) => mutate()); + if (mutateObject) { + mutateObject(); + } + pop(); + } catch (error) { + await showFailureToast(error, { title: "Failed to update object" }); + } + }, + validation: { + name: (v: PropertyFieldValue) => { + const s = typeof v === "string" ? v.trim() : ""; + if (![bundledTypeKeys.bookmark, bundledTypeKeys.note].includes(object.type.key) && !s) { + return "Name is required"; + } + }, + icon: (v: PropertyFieldValue) => { + if (typeof v === "string" && v && !isEmoji(v)) { + return "Icon must be a single emoji"; + } + }, + ...numberFieldValidations, + }, + }); + + return ( +
+ + + } + > + {![bundledTypeKeys.note].includes(object.type.key) && ( + + )} + {![bundledTypeKeys.task, bundledTypeKeys.note, bundledTypeKeys.profile].includes(object.type.key) && ( + + )} + + + + + {properties.map((prop) => { + const tags = (tagsMap && tagsMap[prop.id]) ?? []; + const id = prop.key; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { value, defaultValue, ...restItemProps } = itemProps[id]; + + switch (prop.format) { + case PropertyFormat.Text: + case PropertyFormat.Url: + case PropertyFormat.Email: + case PropertyFormat.Phone: + return ( + + ); + case PropertyFormat.Number: + return ( + + ); + case PropertyFormat.Select: + return ( + + + {tags.map((tag) => ( + + ))} + + ); + case PropertyFormat.MultiSelect: + return ( + + {tags.map((tag) => ( + + ))} + + ); + case PropertyFormat.Date: + return ( + + ); + case PropertyFormat.Files: + // TODO: implement file picker + return null; + case PropertyFormat.Checkbox: + return ( + + ); + case PropertyFormat.Objects: + return ( + + {!objectSearchText && ( + + )} + {objects + .filter((candidate) => candidate.id !== object.id) + .map((object) => ( + + ))} + + ); + + default: + return null; + } + })} + + ); +} diff --git a/extensions/anytype/src/components/UpdateForm/UpdatePropertyForm.tsx b/extensions/anytype/src/components/UpdateForm/UpdatePropertyForm.tsx new file mode 100644 index 00000000000..931b5a7bf00 --- /dev/null +++ b/extensions/anytype/src/components/UpdateForm/UpdatePropertyForm.tsx @@ -0,0 +1,75 @@ +import { Action, ActionPanel, Form, Icon, showToast, Toast, useNavigation } from "@raycast/api"; +import { MutatePromise, showFailureToast, useForm } from "@raycast/utils"; +import { updateProperty } from "../../api"; // ← import your new helper +import { Property, PropertyFormat } from "../../models"; + +export interface UpdatePropertyFormValues { + name: string; + format?: string; +} + +interface UpdatePropertyFormProps { + spaceId: string; + property: Property; + mutateProperties: MutatePromise[]; +} + +export function UpdatePropertyForm({ spaceId, property, mutateProperties }: UpdatePropertyFormProps) { + const { pop } = useNavigation(); + const { handleSubmit, itemProps } = useForm({ + initialValues: { + name: property.name, + }, + onSubmit: async (values) => { + try { + await showToast({ style: Toast.Style.Animated, title: "Updating property..." }); + + await updateProperty(spaceId, property.id, { name: values.name || "" }); + + showToast(Toast.Style.Success, "Property updated successfully"); + mutateProperties.forEach((mutate) => mutate()); + pop(); + } catch (error) { + await showFailureToast(error, { title: "Failed to update property" }); + } + }, + validation: { + name: (v) => (!v ? "Name is required" : undefined), + }, + }); + + const propertyFormatKeys = Object.keys(PropertyFormat) as Array; + + return ( +
+ + + } + > + {}} + onFocus={() => {}} + info="Format is read-only" + > + PropertyFormat[key] === property.format) || property.format} + icon={{ source: `icons/property/${property.format}.svg` }} + /> + + + + ); +} diff --git a/extensions/anytype/src/components/UpdateForm/UpdateSpaceForm.tsx b/extensions/anytype/src/components/UpdateForm/UpdateSpaceForm.tsx new file mode 100644 index 00000000000..b117f5ee610 --- /dev/null +++ b/extensions/anytype/src/components/UpdateForm/UpdateSpaceForm.tsx @@ -0,0 +1,66 @@ +import { Action, ActionPanel, Form, Icon, showToast, Toast, useNavigation } from "@raycast/api"; +import { MutatePromise, showFailureToast, useForm } from "@raycast/utils"; +import { updateSpace } from "../../api"; +import { Space } from "../../models"; + +export interface UpdateSpaceFormValues { + name: string; + description: string; +} + +interface UpdateSpaceFormProps { + space: Space; + mutateSpaces: MutatePromise[]; +} + +export function UpdateSpaceForm({ space, mutateSpaces }: UpdateSpaceFormProps) { + const { pop } = useNavigation(); + + const { handleSubmit, itemProps } = useForm({ + initialValues: { + name: space.name, + description: space.description, + }, + onSubmit: async (values) => { + try { + await showToast({ + style: Toast.Style.Animated, + title: "Updating space…", + }); + + await updateSpace(space.id, { + name: values.name || "", + description: values.description || "", + }); + + showToast(Toast.Style.Success, "Space updated successfully"); + mutateSpaces.forEach((mutate) => mutate()); + pop(); + } catch (error) { + await showFailureToast(error, { title: "Failed to update space" }); + } + }, + validation: { + name: (v) => (!v ? "Name is required" : undefined), + }, + }); + + return ( +
+ + + } + > + + + + ); +} diff --git a/extensions/anytype/src/components/UpdateForm/UpdateTagForm.tsx b/extensions/anytype/src/components/UpdateForm/UpdateTagForm.tsx new file mode 100644 index 00000000000..784a87fa6a3 --- /dev/null +++ b/extensions/anytype/src/components/UpdateForm/UpdateTagForm.tsx @@ -0,0 +1,79 @@ +import { Action, ActionPanel, Form, Icon, showToast, Toast, useNavigation } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { updateTag } from "../../api"; +import { Color, Tag } from "../../models"; +import { colorToHex, hexToColor } from "../../utils"; + +export interface UpdateTagFormValues { + name: string; + color: string; +} + +interface UpdateTagFormProps { + spaceId: string; + propertyId: string; + tag: Tag; + mutateTags: () => void; +} + +export function UpdateTagForm({ spaceId, propertyId, tag, mutateTags }: UpdateTagFormProps) { + const { pop } = useNavigation(); + + const { handleSubmit, itemProps } = useForm({ + initialValues: { + name: tag.name, + color: hexToColor[tag.color] as Color, + }, + onSubmit: async (values) => { + try { + await showToast({ + style: Toast.Style.Animated, + title: "Updating tag…", + }); + + await updateTag(spaceId, propertyId, tag.id, { + name: values.name || "", + color: values.color as Color, + }); + + showToast(Toast.Style.Success, "Tag updated successfully"); + mutateTags(); + pop(); + } catch (error) { + await showFailureToast(error, { title: "Failed to update tag" }); + } + }, + validation: { + name: (v) => (!v ? "Name is required" : undefined), + color: (v) => (!v ? "Color is required" : undefined), + }, + }); + + const tagColorKeys = Object.keys(Color) as Array; + + return ( +
+ + + } + > + + + {tagColorKeys.map((key) => { + const value = Color[key]; + return ( + + ); + })} + + + ); +} diff --git a/extensions/anytype/src/components/UpdateForm/UpdateTypeForm.tsx b/extensions/anytype/src/components/UpdateForm/UpdateTypeForm.tsx new file mode 100644 index 00000000000..f3723571cc2 --- /dev/null +++ b/extensions/anytype/src/components/UpdateForm/UpdateTypeForm.tsx @@ -0,0 +1,119 @@ +import { Action, ActionPanel, Form, Icon, showToast, Toast, useNavigation } from "@raycast/api"; +import { MutatePromise, showFailureToast, useForm } from "@raycast/utils"; +import { useState } from "react"; +import { updateType } from "../../api"; +import { useProperties } from "../../hooks"; +import { IconFormat, ObjectLayout, PropertyLink, RawType, Type, TypeLayout, UpdateTypeRequest } from "../../models"; +import { isEmoji } from "../../utils"; + +export interface UpdateTypeFormValues { + name: string; + plural_name: string; + icon?: string; + layout: string; + properties?: string[]; +} + +interface UpdateTypeFormProps { + spaceId: string; + type: RawType; + mutateTypes: MutatePromise[]; +} + +export function UpdateTypeForm({ spaceId, type, mutateTypes }: UpdateTypeFormProps) { + const { pop } = useNavigation(); + const [loading, setLoading] = useState(false); + const { properties } = useProperties(spaceId); + + const initialValues: UpdateTypeFormValues = { + name: type.name, + plural_name: type.plural_name, + icon: type.icon.format === IconFormat.Emoji ? type.icon.emoji : undefined, + layout: type.layout, + properties: type.properties?.map((p) => p.key) || [], + }; + + const { handleSubmit, itemProps } = useForm({ + initialValues, + onSubmit: async (values) => { + setLoading(true); + try { + await showToast({ style: Toast.Style.Animated, title: "Updating type..." }); + + const propertyLinks: PropertyLink[] = + values.properties?.map((key) => { + const prop = properties.find((p) => p.key === key)!; + return { key: prop.key, format: prop.format, name: prop.name }; + }) || []; + + const request: UpdateTypeRequest = { + name: values.name, + plural_name: values.plural_name, + icon: { format: IconFormat.Emoji, emoji: values.icon || "" }, + ...(availableLayouts.includes(values.layout as TypeLayout) ? { layout: values.layout as TypeLayout } : {}), + properties: propertyLinks, + }; + + await updateType(spaceId, type.id, request); + + await showToast(Toast.Style.Success, "Type updated successfully"); + mutateTypes.forEach((mutate) => mutate()); + pop(); + } catch (error) { + await showFailureToast(error, { title: "Failed to update type" }); + } finally { + setLoading(false); + } + }, + validation: { + name: (v) => (!v ? "Name is required" : undefined), + plural_name: (v) => (!v ? "Plural name is required" : undefined), + icon: (v) => (v && !isEmoji(v) ? "Icon must be a single emoji" : undefined), + }, + }); + + const layoutKeys = Object.keys(TypeLayout) as Array; + const availableLayouts = Object.values(TypeLayout); + const isFixedLayout = !availableLayouts.includes(type.layout as unknown as TypeLayout); + + return ( +
+ + + } + > + + + + {isFixedLayout ? ( + + ).find( + (k) => ObjectLayout[k] === initialValues.layout, + )! + } + /> + + ) : ( + + {layoutKeys.map((layout) => { + const value = TypeLayout[layout]; + return ; + })} + + )} + + {properties.map((prop) => ( + + ))} + + + ); +} diff --git a/extensions/anytype/src/components/index.ts b/extensions/anytype/src/components/index.ts index a9b3b0c202e..b82d9f6c344 100644 --- a/extensions/anytype/src/components/index.ts +++ b/extensions/anytype/src/components/index.ts @@ -1,13 +1,27 @@ -export * from "./CollectionList"; -export * from "./CreateObjectForm"; -export * from "./CreateSpaceForm"; -export * from "./EmptyViewObject"; -export * from "./EmptyViewSpace"; +export * from "./Actions/ListSubmenu"; +export * from "./Actions/ObjectActions"; +export * from "./Actions/SpaceActions"; +export * from "./CreateForm/CreateObjectForm"; +export * from "./CreateForm/CreatePropertyForm"; +export * from "./CreateForm/CreateSpaceForm"; +export * from "./CreateForm/CreateTagForm"; +export * from "./CreateForm/CreateTypeForm"; +export * from "./EmptyView/EmptyViewObject"; +export * from "./EmptyView/EmptyViewProperty"; +export * from "./EmptyView/EmptyViewSpace"; +export * from "./EmptyView/EmptyViewTag"; +export * from "./EmptyView/EmptyViewType"; export * from "./EnsureAuthenticated"; -export * from "./ObjectActions"; +export * from "./Lists/CollectionList"; +export * from "./Lists/SpaceList"; +export * from "./Lists/TagList"; +export * from "./Lists/TemplateList"; export * from "./ObjectDetail"; export * from "./ObjectList"; export * from "./ObjectListItem"; -export * from "./SpaceActions"; export * from "./SpaceListItem"; -export * from "./TemplateList"; +export * from "./UpdateForm/UpdateObjectForm"; +export * from "./UpdateForm/UpdatePropertyForm"; +export * from "./UpdateForm/UpdateSpaceForm"; +export * from "./UpdateForm/UpdateTagForm"; +export * from "./UpdateForm/UpdateTypeForm"; diff --git a/extensions/anytype/src/create-object.tsx b/extensions/anytype/src/create-object.tsx index 7e50d03f195..16c85487ef8 100644 --- a/extensions/anytype/src/create-object.tsx +++ b/extensions/anytype/src/create-object.tsx @@ -1,17 +1,27 @@ import { LaunchProps } from "@raycast/api"; import { CreateObjectForm, EnsureAuthenticated } from "./components"; -import { useCreateObjectData } from "./hooks"; - +import { PropertyFieldValue } from "./models"; export interface CreateObjectFormValues { - space?: string; - type?: string; - template?: string; - list?: string; + spaceId?: string; + typeId?: string; + templateId?: string; + listId?: string; name?: string; icon?: string; description?: string; body?: string; source?: string; + + /** + * Dynamic property values coming from the selected Type definition. + * Keys are the property `key` strings and values depend on the property format: + * - "text" & "select" -> string + * - "number" -> string (raw text input before cast) + * - "date" -> Date | null (Raycast DatePicker returns a Date object) + * - "multi_select" -> string[] + * - "checkbox" -> boolean + */ + [key: string]: PropertyFieldValue; } interface LaunchContext { @@ -45,43 +55,5 @@ function CreateObject({ draftValues, launchContext }: CreateObjectProps) { ...draftValues, // `draftValues` takes precedence }; - const { - spaces, - types, - templates, - lists, - selectedSpace, - setSelectedSpace, - selectedType, - setSelectedType, - selectedTemplate, - setSelectedTemplate, - selectedList, - setSelectedList, - listSearchText, - setListSearchText, - isLoading, - } = useCreateObjectData(mergedValues); - - return ( - - ); + return ; } diff --git a/extensions/anytype/src/hooks/index.ts b/extensions/anytype/src/hooks/index.ts index 0d502dc4b4c..1f12ae91978 100644 --- a/extensions/anytype/src/hooks/index.ts +++ b/extensions/anytype/src/hooks/index.ts @@ -1,5 +1,4 @@ export * from "./useCreateObjectData"; -export * from "./useExport"; export * from "./useGlobalSearch"; export * from "./useMembers"; export * from "./useObject"; @@ -7,9 +6,12 @@ export * from "./useObjects"; export * from "./useObjectsInList"; export * from "./usePinnedMembers"; export * from "./usePinnedObjects"; +export * from "./usePinnedProperties"; export * from "./usePinnedSpaces"; export * from "./usePinnedTypes"; +export * from "./useProperties"; export * from "./useSearch"; export * from "./useSpaces"; +export * from "./useTags"; export * from "./useTemplates"; export * from "./useTypes"; diff --git a/extensions/anytype/src/hooks/useCreateObjectData.ts b/extensions/anytype/src/hooks/useCreateObjectData.ts index 3574a742853..3b53de6bc23 100644 --- a/extensions/anytype/src/hooks/useCreateObjectData.ts +++ b/extensions/anytype/src/hooks/useCreateObjectData.ts @@ -1,42 +1,42 @@ -import { showToast, Toast } from "@raycast/api"; -import { useCachedPromise } from "@raycast/utils"; +import { showFailureToast, useCachedPromise } from "@raycast/utils"; import { useEffect, useMemo, useState } from "react"; import { CreateObjectFormValues } from "../create-object"; -import { fetchAllTemplatesForSpace, fetchAllTypesForSpace } from "../utils"; +import { bundledTypeKeys, fetchAllTemplatesForSpace, fetchAllTypesForSpace } from "../utils"; import { useSearch } from "./useSearch"; import { useSpaces } from "./useSpaces"; export function useCreateObjectData(initialValues?: CreateObjectFormValues) { - const [selectedSpace, setSelectedSpace] = useState(initialValues?.space || ""); - const [selectedType, setSelectedType] = useState(initialValues?.type || ""); - const [selectedTemplate, setSelectedTemplate] = useState(initialValues?.template || ""); - const [selectedList, setSelectedList] = useState(initialValues?.list || ""); + const [selectedSpaceId, setSelectedSpaceId] = useState(initialValues?.spaceId || ""); + const [selectedTypeId, setSelectedTypeId] = useState(initialValues?.typeId || ""); + const [selectedTemplateId, setSelectedTemplateId] = useState(initialValues?.templateId || ""); + const [selectedListId, setSelectedListId] = useState(initialValues?.listId || ""); const [listSearchText, setListSearchText] = useState(""); + const [objectSearchText, setObjectSearchText] = useState(""); const { spaces, spacesError, isLoadingSpaces } = useSpaces(); const { objects: lists, objectsError: listsError, isLoadingObjects: isLoadingLists, - } = useSearch(selectedSpace, listSearchText, ["ot-collection"]); + } = useSearch(selectedSpaceId, listSearchText, [bundledTypeKeys.collection]); const restrictedTypes = [ - "ot-audio", - "ot-chat", - "ot-file", - "ot-image", - "ot-objectType", - "ot-tag", - "ot-template", - "ot-video", - "ot-participant", + bundledTypeKeys.audio, + bundledTypeKeys.chat, + bundledTypeKeys.file, + bundledTypeKeys.image, + bundledTypeKeys.object_type, + bundledTypeKeys.tag, + bundledTypeKeys.template, + bundledTypeKeys.video, + bundledTypeKeys.participant, ]; const { data: allTypes, error: typesError, isLoading: isLoadingTypes, - } = useCachedPromise(fetchAllTypesForSpace, [selectedSpace], { execute: !!selectedSpace }); + } = useCachedPromise(fetchAllTypesForSpace, [selectedSpaceId], { execute: !!selectedSpaceId }); const types = useMemo(() => { if (!allTypes) return []; @@ -47,38 +47,41 @@ export function useCreateObjectData(initialValues?: CreateObjectFormValues) { data: templates, error: templatesError, isLoading: isLoadingTemplates, - } = useCachedPromise(fetchAllTemplatesForSpace, [selectedSpace, selectedType], { - execute: !!selectedSpace && !!selectedType, + } = useCachedPromise(fetchAllTemplatesForSpace, [selectedSpaceId, selectedTypeId], { + execute: !!selectedSpaceId && !!selectedTypeId, initialData: [], }); + const { objects, objectsError, isLoadingObjects } = useSearch(selectedSpaceId, objectSearchText, []); + useEffect(() => { - if (spacesError || typesError || templatesError || listsError) { - showToast( - Toast.Style.Failure, - "Failed to fetch latest data", - spacesError?.message || typesError?.message || templatesError?.message || listsError?.message, - ); + if (spacesError || typesError || templatesError || listsError || objectsError) { + showFailureToast(spacesError || typesError || templatesError || listsError || objectsError, { + title: "Failed to fetch latest data", + }); } }, [spacesError, typesError, templatesError, listsError]); - const isLoading = isLoadingSpaces || isLoadingTypes || isLoadingTemplates || isLoadingLists; + const isLoading = isLoadingSpaces || isLoadingTypes || isLoadingTemplates || isLoadingLists || isLoadingObjects; return { spaces, types, templates, lists, - selectedSpace, - setSelectedSpace, - selectedType, - setSelectedType, - selectedTemplate, - setSelectedTemplate, - selectedList, - setSelectedList, + objects, + selectedSpaceId, + setSelectedSpaceId, + selectedTypeId, + setSelectedTypeId, + selectedTemplateId, + setSelectedTemplateId, + selectedListId, + setSelectedListId, listSearchText, setListSearchText, + objectSearchText, + setObjectSearchText, isLoading, }; } diff --git a/extensions/anytype/src/hooks/useExport.ts b/extensions/anytype/src/hooks/useExport.ts deleted file mode 100644 index 3714ed2576e..00000000000 --- a/extensions/anytype/src/hooks/useExport.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useCachedPromise } from "@raycast/utils"; -import { getExport } from "../api"; -import { ExportFormat } from "../models"; - -export function useExport(spaceId: string, objectId: string, format: ExportFormat) { - const { data, error, mutate, isLoading } = useCachedPromise( - async (spaceId, objectId, format) => { - const response = await getExport(spaceId, objectId, format); - return response; - }, - [spaceId, objectId, format], - { - execute: !!spaceId && !!objectId && !!format, - }, - ); - - return { - objectExport: data, - objectExportError: error, - isLoadingObjectExport: isLoading, - mutateObjectExport: mutate, - }; -} diff --git a/extensions/anytype/src/hooks/useGlobalSearch.ts b/extensions/anytype/src/hooks/useGlobalSearch.ts index c7122771f1a..180e750b50b 100644 --- a/extensions/anytype/src/hooks/useGlobalSearch.ts +++ b/extensions/anytype/src/hooks/useGlobalSearch.ts @@ -13,7 +13,7 @@ export function useGlobalSearch(query: string, types: string[]) { const sortDirection = sortPreference === "name" ? SortDirection.Ascending : SortDirection.Descending; const response = await globalSearch( - { query, types, sort: { property: sortPreference, direction: sortDirection } }, + { query, types, sort: { property_key: sortPreference, direction: sortDirection } }, { offset, limit: apiLimit }, ); diff --git a/extensions/anytype/src/hooks/useObject.ts b/extensions/anytype/src/hooks/useObject.ts index 300ba24febf..f68257cc0de 100644 --- a/extensions/anytype/src/hooks/useObject.ts +++ b/extensions/anytype/src/hooks/useObject.ts @@ -1,10 +1,11 @@ import { useCachedPromise } from "@raycast/utils"; import { getObject } from "../api"; +import { BodyFormat } from "../models"; -export function useObject(spaceId: string, objectId: string) { +export function useObject(spaceId: string, objectId: string, format: BodyFormat) { const { data, error, isLoading, mutate } = useCachedPromise( async (spaceId: string, objectId: string) => { - const response = await getObject(spaceId, objectId); + const response = await getObject(spaceId, objectId, format); return response.object; }, [spaceId, objectId], diff --git a/extensions/anytype/src/hooks/useObjects.ts b/extensions/anytype/src/hooks/useObjects.ts index 969f3202d1a..728d4464431 100644 --- a/extensions/anytype/src/hooks/useObjects.ts +++ b/extensions/anytype/src/hooks/useObjects.ts @@ -17,6 +17,7 @@ export function useObjects(spaceId: string) { [spaceId], { keepPreviousData: true, + execute: !!spaceId, }, ); diff --git a/extensions/anytype/src/hooks/usePinnedMembers.ts b/extensions/anytype/src/hooks/usePinnedMembers.ts index 722cc4fced5..011b556392f 100644 --- a/extensions/anytype/src/hooks/usePinnedMembers.ts +++ b/extensions/anytype/src/hooks/usePinnedMembers.ts @@ -1,6 +1,6 @@ import { useCachedPromise } from "@raycast/utils"; import { getMember } from "../api"; -import { ErrorWithStatus, getPinned, removePinned } from "../utils"; +import { errorConnectionMessage, ErrorWithStatus, getPinned, removePinned } from "../utils"; export function usePinnedMembers(key: string) { const { data, error, isLoading, mutate } = useCachedPromise( @@ -13,7 +13,9 @@ export function usePinnedMembers(key: string) { return response.member; } catch (error) { const typedError = error as ErrorWithStatus; - if (typedError.status === 404 || typedError.status === 410) { + if (typedError.message === errorConnectionMessage) { + throw error; + } else if (typedError.status === 404 || typedError.status === 410) { await removePinned(pinned.spaceId, pinned.objectId, key); } return null; diff --git a/extensions/anytype/src/hooks/usePinnedObjects.ts b/extensions/anytype/src/hooks/usePinnedObjects.ts index addcdab3e84..a97a5168b79 100644 --- a/extensions/anytype/src/hooks/usePinnedObjects.ts +++ b/extensions/anytype/src/hooks/usePinnedObjects.ts @@ -1,7 +1,7 @@ import { MutatePromise, useCachedPromise } from "@raycast/utils"; import { getObject } from "../api"; -import { Member, SpaceObject, Type } from "../models"; -import { ErrorWithStatus, getPinned, removePinned } from "../utils"; +import { BodyFormat, Member, Property, SpaceObject, Type } from "../models"; +import { errorConnectionMessage, ErrorWithStatus, getPinned, removePinned } from "../utils"; export function usePinnedObjects(key: string) { const { data, error, isLoading, mutate } = useCachedPromise( @@ -10,15 +10,17 @@ export function usePinnedObjects(key: string) { const objects = await Promise.all( pinnedObjects.map(async (pinned) => { try { - const response = await getObject(pinned.spaceId, pinned.objectId); - if (response.object?.archived) { + const response = await getObject(pinned.spaceId, pinned.objectId, BodyFormat.Markdown); + if (response.object.archived) { await removePinned(pinned.spaceId, pinned.objectId, key); return null; } - return response.object; + return response.object as SpaceObject; } catch (error) { const typedError = error as ErrorWithStatus; - if (typedError.status === 404 || typedError.status === 410) { + if (typedError.message === errorConnectionMessage) { + throw error; + } else if (typedError.status === 404 || typedError.status === 410) { await removePinned(pinned.spaceId, pinned.objectId, key); } return null; @@ -37,6 +39,6 @@ export function usePinnedObjects(key: string) { pinnedObjects: data as SpaceObject[], pinnedObjectsError: error, isLoadingPinnedObjects: isLoading, - mutatePinnedObjects: mutate as MutatePromise, + mutatePinnedObjects: mutate as MutatePromise, }; } diff --git a/extensions/anytype/src/hooks/usePinnedProperties.ts b/extensions/anytype/src/hooks/usePinnedProperties.ts new file mode 100644 index 00000000000..18a5c7297ca --- /dev/null +++ b/extensions/anytype/src/hooks/usePinnedProperties.ts @@ -0,0 +1,44 @@ +import { useCachedPromise } from "@raycast/utils"; +import { getProperty } from "../api"; +import { errorConnectionMessage, ErrorWithStatus, getPinned, removePinned } from "../utils"; + +export function usePinnedProperties(key: string) { + const { data, error, isLoading, mutate } = useCachedPromise( + async (key: string) => { + const pinnedProperties = await getPinned(key); + const properties = await Promise.all( + pinnedProperties.map(async (pinned) => { + try { + const response = await getProperty(pinned.spaceId, pinned.objectId); + // TODO: enable this when the API supports it + // if (response.property?.archived) { + // await removePinned(pinned.spaceId, pinned.objectId, key); + // return null; + // } + return response.property; + } catch (error) { + const typedError = error as ErrorWithStatus; + if (typedError.message === errorConnectionMessage) { + throw error; + } else if (typedError.status === 404 || typedError.status === 410) { + await removePinned(pinned.spaceId, pinned.objectId, key); + } + return null; + } + }), + ); + return properties.filter((property) => property !== null); + }, + [key], + { + keepPreviousData: true, + }, + ); + + return { + pinnedProperties: data, + pinnedPropertiesError: error, + isLoadingPinnedProperties: isLoading, + mutatePinnedProperties: mutate, + }; +} diff --git a/extensions/anytype/src/hooks/usePinnedSpaces.ts b/extensions/anytype/src/hooks/usePinnedSpaces.ts index ef0008ec1ec..a3d64329060 100644 --- a/extensions/anytype/src/hooks/usePinnedSpaces.ts +++ b/extensions/anytype/src/hooks/usePinnedSpaces.ts @@ -1,6 +1,6 @@ import { useCachedPromise } from "@raycast/utils"; import { getSpace } from "../api"; -import { ErrorWithStatus, getPinned, localStorageKeys, removePinned } from "../utils"; +import { errorConnectionMessage, ErrorWithStatus, getPinned, localStorageKeys, removePinned } from "../utils"; export function usePinnedSpaces() { const { data, error, isLoading, mutate } = useCachedPromise( @@ -14,7 +14,9 @@ export function usePinnedSpaces() { return response.space; } catch (error) { const typedError = error as ErrorWithStatus; - if (typedError.status === 404 || typedError.status === 410) { + if (typedError.message === errorConnectionMessage) { + throw error; + } else if (typedError.status === 404 || typedError.status === 410) { await removePinned(pinned.spaceId, pinned.objectId, key); } return null; diff --git a/extensions/anytype/src/hooks/usePinnedTypes.ts b/extensions/anytype/src/hooks/usePinnedTypes.ts index 60e6b91fec5..a79df6d8bcb 100644 --- a/extensions/anytype/src/hooks/usePinnedTypes.ts +++ b/extensions/anytype/src/hooks/usePinnedTypes.ts @@ -1,6 +1,6 @@ import { useCachedPromise } from "@raycast/utils"; import { getType } from "../api"; -import { ErrorWithStatus, getPinned, removePinned } from "../utils"; +import { errorConnectionMessage, ErrorWithStatus, getPinned, removePinned } from "../utils"; export function usePinnedTypes(key: string) { const { data, error, isLoading, mutate } = useCachedPromise( @@ -10,14 +10,16 @@ export function usePinnedTypes(key: string) { pinnedTypes.map(async (pinned) => { try { const response = await getType(pinned.spaceId, pinned.objectId); - if (response.type?.archived) { + if (response.type.archived) { await removePinned(pinned.spaceId, pinned.objectId, key); return null; } return response.type; } catch (error) { const typedError = error as ErrorWithStatus; - if (typedError.status === 404 || typedError.status === 410) { + if (typedError.message === errorConnectionMessage) { + throw error; + } else if (typedError.status === 404 || typedError.status === 410) { await removePinned(pinned.spaceId, pinned.objectId, key); } return null; diff --git a/extensions/anytype/src/hooks/useProperties.ts b/extensions/anytype/src/hooks/useProperties.ts new file mode 100644 index 00000000000..8d8306f208a --- /dev/null +++ b/extensions/anytype/src/hooks/useProperties.ts @@ -0,0 +1,34 @@ +import { useCachedPromise } from "@raycast/utils"; +import { useMemo } from "react"; +import { getProperties } from "../api"; +import { apiLimit } from "../utils/constant"; + +export function useProperties(spaceId: string) { + const { data, error, isLoading, mutate, pagination } = useCachedPromise( + (spaceId: string) => async (options: { page: number }) => { + const offset = options.page * apiLimit; + const response = await getProperties(spaceId, { offset, limit: apiLimit }); + + return { + data: response.properties, + hasMore: response.pagination.has_more, + }; + }, + [spaceId], + { + keepPreviousData: true, + execute: !!spaceId, + }, + ); + + // filter empty data to prevent flickering at the bottom + const filteredData = useMemo(() => data?.filter((property) => property) || [], [data]); + + return { + properties: filteredData, + propertiesError: error, + isLoadingProperties: isLoading, + mutateProperties: mutate, + propertiesPagination: pagination, + }; +} diff --git a/extensions/anytype/src/hooks/useSearch.ts b/extensions/anytype/src/hooks/useSearch.ts index 1e3e0b78210..0504773f9e6 100644 --- a/extensions/anytype/src/hooks/useSearch.ts +++ b/extensions/anytype/src/hooks/useSearch.ts @@ -5,7 +5,7 @@ import { search } from "../api"; import { SortDirection } from "../models"; import { apiLimit } from "../utils"; -export function useSearch(spaceId: string, query: string, types: string[]) { +export function useSearch(spaceId: string, query: string, types: string[], config?: { execute?: boolean }) { const { data, error, isLoading, mutate, pagination } = useCachedPromise( (spaceId: string, query: string, types: string[]) => async (options: { page: number }) => { const offset = options.page * apiLimit; @@ -14,7 +14,7 @@ export function useSearch(spaceId: string, query: string, types: string[]) { const response = await search( spaceId, - { query, types, sort: { property: sortPreference, direction: sortDirection } }, + { query, types, sort: { property_key: sortPreference, direction: sortDirection } }, { offset, limit: apiLimit }, ); @@ -26,7 +26,7 @@ export function useSearch(spaceId: string, query: string, types: string[]) { [spaceId, query, types], { keepPreviousData: true, - execute: !!spaceId, + execute: !!spaceId && config?.execute !== false, }, ); diff --git a/extensions/anytype/src/hooks/useTags.ts b/extensions/anytype/src/hooks/useTags.ts new file mode 100644 index 00000000000..ad15d517ace --- /dev/null +++ b/extensions/anytype/src/hooks/useTags.ts @@ -0,0 +1,64 @@ +import { useCachedPromise } from "@raycast/utils"; +import { useMemo } from "react"; +import { getTags } from "../api"; +import { apiLimit } from "../utils"; + +export function useTags(spaceId: string, propertyId: string) { + const { data, error, isLoading, mutate, pagination } = useCachedPromise( + (spaceId: string, propertyId: string) => async (options: { page: number }) => { + const offset = options.page * apiLimit; + const response = await getTags(spaceId, propertyId, { offset, limit: apiLimit }); + + return { + data: response.tags, + hasMore: response.pagination.has_more, + }; + }, + [spaceId, propertyId], + { + keepPreviousData: true, + execute: !!spaceId && !!propertyId, + }, + ); + + // filter empty data to prevent flickering at the bottom + const filteredData = useMemo(() => data?.filter((tag) => tag) || [], [data]); + + return { + tags: filteredData, + tagsError: error, + isLoadingTags: isLoading, + mutateTags: mutate, + tagsPagination: pagination, + }; +} + +export function useTagsMap(spaceId: string, propertyIds: string[]) { + const { data, error, isLoading, mutate } = useCachedPromise( + async (spaceId: string, propertyIds: string[]) => { + const results = await Promise.all( + propertyIds.map(async (propertyId) => { + const response = await getTags(spaceId, propertyId, { offset: 0, limit: apiLimit }); + return { propertyId, tags: response.tags }; + }), + ); + const tagsMap: Record = {}; + results.forEach(({ propertyId, tags }) => { + tagsMap[propertyId] = tags; + }); + return tagsMap; + }, + [spaceId, propertyIds], + { + keepPreviousData: true, + execute: !!spaceId && propertyIds.length > 0, + }, + ); + + return { + tagsMap: data, + tagsError: error, + isLoadingTags: isLoading, + mutateTags: mutate, + }; +} diff --git a/extensions/anytype/src/mappers/members.ts b/extensions/anytype/src/mappers/members.ts index e377842c6c4..d3b9482d774 100644 --- a/extensions/anytype/src/mappers/members.ts +++ b/extensions/anytype/src/mappers/members.ts @@ -1,4 +1,4 @@ -import { Member, RawMember } from "../models"; +import { Member, ObjectLayout, RawMember } from "../models"; import { getIconWithFallback } from "../utils"; /** @@ -20,11 +20,11 @@ export async function mapMembers(members: RawMember[]): Promise { * @returns The display-ready `Member` object. */ export async function mapMember(member: RawMember): Promise { - const icon = await getIconWithFallback(member.icon, "participant"); + const icon = await getIconWithFallback(member.icon, ObjectLayout.Participant); return { ...member, - name: member.name || "Untitled", + name: member.name?.trim() || "Untitled", // empty string comes as \n icon, }; } diff --git a/extensions/anytype/src/mappers/objects.ts b/extensions/anytype/src/mappers/objects.ts index 8c2716fe215..edfdc70e3fa 100644 --- a/extensions/anytype/src/mappers/objects.ts +++ b/extensions/anytype/src/mappers/objects.ts @@ -1,7 +1,17 @@ import { getPreferenceValues } from "@raycast/api"; -import { getObjectWithoutMappedDetails } from "../api"; -import { Property, RawSpaceObject, SortProperty, SpaceObject } from "../models"; -import { colorMap, getIconWithFallback } from "../utils"; +import { getObjectWithoutMappedProperties } from "../api"; +import { + BodyFormat, + PropertyFormat, + PropertyWithValue, + RawSpaceObject, + RawSpaceObjectWithBody, + SortProperty, + SpaceObject, + SpaceObjectWithBody, +} from "../models"; +import { bundledPropKeys, getIconWithFallback } from "../utils"; +import { mapTag } from "./properties"; import { mapType } from "./types"; /** @@ -18,13 +28,15 @@ export async function mapObjects(objects: RawSpaceObject[]): Promise { - if (sort === SortProperty.Name) { - // When sorting by name, keep the 'LastModifiedDate' property for tooltip purposes - return property.id === SortProperty.LastModifiedDate; - } - return property.id === sort; - }), + properties: await Promise.all( + (object.properties?.filter((property) => { + if (sort === SortProperty.Name) { + // When sorting by name, keep the 'LastModifiedDate' property for tooltip purposes + return property.key === SortProperty.LastModifiedDate; + } + return property.key === sort || property.key === bundledPropKeys.source; // keep source to open bookmarks in browser + }) || []) as PropertyWithValue[], + ), }; }), ); @@ -33,102 +45,97 @@ export async function mapObjects(objects: RawSpaceObject[]): Promise { +export async function mapObject( + object: RawSpaceObject | RawSpaceObjectWithBody, +): Promise { const icon = await getIconWithFallback(object.icon, object.layout, object.type); const mappedProperties = await Promise.all( (object.properties || []).map(async (property) => { - let mappedProperty: Property = { + let mappedProperty: PropertyWithValue = { id: property.id, - name: property.name, + key: property.key, + name: property.name || "Untitled", format: property.format, }; switch (property.format) { - case "text": + case PropertyFormat.Text: mappedProperty = { ...mappedProperty, text: typeof property.text === "string" ? property.text.trim() : "", }; break; - case "number": + case PropertyFormat.Number: mappedProperty = { ...mappedProperty, number: property.number !== undefined && property.number !== null ? property.number : 0, }; break; - case "select": + case PropertyFormat.Select: if (property.select) { mappedProperty = { ...mappedProperty, - select: { - id: property.select.id || "", - name: property.select.name || "", - color: colorMap[property.select.color] || property.select.color, - }, + select: mapTag(property.select), }; } break; - case "multi_select": + case PropertyFormat.MultiSelect: if (property.multi_select) { mappedProperty = { ...mappedProperty, - multi_select: property.multi_select.map((tag) => ({ - id: tag.id || "", - name: tag.name || "", - color: colorMap[tag.color] || tag.color, - })), + multi_select: property.multi_select.map((tag) => mapTag(tag)), }; } break; - case "date": + case PropertyFormat.Date: mappedProperty = { ...mappedProperty, date: property.date ? new Date(property.date).toISOString() : "", }; break; - case "file": - if (property.file) { + case PropertyFormat.Files: + if (property.files) { mappedProperty = { ...mappedProperty, - file: await mapObjectWithoutDetails(object.space_id, property.file), + files: await mapObjectWithoutProperties(object.space_id, property.files), }; } break; - case "checkbox": + case PropertyFormat.Checkbox: mappedProperty = { ...mappedProperty, checkbox: property.checkbox || false, }; break; - case "url": + case PropertyFormat.Url: mappedProperty = { ...mappedProperty, url: typeof property.url === "string" ? property.url.trim() : "", }; break; - case "email": + case PropertyFormat.Email: mappedProperty = { ...mappedProperty, email: typeof property.email === "string" ? property.email.trim() : "", }; break; - case "phone": + case PropertyFormat.Phone: mappedProperty = { ...mappedProperty, phone: typeof property.phone === "string" ? property.phone.trim() : "", }; break; - case "object": - if (property.object) { + case PropertyFormat.Objects: + if (property.objects) { mappedProperty = { ...mappedProperty, - object: await mapObjectWithoutDetails(object.space_id, property.object), + objects: await mapObjectWithoutProperties(object.space_id, property.objects), }; } break; default: - console.warn(`Unknown property format: ${property.format}`); + console.warn(`Unknown property format: '${property.format}' for property '${property.key}'`); } return mappedProperty; @@ -138,22 +145,18 @@ export async function mapObject(object: RawSpaceObject): Promise { return { ...object, icon, - name: object.name || `${object.snippet.split("\n")[0]}...` || "Untitled", + name: object.name?.trim() || `${object.snippet.split("\n")[0]}...` || "Untitled", type: await mapType(object.type), properties: mappedProperties, }; } -export async function mapObjectWithoutDetails(spaceId: string, object: SpaceObject[]): Promise { +export async function mapObjectWithoutProperties(spaceId: string, object: string[]): Promise { const rawItems = Array.isArray(object) ? object : [object]; return await Promise.all( rawItems.map(async (item) => { if (typeof item === "string") { - const fetched = await getObjectWithoutMappedDetails(spaceId, item); - if (!fetched) { - throw new Error(`getRawObject returned null for item ${item}`); - } - return fetched; + return await getObjectWithoutMappedProperties(spaceId, item, BodyFormat.Markdown); } else { return item; } diff --git a/extensions/anytype/src/mappers/properties.ts b/extensions/anytype/src/mappers/properties.ts new file mode 100644 index 00000000000..0c8114e2a96 --- /dev/null +++ b/extensions/anytype/src/mappers/properties.ts @@ -0,0 +1,59 @@ +import { Image } from "@raycast/api"; +import { Property, PropertyFormat, RawProperty, RawTag, Tag } from "../models"; +import { colorToHex } from "../utils"; + +export function mapProperties(properties: RawProperty[]): Property[] { + return properties.map((property) => { + return mapProperty(property); + }); +} + +export function mapProperty(property: RawProperty): Property { + return { + ...property, + name: property.name?.trim() || "Untitled", + icon: getIconForProperty(property.format), + }; +} + +export function getIconForProperty(format: PropertyFormat): Image.ImageLike { + const tintColor = { light: "grey", dark: "grey" }; + switch (format) { + case PropertyFormat.Text: + return { source: "icons/property/text.svg", tintColor: tintColor }; + case PropertyFormat.Number: + return { source: "icons/property/number.svg", tintColor: tintColor }; + case PropertyFormat.Select: + return { source: "icons/property/select.svg", tintColor: tintColor }; + case PropertyFormat.MultiSelect: + return { source: "icons/property/multi_select.svg", tintColor: tintColor }; + case PropertyFormat.Date: + return { source: "icons/property/date.svg", tintColor: tintColor }; + case PropertyFormat.Files: + return { source: "icons/property/files.svg", tintColor: tintColor }; + case PropertyFormat.Checkbox: + return { source: "icons/property/checkbox.svg", tintColor: tintColor }; + case PropertyFormat.Url: + return { source: "icons/property/url.svg", tintColor: tintColor }; + case PropertyFormat.Email: + return { source: "icons/property/email.svg", tintColor: tintColor }; + case PropertyFormat.Phone: + return { source: "icons/property/phone.svg", tintColor: tintColor }; + case PropertyFormat.Objects: + return { source: "icons/property/objects.svg", tintColor: tintColor }; + } +} + +export function mapTags(tags: RawTag[]): Tag[] { + return tags.map((tag) => { + return mapTag(tag); + }); +} + +export function mapTag(tag: RawTag): Tag { + return { + ...tag, + name: tag.name?.trim() || "Untitled", + color: colorToHex[tag.color] || tag.color, + }; +} diff --git a/extensions/anytype/src/mappers/spaces.ts b/extensions/anytype/src/mappers/spaces.ts index dd6cd2e8ab0..8713a4f61fd 100644 --- a/extensions/anytype/src/mappers/spaces.ts +++ b/extensions/anytype/src/mappers/spaces.ts @@ -24,7 +24,7 @@ export async function mapSpace(space: RawSpace): Promise { return { ...space, - name: space.name || "Untitled", + name: space.name?.trim() || "Untitled", icon, }; } diff --git a/extensions/anytype/src/mappers/templates.ts b/extensions/anytype/src/mappers/templates.ts deleted file mode 100644 index 0325770ae9f..00000000000 --- a/extensions/anytype/src/mappers/templates.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { RawTemplate, Template } from "../models"; -import { getIconWithFallback } from "../utils"; - -/** - * Map raw `Template` objects from the API into display-ready data (e.g., icon). - */ -export async function mapTemplates(templates: RawTemplate[]): Promise { - return Promise.all( - templates.map(async (template) => { - return mapTemplate(template); - }), - ); -} - -/** - * Map raw `Template` object from the API into display-ready data (e.g., icon). - */ -export async function mapTemplate(template: RawTemplate): Promise