From 936ba724e73f0db1ce21130313b5b24f35a858fe Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Wed, 7 May 2025 00:56:13 +0200 Subject: [PATCH 1/3] Update anytype extension - Fix sort.propertyKey in ai eval mocks - Improve icon replacement with file/built-in vs emoji - Optimize performance by unsetting markdown for prop reference objects - Rename mapObjectWithoutDetails to mapObjectWithoutProperties - Deprecate separate export, move markdown body to getObject - Deprecate separate export, move markdown body to getObject - Update extension to version v3_0425 - Refactor TagList actions to group Create Tag and Refresh Tags - Filter out the current object from the dropdown list in UpdateObjectForm - Update member icon to reflect pluralization in SpaceList and ObjectList - Refactor update functions to remove null checks and simplify return types - Fix updateType call to use \'id\' instead of \'key\' for type - Add mutate refresh after edit from ObjectDetail - Update member icon in SpaceList to person instead of person-circle - Replace popToRoot() with pop() and mutate to refresh - Refactor create form context values to use \'Id\' suffix for space, type, and list - Introduce oneOf for ObjectIcon and refactor property handling in forms - Add \'key\' prop to Tag interface - Refactor type keys to use bundled constants and remove \'ot-\' prefix - Enable delete enpoint for types - Refactor member and object API functions to remove null handling and ensure consistent return types - Add update functionality for types and improve form placeholders - Restrict create action visibility based on viewtype - Add CRUD operations for types, create form and empty view - Merge pull request #4 from anyproto/api-stage-2 - Pass searchText props and dd create actions for objects and properties - Add create new as space action - Fix null return for pinned items - Exclude bundled properties from create form - Add update space functionality with form and api - Support opening bookmarks as primary action - Move source to properties CreateObjectRequest - Rename sort property to property_key in search hooks and models - Fix pinned items temporarily dissappearing for not reachable api - Fix lint - Fix uncontrolled controlled prop switch - Rework property unsetting in form and hide bundled properties - Fix unsetting of boolean, string and object references - Show update properties based on type - Rename "Created Date" to "Creation Date" for clear name - Refactor object forms and field validation - Rename space component and reorganize imports into Lists directory - Refactor shortcuts to common edit and new actions - Show list view icon only if no emoji already present - Fix typo - Use optional chaining for trim - Refactor property models to use RawProperty type across API functions and mappers - Optimize update object and refactor property models - Trim whitespace from names in mappers - Fix update form implementation - Refactor create form and fieldvalue - Add missing unique keys to form components in CreateObjectForm - Remove defaultValue from ObjectCreateForm and fix custom props for quicklink - Update icons for no-selection state in create object form - Fix layout based action checks - Refactor TypeLayout and ObjectLayout enums - Add initial UpdateObjectForm - Refactor CreateObjectForm to simplify property handling and validation - Fix checbox icon source boolean state - Refactor CreateObjectForm to use PropertyEntry for properties; update models and create-object tool to support new structure - Add updateObject api and integrate with ObjectActions for editing objects - Deprecate template data model, treat as object - Refactor space change handling to inline - Add UpdatePropertyForm for editing properties; refactor ObjectActions props - Refactor color handling in tag components; add UpdateTagForm for editing tags - Move states for CreateObjectForm to fix missing re-renders from EmptyViewObject - Clear form states on space change to prevent inconsistent API calls - Refactor ObjectActions and SpaceActions components; add tag and property deletion to actions - Fix tag and property dropdown - Fix lint for properties - Add tag management functionality: create, update, delete - Add number field validations to CreateObjectForm - Show "No objects" label for empty objects properties - Rename property formats from File/Object to Files/Objects - Switch property lookup to id, remove api prefixes - Show Add to List action only for objects - Add TagList action to ObjectActions - Add TagList component - Fix properties pagination bug - Fix search execution condition to execute when config is undefined - Add property management API, form and view - Move multi_select.svg - Increase default api limit to 100 - Refactor property format checks to use PropertyFormat enum - Add ListSubmenu component for adding objects to lists - Refactor getCustomTypeIcon to remove unnecessary async/await - Smoothen form animation for add-to-list - Filter objects for add-to-list command ot avoid duplication - Remove detail view for properties - Refactor error handling to use showFailureToast - Update tag api endpoints - Support object properties when creating object - Pop back to object list when deleting object from detail view - Update emoji validation logic - Update CI workflow to trigger on all branches - Set property icon tint color to grey - Fix search bar placeholder in browse - Enhance pluralization logic for nouns ending in \'y\' - Add property model, API endpoints, and hooks for managing properties - Refactor API key constants - Enhance ObjectList component with system type indication and update API key prefixes - Allow string for tag and icon color to map hex value - Add dynamic properties support to CreateObjectForm - Merge branch \'main\' into api-stage-2 - Refactor tag mapping logic - Add execute condition to useObjects hook - Add Color enum and update ObjectIcon and Tag interfaces to use Color type - Add tag management functionality and update form elements when creating object - Merge branch \'main\' into api-stage-2 - Merge branch \'command-add-obj-to-list\' into api-stage-2 - Rely on property key instead of id - Update property and type models - Add form validation for \'Add to List\' command - Create new \'Add to List\' command --- .../anytype/assets/icons/object/action.svg | 4 + .../anytype/assets/icons/object/basic.svg | 5 + .../anytype/assets/icons/object/note.svg | 6 + .../anytype/assets/icons/object/profile.svg | 5 + .../assets/icons/property/checkbox.svg | 4 + .../anytype/assets/icons/property/files.svg | 9 + .../assets/icons/property/multi_select.svg | 7 + .../anytype/assets/icons/property/objects.svg | 4 + .../anytype/assets/icons/property/tag.svg | 4 + extensions/anytype/package-lock.json | 34 +- extensions/anytype/package.json | 44 +- extensions/anytype/src/add-to-list.tsx | 148 +++++ .../anytype/src/api/auth/validateToken.ts | 20 +- extensions/anytype/src/api/index.ts | 16 +- .../anytype/src/api/lists/addObjectsToList.ts | 9 +- .../anytype/src/api/members/getMember.ts | 11 +- .../anytype/src/api/members/updateMember.ts | 8 +- .../anytype/src/api/objects/deleteObject.ts | 9 +- .../anytype/src/api/objects/getObject.ts | 42 +- .../anytype/src/api/objects/updateObject.ts | 18 + .../src/api/properties/createProperty.ts | 19 + .../src/api/properties/deleteProperty.ts | 13 + .../src/api/properties/getProperties.ts | 19 + .../anytype/src/api/properties/getProperty.ts | 11 + .../src/api/properties/updateProperty.ts | 18 + extensions/anytype/src/api/spaces/getSpace.ts | 8 +- .../anytype/src/api/spaces/updateSpace.ts | 14 + extensions/anytype/src/api/tags/createTag.ts | 20 + extensions/anytype/src/api/tags/deleteTag.ts | 13 + extensions/anytype/src/api/tags/getTag.ts | 11 + extensions/anytype/src/api/tags/getTags.ts | 20 + extensions/anytype/src/api/tags/updateTag.ts | 19 + .../anytype/src/api/templates/getTemplates.ts | 10 +- .../anytype/src/api/types/createType.ts | 16 + .../anytype/src/api/types/deleteType.ts | 13 + extensions/anytype/src/api/types/getType.ts | 17 +- .../anytype/src/api/types/updateType.ts | 14 + extensions/anytype/src/browse-spaces.tsx | 135 +---- .../src/components/Actions/ListSubmenu.tsx | 44 ++ .../src/components/Actions/ObjectActions.tsx | 529 ++++++++++++++++++ .../src/components/Actions/SpaceActions.tsx | 137 +++++ .../CreateForm/CreateObjectForm.tsx | 519 +++++++++++++++++ .../CreateForm/CreatePropertyForm.tsx | 66 +++ .../components/CreateForm/CreateSpaceForm.tsx | 58 ++ .../components/CreateForm/CreateTagForm.tsx | 77 +++ .../components/CreateForm/CreateTypeForm.tsx | 102 ++++ .../components/EmptyView/EmptyViewObject.tsx | 37 ++ .../EmptyView/EmptyViewProperty.tsx | 26 + .../components/EmptyView/EmptyViewSpace.tsx | 21 + .../src/components/EmptyView/EmptyViewTag.tsx | 27 + .../components/EmptyView/EmptyViewType.tsx | 21 + .../src/components/EnsureAuthenticated.tsx | 11 +- .../src/components/Lists/CollectionList.tsx | 117 ++++ .../src/components/Lists/SpaceList.tsx | 135 +++++ .../anytype/src/components/Lists/TagList.tsx | 101 ++++ .../src/components/Lists/TemplateList.tsx | 127 +++++ .../anytype/src/components/ObjectDetail.tsx | 165 +++--- .../anytype/src/components/ObjectList.tsx | 171 ++++-- .../anytype/src/components/ObjectListItem.tsx | 15 +- .../anytype/src/components/SpaceListItem.tsx | 5 +- .../UpdateForm/UpdateObjectForm.tsx | 374 +++++++++++++ .../UpdateForm/UpdatePropertyForm.tsx | 75 +++ .../components/UpdateForm/UpdateSpaceForm.tsx | 66 +++ .../components/UpdateForm/UpdateTagForm.tsx | 79 +++ .../components/UpdateForm/UpdateTypeForm.tsx | 119 ++++ extensions/anytype/src/components/index.ts | 30 +- extensions/anytype/src/create-object.tsx | 62 +- extensions/anytype/src/hooks/index.ts | 4 +- .../anytype/src/hooks/useCreateObjectData.ts | 73 +-- .../anytype/src/hooks/useGlobalSearch.ts | 2 +- extensions/anytype/src/hooks/useObject.ts | 5 +- extensions/anytype/src/hooks/useObjects.ts | 1 + .../anytype/src/hooks/usePinnedMembers.ts | 6 +- .../anytype/src/hooks/usePinnedObjects.ts | 16 +- .../anytype/src/hooks/usePinnedProperties.ts | 44 ++ .../anytype/src/hooks/usePinnedSpaces.ts | 6 +- .../anytype/src/hooks/usePinnedTypes.ts | 8 +- extensions/anytype/src/hooks/useProperties.ts | 34 ++ extensions/anytype/src/hooks/useSearch.ts | 6 +- extensions/anytype/src/hooks/useTags.ts | 64 +++ extensions/anytype/src/mappers/members.ts | 6 +- extensions/anytype/src/mappers/objects.ts | 95 ++-- extensions/anytype/src/mappers/properties.ts | 59 ++ extensions/anytype/src/mappers/spaces.ts | 2 +- extensions/anytype/src/mappers/types.ts | 1 + extensions/anytype/src/models/icon.ts | 35 +- extensions/anytype/src/models/index.ts | 4 +- extensions/anytype/src/models/object.ts | 66 ++- extensions/anytype/src/models/property.ts | 86 +++ extensions/anytype/src/models/search.ts | 2 +- extensions/anytype/src/models/space.ts | 5 + extensions/anytype/src/models/tag.ts | 22 + extensions/anytype/src/models/type.ts | 40 +- extensions/anytype/src/search-anytype.tsx | 19 +- extensions/anytype/src/tools/add-to-list.ts | 2 +- .../anytype/src/tools/approve-member.ts | 4 +- extensions/anytype/src/tools/create-object.ts | 32 +- .../anytype/src/tools/get-list-items.ts | 2 +- extensions/anytype/src/tools/get-object.ts | 21 +- extensions/anytype/src/tools/get-types.ts | 4 +- extensions/anytype/src/tools/reject-member.ts | 4 +- .../anytype/src/tools/remove-from-list.ts | 2 +- extensions/anytype/src/tools/remove-member.ts | 4 +- .../anytype/src/tools/search-anytype.ts | 10 +- extensions/anytype/src/tools/search-space.ts | 10 +- extensions/anytype/src/tools/update-member.ts | 4 +- extensions/anytype/src/utils/constant.ts | 149 ++++- extensions/anytype/src/utils/form.ts | 23 + extensions/anytype/src/utils/icon.ts | 52 +- extensions/anytype/src/utils/index.ts | 1 + extensions/anytype/src/utils/object.ts | 14 +- extensions/anytype/src/utils/string.ts | 25 +- extensions/anytype/src/utils/type.ts | 81 +-- 113 files changed, 4554 insertions(+), 714 deletions(-) create mode 100644 extensions/anytype/assets/icons/object/action.svg create mode 100644 extensions/anytype/assets/icons/object/basic.svg create mode 100644 extensions/anytype/assets/icons/object/note.svg create mode 100644 extensions/anytype/assets/icons/object/profile.svg create mode 100644 extensions/anytype/assets/icons/property/checkbox.svg create mode 100644 extensions/anytype/assets/icons/property/files.svg create mode 100644 extensions/anytype/assets/icons/property/multi_select.svg create mode 100644 extensions/anytype/assets/icons/property/objects.svg create mode 100644 extensions/anytype/assets/icons/property/tag.svg create mode 100644 extensions/anytype/src/add-to-list.tsx create mode 100644 extensions/anytype/src/api/objects/updateObject.ts create mode 100644 extensions/anytype/src/api/properties/createProperty.ts create mode 100644 extensions/anytype/src/api/properties/deleteProperty.ts create mode 100644 extensions/anytype/src/api/properties/getProperties.ts create mode 100644 extensions/anytype/src/api/properties/getProperty.ts create mode 100644 extensions/anytype/src/api/properties/updateProperty.ts create mode 100644 extensions/anytype/src/api/spaces/updateSpace.ts create mode 100644 extensions/anytype/src/api/tags/createTag.ts create mode 100644 extensions/anytype/src/api/tags/deleteTag.ts create mode 100644 extensions/anytype/src/api/tags/getTag.ts create mode 100644 extensions/anytype/src/api/tags/getTags.ts create mode 100644 extensions/anytype/src/api/tags/updateTag.ts create mode 100644 extensions/anytype/src/api/types/createType.ts create mode 100644 extensions/anytype/src/api/types/deleteType.ts create mode 100644 extensions/anytype/src/api/types/updateType.ts create mode 100644 extensions/anytype/src/components/Actions/ListSubmenu.tsx create mode 100644 extensions/anytype/src/components/Actions/ObjectActions.tsx create mode 100644 extensions/anytype/src/components/Actions/SpaceActions.tsx create mode 100644 extensions/anytype/src/components/CreateForm/CreateObjectForm.tsx create mode 100644 extensions/anytype/src/components/CreateForm/CreatePropertyForm.tsx create mode 100644 extensions/anytype/src/components/CreateForm/CreateSpaceForm.tsx create mode 100644 extensions/anytype/src/components/CreateForm/CreateTagForm.tsx create mode 100644 extensions/anytype/src/components/CreateForm/CreateTypeForm.tsx create mode 100644 extensions/anytype/src/components/EmptyView/EmptyViewObject.tsx create mode 100644 extensions/anytype/src/components/EmptyView/EmptyViewProperty.tsx create mode 100644 extensions/anytype/src/components/EmptyView/EmptyViewSpace.tsx create mode 100644 extensions/anytype/src/components/EmptyView/EmptyViewTag.tsx create mode 100644 extensions/anytype/src/components/EmptyView/EmptyViewType.tsx create mode 100644 extensions/anytype/src/components/Lists/CollectionList.tsx create mode 100644 extensions/anytype/src/components/Lists/SpaceList.tsx create mode 100644 extensions/anytype/src/components/Lists/TagList.tsx create mode 100644 extensions/anytype/src/components/Lists/TemplateList.tsx create mode 100644 extensions/anytype/src/components/UpdateForm/UpdateObjectForm.tsx create mode 100644 extensions/anytype/src/components/UpdateForm/UpdatePropertyForm.tsx create mode 100644 extensions/anytype/src/components/UpdateForm/UpdateSpaceForm.tsx create mode 100644 extensions/anytype/src/components/UpdateForm/UpdateTagForm.tsx create mode 100644 extensions/anytype/src/components/UpdateForm/UpdateTypeForm.tsx create mode 100644 extensions/anytype/src/hooks/usePinnedProperties.ts create mode 100644 extensions/anytype/src/hooks/useProperties.ts create mode 100644 extensions/anytype/src/hooks/useTags.ts create mode 100644 extensions/anytype/src/mappers/properties.ts create mode 100644 extensions/anytype/src/models/property.ts create mode 100644 extensions/anytype/src/models/tag.ts create mode 100644 extensions/anytype/src/utils/form.ts 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/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/Actions/ObjectActions.tsx b/extensions/anytype/src/components/Actions/ObjectActions.tsx new file mode 100644 index 00000000000..8b5d300b627 --- /dev/null +++ b/extensions/anytype/src/components/Actions/ObjectActions.tsx @@ -0,0 +1,529 @@ +import { + Action, + ActionPanel, + Clipboard, + Color, + confirmAlert, + getPreferenceValues, + Icon, + Keyboard, + open, + showToast, + Toast, + useNavigation, +} from "@raycast/api"; +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, +} from "../../utils"; + +type ObjectActionsProps = { + space: Space; + objectId: string; + title: string; + mutate?: MutatePromise[]; + mutateTemplates?: MutatePromise; + mutateObject?: MutatePromise; + mutateViews?: MutatePromise; + 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({ + space, + objectId, + title, + mutate, + mutateTemplates, + mutateObject, + 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 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() { + await Clipboard.copy(objectUrl); + await showToast({ + title: "Link copied", + message: `The ${getContextLabel().toLowerCase()} link has been copied to your clipboard.`, + style: Toast.Style.Success, + }); + } + + async function handleDeleteObject() { + const confirm = await confirmAlert({ + title: `Delete ${getContextLabel()}`, + message: `Are you sure you want to delete "${title}"?`, + icon: { source: Icon.Trash, tintColor: Color.Red }, + }); + + if (confirm) { + if (isDetailView) { + pop(); // pop back to list view + } + try { + 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(); + } + } + if (mutateTemplates) { + await mutateTemplates(); + } + if (mutateObject) { + await mutateObject(); + } + if (mutateViews) { + await mutateViews(); + } + await showToast({ + style: Toast.Style.Success, + title: `${getContextLabel()} deleted`, + message: `"${title}" has been deleted.`, + }); + } catch (error) { + await showFailureToast(error, { title: `Failed to delete ${getContextLabel()}` }); + } + } + } + + async function handleMoveUpInFavorites() { + await moveUpInPinned(space.id, objectId, pinSuffixForView); + if (mutate) { + for (const m of mutate) { + await m(); + } + } + await showToast({ + style: Toast.Style.Success, + title: "Moved Up in Pinned", + }); + } + + async function handleMoveDownInFavorites() { + await moveDownInPinned(space.id, objectId, pinSuffixForView); + if (mutate) { + for (const m of mutate) { + await m(); + } + } + + await showToast({ + style: Toast.Style.Success, + title: "Moved Down in Pinned", + }); + } + + async function handlePin() { + if (isPinned) { + await removePinned(space.id, objectId, pinSuffixForView, title, getContextLabel()); + } else { + await addPinned(space.id, objectId, pinSuffixForView, title, getContextLabel()); + } + if (mutate) { + for (const m of mutate) { + await m(); + } + } + } + + async function handleRefresh() { + const label = getContextLabel(false); + await showToast({ + style: Toast.Style.Animated, + title: `Refreshing ${label}...`, + }); + try { + if (mutate) { + for (const m of mutate) { + await m(); + } + } + if (mutateTemplates) { + await mutateTemplates(); + } + if (mutateObject) { + await mutateObject(); + } + + await showToast({ + style: Toast.Style.Success, + title: `${label} refreshed`, + }); + } catch (error) { + await showFailureToast(error, { title: `Failed to refresh ${label}` }); + } + } + + //! Member management not enabled yet + // async function handleApproveMember(identity: string, name: string, role: UpdateMemberRole) { + // try { + // await updateMember(space.id, identity, { status: MemberStatus.Active, role }); + // if (mutate) { + // for (const m of mutate) { + // await m(); + // } + // } + // await showToast({ + // style: Toast.Style.Success, + // title: `Member approved`, + // message: `${name} has been approved as ${formatMemberRole(role)}.`, + // }); + // } catch (error) { + // await showFailureToast(error, { title: `Failed to approve member` }); + // } + // } + + // async function handleRejectMember(identity: string, name: string, spaceName: string) { + // const confirm = await confirmAlert({ + // title: `Reject Member`, + // message: `Are you sure you want to reject ${name} from ${spaceName}?`, + // icon: { source: Icon.XMarkCircleHalfDash, tintColor: Color.Red }, + // }); + + // if (confirm) { + // try { + // await updateMember(space.id, identity, { status: MemberStatus.Declined }); + // if (mutate) { + // for (const m of mutate) { + // await m(); + // } + // } + // await showToast({ + // style: Toast.Style.Success, + // title: "Member rejected", + // message: `${name} has been rejected.`, + // }); + // } catch (error) { + // await showFailureToast(error, { title: `Failed to reject member` }); + // } + // } + // } + + // async function handleRemoveMember(identity: string, memberName: string, spaceName: string) { + // const confirm = await confirmAlert({ + // title: `Remove Member`, + // message: `Are you sure you want to remove ${memberName} from ${spaceName}?`, + // icon: { source: Icon.RemovePerson, tintColor: Color.Red }, + // }); + + // if (confirm) { + // try { + // await updateMember(space.id, identity, { status: MemberStatus.Removed }); + // if (mutate) { + // for (const m of mutate) { + // await m(); + // } + // } + // await showToast({ + // style: Toast.Style.Success, + // title: "Member removed", + // message: `${memberName} has been removed from ${spaceName}.`, + // }); + // } catch (error) { + // await showFailureToast(error, { title: `Failed to remove member` }); + // } + // } + // } + + // async function handleChangeMemberRole(identity: string, name: string, role: UpdateMemberRole) { + // try { + // await updateMember(space.id, identity, { status: MemberStatus.Active, role }); + // if (mutate) { + // for (const m of mutate) { + // await m(); + // } + // } + // await showToast({ + // style: Toast.Style.Success, + // title: `Role changed`, + // message: `${name} has been changed to ${formatMemberRole(role)}.`, + // }); + // } catch (error) { + // await showFailureToast(error, { title: `Failed to change member role` }); + // } + // } + + const canShowDetails = !isType && !isProperty && !isList && !isBookmark && !isDetailView; + const showDetailsAction = canShowDetails && ( + + } + /> + ); + + const openObjectAction = ( + + ); + + const firstPrimaryAction = primaryAction === "show_details" ? showDetailsAction : openObjectAction; + const secondPrimaryAction = primaryAction === "show_details" ? openObjectAction : showDetailsAction; + + return ( + + + {firstPrimaryAction} + {isList && ( + } + /> + )} + {isType && ( + + } + /> + )} + {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} + + + + {!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 && } + + {!isDetailView && !isNoPinView && ( + <> + + {isPinned && ( + <> + + + + )} + + )} + {!isMember && ( + + )} + + + + {!isMember && ( + { + if (isType) { + push(); + } else if (isProperty) { + push(); + } else { + push(); + } + }} + /> + )} + {isDetailView && ( + + )} + + + + ); +} diff --git a/extensions/anytype/src/components/Actions/SpaceActions.tsx b/extensions/anytype/src/components/Actions/SpaceActions.tsx new file mode 100644 index 00000000000..193cf66a4b7 --- /dev/null +++ b/extensions/anytype/src/components/Actions/SpaceActions.tsx @@ -0,0 +1,137 @@ +import { Action, ActionPanel, Clipboard, Icon, Keyboard, showToast, Toast } from "@raycast/api"; +import { MutatePromise, showFailureToast } from "@raycast/utils"; +import { CreateSpaceForm, ObjectList, UpdateSpaceForm } from ".."; +import { Space } from "../../models"; +import { + addPinned, + anytypeSpaceDeeplink, + localStorageKeys, + moveDownInPinned, + moveUpInPinned, + removePinned, +} from "../../utils"; + +type SpaceActionsProps = { + space: Space; + mutate: MutatePromise[]; + isPinned: boolean; + searchText: string; +}; + +export function SpaceActions({ space, mutate, isPinned, searchText }: SpaceActionsProps) { + const spaceDeeplink = anytypeSpaceDeeplink(space.id); + const pinSuffix = localStorageKeys.suffixForSpaces; + + async function handleCopyLink() { + await Clipboard.copy(spaceDeeplink); + await showToast({ + title: "Link copied", + message: "The space link has been copied to your clipboard", + style: Toast.Style.Success, + }); + } + + async function handleMoveUpInFavorites() { + await moveUpInPinned(space.id, space.id, pinSuffix); + await Promise.all(mutate.map((mutateFunc) => mutateFunc())); + await showToast({ + style: Toast.Style.Success, + title: "Moved Up in Pinned", + }); + } + + async function handleMoveDownInFavorites() { + await moveDownInPinned(space.id, space.id, pinSuffix); + await Promise.all(mutate.map((mutateFunc) => mutateFunc())); + await showToast({ + style: Toast.Style.Success, + title: "Moved Down in Pinned", + }); + } + + async function handlePin() { + if (isPinned) { + await removePinned(space.id, space.id, pinSuffix, space.name, "Space"); + } else { + await addPinned(space.id, space.id, pinSuffix, space.name, "Space"); + } + await Promise.all(mutate.map((mutateFunc) => mutateFunc())); + } + + async function handleRefresh() { + await showToast({ style: Toast.Style.Animated, title: "Refreshing spaces" }); + if (mutate) { + try { + await Promise.all(mutate.map((mutateFunc) => mutateFunc())); + await showToast({ style: Toast.Style.Success, title: "Spaces refreshed" }); + } catch (error) { + await showFailureToast(error, { title: "Failed to refresh spaces" }); + } + } + } + + return ( + + + } /> + + + + } + /> + + + + + {isPinned && ( + <> + + + + )} + + + } + /> + + + + ); +} diff --git a/extensions/anytype/src/components/CreateForm/CreateObjectForm.tsx b/extensions/anytype/src/components/CreateForm/CreateObjectForm.tsx new file mode 100644 index 00000000000..05d84d6010e --- /dev/null +++ b/extensions/anytype/src/components/CreateForm/CreateObjectForm.tsx @@ -0,0 +1,519 @@ +import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { formatRFC3339 } from "date-fns"; +import { useEffect, useMemo, useState } from "react"; +import { addObjectsToList, createObject } from "../../api"; +import { CreateObjectFormValues } from "../../create-object"; +import { useCreateObjectData, useTagsMap } from "../../hooks"; +import { + CreateObjectRequest, + IconFormat, + PropertyFieldValue, + PropertyFormat, + PropertyLinkWithValue, +} from "../../models"; +import { + bundledPropKeys, + bundledTypeKeys, + defaultTintColor, + fetchTypeKeysForLists, + getNumberFieldValidations, + isEmoji, +} from "../../utils"; + +interface CreateObjectFormProps { + draftValues: CreateObjectFormValues; + enableDrafts: boolean; +} + +export function CreateObjectForm({ draftValues, enableDrafts }: CreateObjectFormProps) { + const { + spaces, + types, + templates, + lists, + objects, + selectedSpaceId, + setSelectedSpaceId, + selectedTypeId, + setSelectedTypeId, + selectedTemplateId, + setSelectedTemplateId, + selectedListId, + setSelectedListId, + listSearchText, + setListSearchText, + objectSearchText, + setObjectSearchText, + isLoading, + } = useCreateObjectData(draftValues); + + const [loading, setLoading] = useState(false); + const [typeKeysForLists, setTypeKeysForLists] = useState([]); + + 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/CreateForm/CreateSpaceForm.tsx b/extensions/anytype/src/components/CreateForm/CreateSpaceForm.tsx new file mode 100644 index 00000000000..f196484f297 --- /dev/null +++ b/extensions/anytype/src/components/CreateForm/CreateSpaceForm.tsx @@ -0,0 +1,58 @@ +import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; +import { showFailureToast, useForm } from "@raycast/utils"; +import { createSpace } from "../../api"; + +export interface CreateSpaceFormValues { + name?: string; + description?: string; +} +interface CreateSpaceFormProps { + draftValues: CreateSpaceFormValues; +} + +export function CreateSpaceForm({ draftValues }: CreateSpaceFormProps) { + const { handleSubmit, itemProps } = useForm({ + initialValues: draftValues, + onSubmit: async (values) => { + try { + await showToast({ style: Toast.Style.Animated, title: "Creating space..." }); + + await createSpace({ + name: values.name || "", + description: values.description || "", + }); + + showToast(Toast.Style.Success, "Space created successfully"); + popToRoot(); + } catch (error) { + await showFailureToast(error, { title: "Failed to create space" }); + } + }, + validation: { + name: (value) => { + if (!value) { + return "Name is required"; + } + }, + }, + }); + + return ( +
+ + + } + > + + + + ); +} 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/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/EmptyView/EmptyViewSpace.tsx b/extensions/anytype/src/components/EmptyView/EmptyViewSpace.tsx new file mode 100644 index 00000000000..ffa202f4836 --- /dev/null +++ b/extensions/anytype/src/components/EmptyView/EmptyViewSpace.tsx @@ -0,0 +1,21 @@ +import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { CreateSpaceForm, CreateSpaceFormValues } from ".."; + +type EmptyViewSpaceProps = { + title: string; + contextValues: CreateSpaceFormValues; +}; + +export function EmptyViewSpace({ title, contextValues }: EmptyViewSpaceProps) { + return ( + + } icon={Icon.Plus} /> + + } + /> + ); +} 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/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/Lists/CollectionList.tsx b/extensions/anytype/src/components/Lists/CollectionList.tsx new file mode 100644 index 00000000000..0c0b5766f39 --- /dev/null +++ b/extensions/anytype/src/components/Lists/CollectionList.tsx @@ -0,0 +1,117 @@ +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 { isEmoji, pluralize, processObject } from "../../utils"; +import { defaultTintColor } from "../../utils/constant"; + +type CollectionListProps = { + space: Space; + listId: string; + listName: string; +}; + +export function CollectionList({ space, listId, listName }: CollectionListProps) { + const [searchText, setSearchText] = useState(""); + const { views, viewsError, isLoadingViews, mutateViews } = useListViews(space.id, listId); + const [viewId, setViewId] = useState(views?.[0]?.id); + const { objects, objectsError, isLoadingObjects, mutateObjects, objectsPagination } = useObjectsInList( + space.id, + listId, + viewId, + ); + + useEffect(() => { + if (viewsError || objectsError) { + showFailureToast(viewsError || objectsError, { title: "Failed to fetch latest data" }); + } + }, [viewsError, objectsError]); + + const filteredObjects = objects + ?.filter((object) => object.name.toLowerCase().includes(searchText.toLowerCase())) + .map((object) => { + return processObject(object, false, mutateObjects); + }); + + const resolveLayoutIcon = (layout: ViewLayout) => { + switch (layout) { + case ViewLayout.Grid: + return { source: "icons/dataview/grid.svg" }; + case ViewLayout.List: + return { source: "icons/dataview/list.svg" }; + case ViewLayout.Gallery: + return { source: "icons/dataview/gallery.svg" }; + case ViewLayout.Kanban: + return { source: "icons/dataview/kanban.svg" }; + case ViewLayout.Calendar: + return { source: "icons/dataview/calendar.svg" }; + case ViewLayout.Graph: + return { source: "icons/dataview/graph.svg" }; + default: + return { source: Icon.AppWindowGrid3x3, tintColor: defaultTintColor }; + } + }; + + return ( + setViewId(newValue)}> + {views.map((view) => ( + + ))} + + } + > + {filteredObjects && filteredObjects.length > 0 ? ( + + {filteredObjects.map((object) => ( + + ))} + + ) : ( + + )} + + ); +} 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/Lists/TemplateList.tsx b/extensions/anytype/src/components/Lists/TemplateList.tsx new file mode 100644 index 00000000000..4ca4d051c06 --- /dev/null +++ b/extensions/anytype/src/components/Lists/TemplateList.tsx @@ -0,0 +1,127 @@ +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, SpaceObject } from "../../models"; +import { pluralize, processObject } from "../../utils"; + +type TemplatesListProps = { + space: Space; + typeId: string; + isGlobalSearch: boolean; + isPinned: boolean; +}; + +export function TemplateList({ space, typeId, isGlobalSearch, isPinned }: TemplatesListProps) { + const [searchText, setSearchText] = useState(""); + const { templates, templatesError, isLoadingTemplates, mutateTemplates, templatesPagination } = useTemplates( + space.id, + typeId, + ); + const { objects, objectsError, isLoadingObjects, mutateObjects, objectsPagination } = useSearch( + space.id, + searchText, + [typeId], + ); + + useEffect(() => { + if (templatesError) { + showFailureToast(templatesError, { title: "Failed to fetch templates" }); + } + }, [templatesError]); + + useEffect(() => { + if (objectsError) { + showFailureToast(objectsError, { title: "Failed to fetch objects" }); + } + }, [objectsError]); + + const filteredTemplates = templates?.filter((template: SpaceObject) => + template.name.toLowerCase().includes(searchText.toLowerCase()), + ); + + const filteredObjects = objects + ?.filter((object) => object.name.toLowerCase().includes(searchText.toLowerCase())) + .map((object) => { + return processObject(object, false, mutateObjects); + }); + + return ( + + {filteredTemplates && filteredTemplates.length > 0 && ( + + {filteredTemplates.map((template: SpaceObject) => ( + + } + /> + ))} + + )} + + {filteredObjects && filteredObjects.length > 0 && ( + + {filteredObjects.map((object) => ( + + ))} + + )} + + {(!filteredTemplates || filteredTemplates.length === 0) && (!filteredObjects || filteredObjects.length === 0) && ( + + )} + + ); +} 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/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/types.ts b/extensions/anytype/src/mappers/types.ts index efcae6978ce..c02c3241d42 100644 --- a/extensions/anytype/src/mappers/types.ts +++ b/extensions/anytype/src/mappers/types.ts @@ -21,6 +21,7 @@ export async function mapType(type: RawType): Promise { return { ...type, name: type.name?.trim() || "Untitled", // empty string comes as \n + plural_name: type.plural_name?.trim() || "Untitled", icon: icon, }; } diff --git a/extensions/anytype/src/models/icon.ts b/extensions/anytype/src/models/icon.ts index a6e6cf69138..23b10b07158 100644 --- a/extensions/anytype/src/models/icon.ts +++ b/extensions/anytype/src/models/icon.ts @@ -4,10 +4,33 @@ export enum IconFormat { Icon = "icon", } -export interface ObjectIcon { - format: IconFormat; - emoji?: string; - file?: string; - name?: string; - color?: string; +export enum Color { + Grey = "grey", + Yellow = "yellow", + Orange = "orange", + Red = "red", + Pink = "pink", + Purple = "purple", + Blue = "blue", + Ice = "ice", + Teal = "teal", + Lime = "lime", } + +export interface EmojiIcon { + format: IconFormat.Emoji; + emoji: string; +} + +export interface FileIcon { + format: IconFormat.File; + file: string; +} + +export interface NamedIcon { + format: IconFormat.Icon; + name: string; + color?: Color | string; +} + +export type ObjectIcon = EmojiIcon | FileIcon | NamedIcon; diff --git a/extensions/anytype/src/models/index.ts b/extensions/anytype/src/models/index.ts index a331bca54f2..52622a872a3 100644 --- a/extensions/anytype/src/models/index.ts +++ b/extensions/anytype/src/models/index.ts @@ -1,11 +1,11 @@ export * from "./auth"; -export * from "./export"; export * from "./icon"; export * from "./list"; export * from "./member"; export * from "./object"; export * from "./pagination"; +export * from "./property"; export * from "./search"; export * from "./space"; -export * from "./template"; +export * from "./tag"; export * from "./type"; diff --git a/extensions/anytype/src/models/object.ts b/extensions/anytype/src/models/object.ts index ae70e585f24..baff83b48c0 100644 --- a/extensions/anytype/src/models/object.ts +++ b/extensions/anytype/src/models/object.ts @@ -1,14 +1,33 @@ import { Image } from "@raycast/api"; -import { ObjectIcon, RawType, Type } from "."; +import { + ObjectIcon, + ObjectLayout, + PropertyLinkWithValue, + PropertyWithValue, + RawProperty, + RawPropertyWithValue, + RawType, + Type, +} from "."; + +export enum BodyFormat { + Markdown = "md", + JSON = "json", +} export interface CreateObjectRequest { name: string; icon: ObjectIcon; - description: string; body: string; - source: string; template_id: string; type_key: string; + properties: PropertyLinkWithValue[]; +} + +export interface UpdateObjectRequest { + name?: string; + icon?: ObjectIcon; + properties?: PropertyLinkWithValue[]; } export interface RawSpaceObject { @@ -18,24 +37,26 @@ export interface RawSpaceObject { icon: ObjectIcon; type: RawType; snippet: string; - layout: string; + layout: ObjectLayout; space_id: string; archived: boolean; - properties: Property[]; + properties: RawPropertyWithValue[]; } -export interface RawSpaceObjectWithBlocks extends RawSpaceObject { - blocks: Block[]; +export interface RawSpaceObjectWithBody extends RawSpaceObject { + markdown: string; } -export interface SpaceObject extends Omit { - type: Type; +export interface SpaceObject extends Omit { icon: Image.ImageLike; + type: Type; + properties: PropertyWithValue[]; } -export interface SpaceObjectWithBlocks extends Omit { +export interface SpaceObjectWithBody extends Omit { type: Type; icon: Image.ImageLike; + properties: PropertyWithValue[]; } export interface Block { @@ -46,7 +67,7 @@ export interface Block { vertical_align: string; text: Text; file: File; - property: Property; + property: RawProperty; } export interface Text { @@ -68,26 +89,3 @@ export interface File { state: string; style: string; } - -export interface Property { - id: string; - name: string; - format: string; - text?: string; - number?: number; - select?: Tag; - multi_select?: Tag[]; - date?: string; - file?: SpaceObject[]; - checkbox?: boolean; - url?: string; - email?: string; - phone?: string; - object?: SpaceObject[]; -} - -export interface Tag { - id: string; - name: string; - color: string; -} diff --git a/extensions/anytype/src/models/property.ts b/extensions/anytype/src/models/property.ts new file mode 100644 index 00000000000..38e6f56732b --- /dev/null +++ b/extensions/anytype/src/models/property.ts @@ -0,0 +1,86 @@ +import { Image } from "@raycast/api"; +import { RawTag, Tag } from "."; +import { SpaceObject } from "./object"; + +export type PropertyFieldValue = string | number | boolean | string[] | Date | null | undefined; + +export enum PropertyFormat { + Text = "text", + Number = "number", + Select = "select", + MultiSelect = "multi_select", + Date = "date", + Files = "files", + Checkbox = "checkbox", + Url = "url", + Email = "email", + Phone = "phone", + Objects = "objects", +} + +export interface CreatePropertyRequest { + name: string; + format: PropertyFormat; +} + +export interface UpdatePropertyRequest { + name: string; +} + +export interface RawProperty { + object: string; + id: string; + key: string; + name: string; + format: PropertyFormat; +} + +export interface Property extends RawProperty { + icon: Image.ImageLike; +} +export interface RawPropertyWithValue { + id: string; + key: string; + name: string; + format: PropertyFormat; + text?: string; + number?: number; + select?: RawTag; + multi_select?: RawTag[]; + date?: string; + files?: string[]; + checkbox?: boolean; + url?: string; + email?: string; + phone?: string; + objects?: string[]; +} + +export interface PropertyWithValue extends Omit { + select?: Tag; + multi_select?: Tag[]; + files?: SpaceObject[]; + objects?: SpaceObject[]; +} + +export interface PropertyLink { + key: string; + name: string; + format: PropertyFormat; +} + +export interface PropertyLinkWithValue { + key: string; + format: PropertyFormat; + text?: string; + number?: number | null; + select?: string | null; + multi_select?: string[]; + date?: string | null; + files?: string[]; + checkbox?: boolean; + url?: string; + email?: string; + phone?: string; + objects?: string[]; +} diff --git a/extensions/anytype/src/models/search.ts b/extensions/anytype/src/models/search.ts index 72e9c6f5d6b..03436c7442e 100644 --- a/extensions/anytype/src/models/search.ts +++ b/extensions/anytype/src/models/search.ts @@ -17,6 +17,6 @@ export interface SearchRequest { } export interface SortOptions { - property: SortProperty; + property_key: SortProperty; direction: SortDirection; } diff --git a/extensions/anytype/src/models/space.ts b/extensions/anytype/src/models/space.ts index 1f3c6cfa7a0..4dba30001ff 100644 --- a/extensions/anytype/src/models/space.ts +++ b/extensions/anytype/src/models/space.ts @@ -6,6 +6,11 @@ export interface CreateSpaceRequest { description: string; } +export interface UpdateSpaceRequest { + name?: string; + description?: string; +} + export interface RawSpace { object: string; id: string; diff --git a/extensions/anytype/src/models/tag.ts b/extensions/anytype/src/models/tag.ts new file mode 100644 index 00000000000..8ecdd760c11 --- /dev/null +++ b/extensions/anytype/src/models/tag.ts @@ -0,0 +1,22 @@ +import { Color } from "."; + +export interface CreateTagRequest { + name: string; + color: Color; +} + +export interface UpdateTagRequest { + name: string; + color?: Color; +} + +export interface RawTag { + id: string; + key: string; + name: string; + color: Color; +} + +export interface Tag extends Omit { + color: string; +} diff --git a/extensions/anytype/src/models/type.ts b/extensions/anytype/src/models/type.ts index 1672c816010..2c8f4193654 100644 --- a/extensions/anytype/src/models/type.ts +++ b/extensions/anytype/src/models/type.ts @@ -1,14 +1,50 @@ import { Image } from "@raycast/api"; -import { ObjectIcon } from "."; +import { ObjectIcon, PropertyLink, RawProperty } from "."; + +export enum TypeLayout { + Basic = "basic", + Profile = "profile", + Action = "action", + Note = "note", +} + +export enum ObjectLayout { + Basic = "basic", + Profile = "profile", + Action = "action", + Note = "note", + Bookmark = "bookmark", + Set = "set", + Collection = "collection", + Participant = "participant", +} + +export interface CreateTypeRequest { + name: string; + plural_name: string; + icon: ObjectIcon; + Layout: TypeLayout; + Properties: PropertyLink[]; +} + +export interface UpdateTypeRequest { + name?: string; + plural_name?: string; + icon?: ObjectIcon; + layout?: TypeLayout; + properties?: PropertyLink[]; +} export interface RawType { object: string; id: string; key: string; name: string; + plural_name: string; icon: ObjectIcon; - recommended_layout: string; + layout: ObjectLayout; archived: boolean; + properties: RawProperty[]; } export interface Type extends Omit { diff --git a/extensions/anytype/src/search-anytype.tsx b/extensions/anytype/src/search-anytype.tsx index 36641a76d06..be8ec5ec7c5 100644 --- a/extensions/anytype/src/search-anytype.tsx +++ b/extensions/anytype/src/search-anytype.tsx @@ -1,9 +1,11 @@ -import { Icon, Image, List, showToast, Toast } from "@raycast/api"; +import { Icon, Image, List } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; import { useEffect, useMemo, useState } from "react"; import { EmptyViewObject, EnsureAuthenticated, ObjectListItem, ViewType } from "./components"; import { useGlobalSearch, usePinnedObjects, useSpaces } from "./hooks"; import { SpaceObject } from "./models"; import { + bundledTypeKeys, defaultTintColor, fetchTypeKeysForLists, fetchTypeKeysForPages, @@ -89,18 +91,16 @@ function Search() { [ViewType.pages]: typeKeysForPages, [ViewType.tasks]: typeKeysForTasks, [ViewType.lists]: typeKeysForLists, - [ViewType.bookmarks]: ["ot-bookmark"], + [ViewType.bookmarks]: [bundledTypeKeys.bookmark], }; setTypes(viewToType[currentView] ?? []); }, [currentView, typeKeysForPages, typeKeysForTasks]); useEffect(() => { if (objectsError || spacesError || pinnedObjectsError) { - showToast( - Toast.Style.Failure, - "Failed to fetch latest data", - objectsError?.message || spacesError?.message || pinnedObjectsError?.message, - ); + showFailureToast(objectsError || spacesError || pinnedObjectsError, { + title: "Failed to fetch latest data", + }); } }, [objectsError, spacesError, pinnedObjectsError]); @@ -138,7 +138,6 @@ function Search() { // Process pinned objects and filter by search term const processedPinnedObjects = pinnedObjects?.length ? pinnedObjects - // TODO: decide on wanted behavior for pinned objects .filter((object) => types.length === 0 || types.includes(object.type.key)) .filter((object) => filterObjectsBySearchTerm([object], searchText).length > 0) .map((object) => processObjectWithSpaceIcon(object, true)) @@ -209,11 +208,13 @@ function Search() { subtitle={object.subtitle} accessories={object.accessories} mutate={[mutateObjects, mutatePinnedObjects]} + object={object.object} layout={object.layout} viewType={currentView} isGlobalSearch={true} isNoPinView={false} isPinned={object.isPinned} + searchText={searchText} /> ))} @@ -233,11 +234,13 @@ function Search() { subtitle={object.subtitle} accessories={object.accessories} mutate={[mutateObjects, mutatePinnedObjects]} + object={object.object} layout={object.layout} viewType={currentView} isGlobalSearch={true} isNoPinView={false} isPinned={object.isPinned} + searchText={searchText} /> ))} diff --git a/extensions/anytype/src/tools/add-to-list.ts b/extensions/anytype/src/tools/add-to-list.ts index f9f82414168..d1d27b2b1f4 100644 --- a/extensions/anytype/src/tools/add-to-list.ts +++ b/extensions/anytype/src/tools/add-to-list.ts @@ -9,7 +9,7 @@ type Input = { /** * The unique identifier of the list to add the object to. - * This value can be obtained from the `search-anytype` or `search-space` tools by searching for type of `ot-collection`. + * This value can be obtained from the `search-anytype` or `search-space` tools by searching for type of 'collection'. */ listId: string; diff --git a/extensions/anytype/src/tools/approve-member.ts b/extensions/anytype/src/tools/approve-member.ts index 1757d713de6..6b063e01ffe 100644 --- a/extensions/anytype/src/tools/approve-member.ts +++ b/extensions/anytype/src/tools/approve-member.ts @@ -56,11 +56,11 @@ export const confirmation: Tool.Confirmation = async (input) => { info: [ { name: "Space", - value: s.space?.name, + value: s.space.name, }, { name: "Name", - value: m.member?.name, + value: m.member.name, }, { name: "Role", diff --git a/extensions/anytype/src/tools/create-object.ts b/extensions/anytype/src/tools/create-object.ts index c7443422d42..37c0b78f378 100644 --- a/extensions/anytype/src/tools/create-object.ts +++ b/extensions/anytype/src/tools/create-object.ts @@ -1,6 +1,7 @@ import { Tool } from "@raycast/api"; import { createObject, getSpace, getType } from "../api"; -import { IconFormat } from "../models"; +import { IconFormat, PropertyFormat, PropertyLinkWithValue } from "../models"; +import { bundledPropKeys } from "../utils"; type Input = { /** @@ -49,7 +50,7 @@ type Input = { body?: string; /** - * The URL of the bookmark, applicable when creating an object with type_key='ot-bookmark'. + * The URL of the bookmark, applicable when creating an object with type_key='bookmark'. * This value should be chosen based on the user's input. * If not given, set as an empty string. */ @@ -60,17 +61,34 @@ type Input = { * Create a new object in the specified space. * This function creates an object with the specified details in the specified space. * The object is created with the specified name, icon, description, body. - * When creating objects of type 'ot-bookmark', ensure the source URL is provided. The icon, name, and description should not be manually set, as they will be automatically populated upon fetching the URL. + * When creating objects of type 'bookmark', ensure the source URL is provided. The icon, name, and description should not be manually set, as they will be automatically populated upon fetching the URL. */ export default async function tool({ spaceId, type_key, name, icon, description, body, source }: Input) { + // TODO: implement properties key-value parsing + const propertyEntries: PropertyLinkWithValue[] = []; + if (description) { + propertyEntries.push({ + key: bundledPropKeys.description, + format: PropertyFormat.Text, + text: description, + }); + } + + if (source) { + propertyEntries.push({ + key: bundledPropKeys.source, + format: PropertyFormat.Url, + url: source, + }); + } + const { object } = await createObject(spaceId, { name: name || "", icon: { format: IconFormat.Emoji, emoji: icon || "" }, - description: description || "", body: body || "", - source: source || "", template_id: "", // not supported here type_key: type_key, + properties: propertyEntries, }); if (!object) { @@ -100,11 +118,11 @@ export const confirmation: Tool.Confirmation = async (input) => { info: [ { name: "Space", - value: s.space?.name, + value: s.space.name, }, { name: "Type", - value: t.type?.name || "", + value: t.type.name || "", }, { name: "Name", diff --git a/extensions/anytype/src/tools/get-list-items.ts b/extensions/anytype/src/tools/get-list-items.ts index fce957b67e9..d33e7a67410 100644 --- a/extensions/anytype/src/tools/get-list-items.ts +++ b/extensions/anytype/src/tools/get-list-items.ts @@ -10,7 +10,7 @@ type Input = { /** * The unique identifier of the list to get items from. - * This value can be obtained from the `search-space`or `search-anytype` tool and specifying types as 'ot-collection'. + * This value can be obtained from the `search-space`or `search-anytype` tool and specifying types as 'collection'. */ listId: string; }; diff --git a/extensions/anytype/src/tools/get-object.ts b/extensions/anytype/src/tools/get-object.ts index 8849269b187..c94da750161 100644 --- a/extensions/anytype/src/tools/get-object.ts +++ b/extensions/anytype/src/tools/get-object.ts @@ -1,5 +1,5 @@ -import { getExport, getObject } from "../api"; -import { ExportFormat } from "../models"; +import { getObject } from "../api"; +import { BodyFormat } from "../models"; type Input = { /** @@ -21,16 +21,9 @@ type Input = { * that matches the specified ID. */ export default async function tool({ spaceId, objectId }: Input) { - const { object } = await getObject(spaceId, objectId); - const { markdown } = await getExport(spaceId, objectId, ExportFormat.Markdown); + const { object } = await getObject(spaceId, objectId, BodyFormat.Markdown); - if (!object) { - return { - markdown, - }; - } - - const results = { + return { object: object.object, name: object.name, id: object.id, @@ -41,10 +34,6 @@ export default async function tool({ spaceId, objectId }: Input) { type_key: object.type.key, }, properties: object.properties, - }; - - return { - results, - markdown, + markdown: object.markdown, }; } diff --git a/extensions/anytype/src/tools/get-types.ts b/extensions/anytype/src/tools/get-types.ts index 3d08b3dd094..fbfc1d7bdd7 100644 --- a/extensions/anytype/src/tools/get-types.ts +++ b/extensions/anytype/src/tools/get-types.ts @@ -26,12 +26,12 @@ export default async function tool({ spaceId }: Input) { offset += apiLimitMax; } - const results = allTypes.map(({ object, name, id, key: type_key, recommended_layout }) => ({ + const results = allTypes.map(({ object, name, id, key: type_key, layout }) => ({ object, name, id, type_key, - recommended_layout, + layout, })); return { diff --git a/extensions/anytype/src/tools/reject-member.ts b/extensions/anytype/src/tools/reject-member.ts index c4bff6fcb65..01ae28f2155 100644 --- a/extensions/anytype/src/tools/reject-member.ts +++ b/extensions/anytype/src/tools/reject-member.ts @@ -48,11 +48,11 @@ export const confirmation: Tool.Confirmation = async (input) => { info: [ { name: "Space", - value: s.space?.name, + value: s.space.name, }, { name: "Name", - value: m.member?.name, + value: m.member.name, }, ], }; diff --git a/extensions/anytype/src/tools/remove-from-list.ts b/extensions/anytype/src/tools/remove-from-list.ts index dcf79164c6f..68533d14663 100644 --- a/extensions/anytype/src/tools/remove-from-list.ts +++ b/extensions/anytype/src/tools/remove-from-list.ts @@ -9,7 +9,7 @@ type Input = { /** * The unique identifier of the list to remove the object from. - * This value can be obtained from the `search-space` or `search-anytype` tools by searching for type of `ot-collection`. + * This value can be obtained from the `search-space` or `search-anytype` tools by searching for type of 'collection'. */ listId: string; diff --git a/extensions/anytype/src/tools/remove-member.ts b/extensions/anytype/src/tools/remove-member.ts index 9dd7665cd93..f588b85eabe 100644 --- a/extensions/anytype/src/tools/remove-member.ts +++ b/extensions/anytype/src/tools/remove-member.ts @@ -48,11 +48,11 @@ export const confirmation: Tool.Confirmation = async (input) => { info: [ { name: "Space", - value: s.space?.name, + value: s.space.name, }, { name: "Name", - value: m.member?.name, + value: m.member.name, }, ], }; diff --git a/extensions/anytype/src/tools/search-anytype.ts b/extensions/anytype/src/tools/search-anytype.ts index c83dae112b3..eac6897aa80 100644 --- a/extensions/anytype/src/tools/search-anytype.ts +++ b/extensions/anytype/src/tools/search-anytype.ts @@ -1,5 +1,5 @@ import { globalSearch } from "../api"; -import { SortDirection, SortProperty } from "../models"; +import { SortDirection, SortOptions, SortProperty } from "../models"; import { apiLimit } from "../utils"; type Input = { @@ -12,7 +12,7 @@ type Input = { /** * The types of objects to search for, identified by their type_key or id. * This value can be obtained from the `getTypes` tool. - * When user asks for 'list' objects, search for 'ot-set' and 'ot-collection' types. + * When user asks for 'list' objects, search for 'set' and 'collection' types. * If no types are specified, the search will include all types of objects. */ types?: string[]; @@ -34,7 +34,7 @@ type Input = { * This value can be "last_modified_date", "last_opened_date", "created_date" or "name". * Default value is "last_modified_date". */ - property?: SortProperty; + propertyKey?: SortProperty; }; }; @@ -46,8 +46,8 @@ type Input = { */ export default async function tool({ query, types, sort }: Input) { types = types ?? []; - const sortOptions = { - property: sort?.property ?? SortProperty.LastModifiedDate, + const sortOptions: SortOptions = { + property_key: sort?.propertyKey ?? SortProperty.LastModifiedDate, direction: sort?.direction ?? SortDirection.Descending, }; diff --git a/extensions/anytype/src/tools/search-space.ts b/extensions/anytype/src/tools/search-space.ts index 41f5ad04710..b88aa0a6c38 100644 --- a/extensions/anytype/src/tools/search-space.ts +++ b/extensions/anytype/src/tools/search-space.ts @@ -1,5 +1,5 @@ import { search } from "../api"; -import { SortDirection, SortProperty } from "../models"; +import { SortDirection, SortOptions, SortProperty } from "../models"; import { apiLimit } from "../utils"; type Input = { @@ -18,7 +18,7 @@ type Input = { /** * The types of objects to search for, identified by their type_key or id. * This value should be obtained from the `getTypes` tool and must be called if users request to search for objects of a certain type. - * When user asks for 'list' objects, search for 'ot-set' and 'ot-collection' types. + * When user asks for 'list' objects, search for 'set' and 'collection' types. * If no types are specified, the search will include all types of objects. */ types?: string[]; @@ -40,7 +40,7 @@ type Input = { * This value can be "last_modified_date", "last_opened_date", "created_date" or "name". * Default value is "last_modified_date". */ - property?: SortProperty; + propertyKey?: SortProperty; }; }; @@ -52,8 +52,8 @@ type Input = { */ export default async function tool({ spaceId, query, types, sort }: Input) { types = types ?? []; - const sortOptions = { - property: sort?.property ?? SortProperty.LastModifiedDate, + const sortOptions: SortOptions = { + property_key: sort?.propertyKey ?? SortProperty.LastModifiedDate, direction: sort?.direction ?? SortDirection.Descending, }; diff --git a/extensions/anytype/src/tools/update-member.ts b/extensions/anytype/src/tools/update-member.ts index c58ab031227..d5bde8453cc 100644 --- a/extensions/anytype/src/tools/update-member.ts +++ b/extensions/anytype/src/tools/update-member.ts @@ -55,11 +55,11 @@ export const confirmation: Tool.Confirmation = async (input) => { info: [ { name: "Space", - value: s.space?.name, + value: s.space.name, }, { name: "Name", - value: m.member?.name, + value: m.member.name, }, { name: "New Role", diff --git a/extensions/anytype/src/utils/constant.ts b/extensions/anytype/src/utils/constant.ts index a2c6733b7dc..39fadceffdc 100644 --- a/extensions/anytype/src/utils/constant.ts +++ b/extensions/anytype/src/utils/constant.ts @@ -1,9 +1,10 @@ import { getPreferenceValues } from "@raycast/api"; import { ViewType } from "../components"; +import { BodyFormat } from "../models"; import { encodeQueryParams } from "./query"; // Strings -export const apiAppName = "raycast_v1_0125"; +export const apiAppName = "raycast_v3_0425"; export const anytypeNetwork = "N83gJpVd9MuNRZAuJLZ7LiMntTThhPc6DtzWWVjb1M3PouVU"; export const errorConnectionMessage = "Can't connect to API. Please ensure Anytype is running and reachable."; @@ -13,7 +14,7 @@ export const downloadUrl = "https://download.anytype.io/"; export const anytypeSpaceDeeplink = (spaceId: string) => `anytype://main/object/_blank_/space.id/${spaceId}`; // Numbers -export const currentApiVersion = "2025-03-17"; +export const currentApiVersion = "2025-04-22"; export const apiLimit = getPreferenceValues().limit; export const apiLimitMax = 1000; export const iconWidth = 64; @@ -32,8 +33,48 @@ export const localStorageKeys = { }, }; +export const apiKeyPrefixes = { + properties: "", + types: "", + tags: "", +}; + +// API Property/Type Keys +export const bundledPropKeys = { + description: `${apiKeyPrefixes.properties}description`, + type: `${apiKeyPrefixes.properties}type`, + addedDate: `${apiKeyPrefixes.properties}added_date`, + createdDate: `${apiKeyPrefixes.properties}created_date`, + createdBy: `${apiKeyPrefixes.properties}creator`, + lastModifiedDate: `${apiKeyPrefixes.properties}last_modified_date`, + lastModifiedBy: `${apiKeyPrefixes.properties}last_modified_by`, + lastOpenedDate: `${apiKeyPrefixes.properties}last_opened_date`, + links: `${apiKeyPrefixes.properties}links`, + backlinks: `${apiKeyPrefixes.properties}backlinks`, + source: `${apiKeyPrefixes.properties}source`, +}; + +export const bundledTypeKeys = { + audio: `${apiKeyPrefixes.types}audio`, + bookmark: `${apiKeyPrefixes.types}bookmark`, + chat: `${apiKeyPrefixes.types}chat`, + collection: `${apiKeyPrefixes.types}collection`, + file: `${apiKeyPrefixes.types}file`, + note: `${apiKeyPrefixes.types}note`, + image: `${apiKeyPrefixes.types}image`, + object_type: `${apiKeyPrefixes.types}object_type`, + page: `${apiKeyPrefixes.types}page`, + participant: `${apiKeyPrefixes.types}participant`, + profile: `${apiKeyPrefixes.types}profile`, + set: `${apiKeyPrefixes.types}set`, + tag: `${apiKeyPrefixes.types}tag`, + task: `${apiKeyPrefixes.types}task`, + template: `${apiKeyPrefixes.types}template`, + video: `${apiKeyPrefixes.types}video`, +}; + // Colors -export const colorMap: { [key: string]: string } = { +export const colorToHex: { [key: string]: string } = { grey: "#b6b6b6", yellow: "#ecd91b", orange: "#ffb522", @@ -45,6 +86,18 @@ export const colorMap: { [key: string]: string } = { teal: "#0fc8ba", lime: "#5dd400", }; +export const hexToColor: { [key: string]: string } = { + "#b6b6b6": "grey", + "#ecd91b": "yellow", + "#ffb522": "orange", + "#f55522": "red", + "#e51ca0": "pink", + "#ab50cc": "purple", + "#3e58eb": "blue", + "#2aa7ee": "ice", + "#0fc8ba": "teal", + "#5dd400": "lime", +}; export const defaultTintColor = { light: "black", dark: "white" }; // API Endpoints @@ -59,12 +112,6 @@ export const apiEndpoints = { method: "POST", }), - // export - getExport: (spaceId: string, objectId: string, format: string) => ({ - url: `${apiUrl}/spaces/${spaceId}/objects/${objectId}/${format}`, - method: "GET", - }), - // lists getListViews: (spaceId: string, listId: string, options: { offset: number; limit: number }) => ({ url: `${apiUrl}/spaces/${spaceId}/lists/${listId}/views${encodeQueryParams(options)}`, @@ -84,22 +131,74 @@ export const apiEndpoints = { }), // objects + getObject: (spaceId: string, objectId: string, format: BodyFormat) => ({ + url: `${apiUrl}/spaces/${spaceId}/objects/${objectId}${encodeQueryParams({ format })}`, + method: "GET", + }), + getObjects: (spaceId: string, options: { offset: number; limit: number }) => ({ + url: `${apiUrl}/spaces/${spaceId}/objects${encodeQueryParams(options)}`, + method: "GET", + }), createObject: (spaceId: string) => ({ url: `${apiUrl}/spaces/${spaceId}/objects`, method: "POST", }), + updateObject: (spaceId: string, objectId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/objects/${objectId}`, + method: "PATCH", + }), deleteObject: (spaceId: string, objectId: string) => ({ url: `${apiUrl}/spaces/${spaceId}/objects/${objectId}`, method: "DELETE", }), - getObject: (spaceId: string, objectId: string) => ({ - url: `${apiUrl}/spaces/${spaceId}/objects/${objectId}`, + getExport: (spaceId: string, objectId: string, format: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/objects/${objectId}/${format}`, method: "GET", }), - getObjects: (spaceId: string, options: { offset: number; limit: number }) => ({ - url: `${apiUrl}/spaces/${spaceId}/objects${encodeQueryParams(options)}`, + + // properties + getProperties: (spaceId: string, options: { offset: number; limit: number }) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties${encodeQueryParams(options)}`, + method: "GET", + }), + getProperty: (spaceId: string, propertyId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}`, method: "GET", }), + createProperty: (spaceId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties`, + method: "POST", + }), + updateProperty: (spaceId: string, propertyId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}`, + method: "PATCH", + }), + deleteProperty: (spaceId: string, propertyId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}`, + method: "DELETE", + }), + + // tags + getTags: (spaceId: string, propertyId: string, options: { offset: number; limit: number }) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}/tags${encodeQueryParams(options)}`, + method: "GET", + }), + getTag: (spaceId: string, propertyId: string, tagId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}/tags/${tagId}`, + method: "GET", + }), + createTag: (spaceId: string, propertyId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}/tags`, + method: "POST", + }), + updateTag: (spaceId: string, propertyId: string, tagId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}/tags/${tagId}`, + method: "PATCH", + }), + deleteTag: (spaceId: string, propertyId: string, tagId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/properties/${propertyId}/tags/${tagId}`, + method: "DELETE", + }), // search globalSearch: (options: { offset: number; limit: number }) => ({ @@ -112,10 +211,6 @@ export const apiEndpoints = { }), // spaces - createSpace: { - url: `${apiUrl}/spaces`, - method: "POST", - }, getSpace: (spaceId: string) => ({ url: `${apiUrl}/spaces/${spaceId}`, method: "GET", @@ -124,6 +219,14 @@ export const apiEndpoints = { url: `${apiUrl}/spaces${encodeQueryParams(options)}`, method: "GET", }), + createSpace: { + url: `${apiUrl}/spaces`, + method: "POST", + }, + updateSpace: (spaceId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}`, + method: "PATCH", + }), // members getMember: (spaceId: string, objectId: string) => ({ @@ -149,6 +252,18 @@ export const apiEndpoints = { url: `${apiUrl}/spaces/${spaceId}/types${encodeQueryParams(options)}`, method: "GET", }), + createType: (spaceId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/types`, + method: "POST", + }), + updateType: (spaceId: string, typeId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/types/${typeId}`, + method: "PATCH", + }), + deleteType: (spaceId: string, typeId: string) => ({ + url: `${apiUrl}/spaces/${spaceId}/types/${typeId}`, + method: "DELETE", + }), // templates getTemplate: (spaceId: string, typeId: string, templateId: string) => ({ diff --git a/extensions/anytype/src/utils/form.ts b/extensions/anytype/src/utils/form.ts new file mode 100644 index 00000000000..4ae66438201 --- /dev/null +++ b/extensions/anytype/src/utils/form.ts @@ -0,0 +1,23 @@ +import { PropertyFormat, RawPropertyWithValue } from "../models"; + +/** + * Form validation to ensure that that number fields contain only numbers. + */ +export function getNumberFieldValidations( + properties: RawPropertyWithValue[], +): Record string | undefined> { + return properties + .filter((prop) => prop.format === PropertyFormat.Number) + .reduce( + (acc, prop) => { + acc[prop.key] = (value: unknown) => { + const str = typeof value === "string" ? value : undefined; + if (str && isNaN(Number(str))) { + return "Value must be a number"; + } + }; + return acc; + }, + {} as Record string | undefined>, + ); +} diff --git a/extensions/anytype/src/utils/icon.ts b/extensions/anytype/src/utils/icon.ts index 5deafe0b1ac..d99b966159a 100644 --- a/extensions/anytype/src/utils/icon.ts +++ b/extensions/anytype/src/utils/icon.ts @@ -1,7 +1,7 @@ import { Icon, Image } from "@raycast/api"; import fetch from "node-fetch"; -import { ObjectIcon, RawType } from "../models"; -import { colorMap, iconWidth } from "./constant"; +import { IconFormat, ObjectIcon, ObjectLayout, RawType } from "../models"; +import { colorToHex, iconWidth } from "./constant"; /** * Determine which icon to show for a given Object. Icon can be url or emoji. @@ -13,31 +13,31 @@ import { colorMap, iconWidth } from "./constant"; export async function getIconWithFallback(icon: ObjectIcon, layout: string, type?: RawType): Promise { if (icon && icon.format) { // type built-in icons - if (icon.format === "icon" && icon.name) { - return await getCustomTypeIcon(icon.name, icon.color); + if (icon.format === IconFormat.Icon && icon.name) { + return getCustomTypeIcon(icon.name, icon.color); } // file reference - if (icon.format === "file" && icon.file) { + if (icon.format === IconFormat.File && icon.file) { const fileSource = await getFile(icon.file); if (fileSource) { return { source: fileSource, mask: getMaskForObject(icon.file, layout) }; } - if (type?.icon.format === "icon" && type?.icon.name) { - return await getCustomTypeIcon(type.icon.name, "grey"); + if (type?.icon.format === IconFormat.Icon && type?.icon.name) { + return getCustomTypeIcon(type.icon.name, "grey"); } return await fallbackToLayout(layout); } // regular emoji - if (icon.format === "emoji" && icon.emoji) { + if (icon.format === IconFormat.Emoji && icon.emoji) { return icon.emoji; } } // fallback to grey version of type built-in icon - if (type?.icon && type.icon.format === "icon" && type.icon.name) { - return await getCustomTypeIcon(type?.icon.name, "grey"); + if (type?.icon && type.icon.format === IconFormat.Icon && type.icon.name) { + return getCustomTypeIcon(type?.icon.name, "grey"); } // fallback to layout @@ -51,23 +51,23 @@ export async function getIconWithFallback(icon: ObjectIcon, layout: string, type */ async function fallbackToLayout(layout: string): Promise { switch (layout) { - case "todo": - return await getCustomTypeIcon("checkbox", "grey"); - case "set": - case "collection": - return await getCustomTypeIcon("layers", "grey"); - case "participant": - return await getCustomTypeIcon("person", "grey"); - case "bookmark": - return await getCustomTypeIcon("bookmark", "grey"); + case ObjectLayout.Action: + return getCustomTypeIcon("checkbox", "grey"); + case ObjectLayout.Set: + case ObjectLayout.Collection: + return getCustomTypeIcon("layers", "grey"); + case ObjectLayout.Participant: + return getCustomTypeIcon("person", "grey"); + case ObjectLayout.Bookmark: + return getCustomTypeIcon("bookmark", "grey"); case "type": - return await getCustomTypeIcon("extension-puzzle", "grey"); + return getCustomTypeIcon("extension-puzzle", "grey"); case "template": - return await getCustomTypeIcon("copy", "grey"); + return getCustomTypeIcon("copy", "grey"); case "space": return Icon.BullsEye; default: - return await getCustomTypeIcon("document", "grey"); + return getCustomTypeIcon("document", "grey"); } } @@ -77,12 +77,12 @@ async function fallbackToLayout(layout: string): Promise { * @param color The color of the icon. * @returns The base64 data URI of the icon. */ -export async function getCustomTypeIcon(name: string, color?: string): Promise { +export function getCustomTypeIcon(name: string, color?: string): Image.ImageLike { return { source: `icons/type/${name}.svg`, tintColor: { - light: colorMap[color || "grey"], - dark: colorMap[color || "grey"], + light: colorToHex[color || "grey"], + dark: colorToHex[color || "grey"], }, }; } @@ -132,7 +132,7 @@ export async function fetchWithTimeout(url: string, timeout: number): Promise, - mutatePinnedObjects?: MutatePromise, + mutateObjects: MutatePromise, + mutatePinnedObjects?: MutatePromise, ) { const { sort } = getPreferenceValues(); // If sort is 'Name', fall back to using 'LastModifiedDate' for date details const sortForDate = sort === SortProperty.Name ? SortProperty.LastModifiedDate : sort; - const dateProperty = object.properties.find((property) => property.id === sortForDate); + const dateProperty = object.properties.find((property) => property.key === sortForDate); const date = dateProperty && dateProperty.date ? dateProperty.date : undefined; const hasValidDate = date && new Date(date).getTime() !== 0; @@ -40,8 +40,10 @@ export function processObject( text: hasValidDate ? undefined : "—", }, ], - mutate: [mutateObjects, mutatePinnedObjects].filter(Boolean) as MutatePromise[], - member: undefined, + mutate: [mutateObjects, mutatePinnedObjects].filter(Boolean) as MutatePromise< + SpaceObject[] | Type[] | Property[] | Member[] + >[], + object: object, layout: object.layout, isPinned, }; diff --git a/extensions/anytype/src/utils/string.ts b/extensions/anytype/src/utils/string.ts index a282b8e495b..0669d1f3103 100644 --- a/extensions/anytype/src/utils/string.ts +++ b/extensions/anytype/src/utils/string.ts @@ -1,4 +1,5 @@ import { getPreferenceValues, Image } from "@raycast/api"; +import emojiRegex from "emoji-regex"; import { MemberRole, SortProperty } from "../models"; /** @@ -9,7 +10,12 @@ export function pluralize( noun: string, { suffix = "s", withNumber = false }: { suffix?: string; withNumber?: boolean } = {}, ): string { - const pluralizedNoun = `${noun}${count !== 1 ? suffix : ""}`; + let pluralizedNoun; + if (noun.endsWith("y") && count !== 1) { + pluralizedNoun = `${noun.slice(0, -1)}ies`; + } else { + pluralizedNoun = `${noun}${count !== 1 ? suffix : ""}`; + } return withNumber ? `${count} ${pluralizedNoun}` : pluralizedNoun; } @@ -20,7 +26,7 @@ export function getDateLabel(): string { const { sort } = getPreferenceValues(); switch (sort) { case SortProperty.CreatedDate: - return "Created Date"; + return "Creation Date"; case SortProperty.LastModifiedDate: return "Last Modified Date"; case SortProperty.LastOpenedDate: @@ -82,7 +88,18 @@ export function formatMemberRole(role: string): string { export function injectEmojiIntoHeading(markdown: string, icon?: Image.ImageLike): string { if (typeof icon !== "string") return markdown; const trimmedIcon = icon.trim(); - const emojiRegex = /^(?:\p{Extended_Pictographic}(?:\p{Grapheme_Extend}|\u200D\p{Extended_Pictographic})*)+$/u; - if (!emojiRegex.test(trimmedIcon)) return markdown; + if (!isEmoji(trimmedIcon)) return markdown; return markdown.replace(/^(#+) (.*)/, (_, hashes, heading) => `${hashes} ${trimmedIcon} ${heading}`); } + +/** + * Checks if a string is a valid emoji. + * + * @param s The string to check. + * @returns True if the string is a valid emoji, false otherwise. + */ +export function isEmoji(s: string) { + const re = emojiRegex(); + const match = re.exec(s); + return match !== null && match[0] === s; +} diff --git a/extensions/anytype/src/utils/type.ts b/extensions/anytype/src/utils/type.ts index 2fe2d7d91b2..88fb05b2c96 100644 --- a/extensions/anytype/src/utils/type.ts +++ b/extensions/anytype/src/utils/type.ts @@ -1,13 +1,6 @@ import { getTemplates, getTypes } from "../api"; -import { Space, Template, Type } from "../models"; -import { apiLimitMax } from "../utils"; - -/** - * Checks if a given `Type` is a list type. - */ -export function typeIsList(layout: string): boolean { - return layout === "set" || layout === "collection"; -} +import { ObjectLayout, Space, SpaceObject, Type } from "../models"; +import { apiKeyPrefixes, apiLimitMax, bundledTypeKeys } from "../utils"; /** * Fetches all `Type`s from a single space, doing pagination if necessary. @@ -46,8 +39,8 @@ export async function getAllTypesFromSpaces(spaces: Space[]): Promise { /** * Fetches all `Template`s from a single space and type, doing pagination if necessary. */ -export async function fetchAllTemplatesForSpace(spaceId: string, typeId: string): Promise { - const allTemplates: Template[] = []; +export async function fetchAllTemplatesForSpace(spaceId: string, typeId: string): Promise { + const allTemplates: SpaceObject[] = []; let hasMore = true; let offset = 0; @@ -66,52 +59,68 @@ export async function fetchAllTemplatesForSpace(spaceId: string, typeId: string) */ export async function fetchTypeKeysForPages( spaces: Space[], - uniqueKeysForTasks: string[], - uniqueKeysForLists: string[], + typeKeysForTasks: string[], + typeKeysForLists: string[], ): Promise { const excludedKeysForPages = new Set([ // not shown anywhere - "ot-audio", - "ot-chat", - "ot-file", - "ot-image", - "ot-objectType", - "ot-tag", - "ot-template", - "ot-video", + bundledTypeKeys.audio, + bundledTypeKeys.chat, + bundledTypeKeys.file, + bundledTypeKeys.image, + bundledTypeKeys.object_type, + bundledTypeKeys.tag, + bundledTypeKeys.template, + bundledTypeKeys.video, // shown in other views - "ot-set", - "ot-collection", - "ot-bookmark", - "ot-participant", - ...uniqueKeysForTasks, - ...uniqueKeysForLists, + bundledTypeKeys.set, + bundledTypeKeys.collection, + bundledTypeKeys.bookmark, + bundledTypeKeys.participant, + ...typeKeysForTasks, + ...typeKeysForLists, ]); const allTypes = await getAllTypesFromSpaces(spaces); - const uniqueKeysSet = new Set(allTypes.map((type) => type.key).filter((key) => !excludedKeysForPages.has(key))); - return Array.from(uniqueKeysSet); + const pageTypeKeys = new Set(allTypes.map((type) => type.key).filter((key) => !excludedKeysForPages.has(key))); + return Array.from(pageTypeKeys); } /** - * Fetches all unique type keys for task types. + * Fetches all type keys for task types. */ export async function fetchTypesKeysForTasks(spaces: Space[]): Promise { const tasksTypes = await getAllTypesFromSpaces(spaces); - const uniqueKeys = new Set(tasksTypes.filter((type) => type.recommended_layout === "todo").map((type) => type.key)); - return Array.from(uniqueKeys); + const taskTypeKeys = new Set( + tasksTypes.filter((type) => type.layout === ObjectLayout.Action).map((type) => type.key), + ); + return Array.from(taskTypeKeys); } /** - * Fetches all unique type keys for list types. + * Fetches all type keys for list types. */ export async function fetchTypeKeysForLists(spaces: Space[]): Promise { const listsTypes = await getAllTypesFromSpaces(spaces); - const typeKeys = new Set( + const listTypeKeys = new Set( listsTypes - .filter((type) => type.recommended_layout === "set" || type.recommended_layout === "collection") + .filter((type) => type.layout === ObjectLayout.Set || type.layout === ObjectLayout.Collection) .map((type) => type.key), ); - return Array.from(typeKeys); + return Array.from(listTypeKeys); +} + +/** + * Checks if a type is custom user type or not (built-in system type). + */ +export function isUserType(key: string): boolean { + return apiKeyPrefixes.types.length + 24 === key.length && /\d/.test(key); +} + +/** + * Checks if a property is custom user property or not (built-in system property). + */ +export function isUserProperty(key: string): boolean { + return apiKeyPrefixes.properties.length + 24 === key.length && /\d/.test(key); } From 2ee6fefbc05f49f8b7bf7c6e1c2a791502d05495 Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Wed, 7 May 2025 01:07:26 +0200 Subject: [PATCH 2/3] Remove deleted files after merge --- .../anytype/src/api/export/getExport.ts | 8 - .../anytype/src/components/CollectionList.tsx | 109 ----- .../src/components/CreateObjectForm.tsx | 270 ----------- .../src/components/CreateSpaceForm.tsx | 62 --- .../src/components/EmptyViewObject.tsx | 89 ---- .../anytype/src/components/EmptyViewSpace.tsx | 21 - .../anytype/src/components/ObjectActions.tsx | 429 ------------------ .../anytype/src/components/SpaceActions.tsx | 127 ------ .../anytype/src/components/TemplateList.tsx | 123 ----- extensions/anytype/src/hooks/useExport.ts | 23 - extensions/anytype/src/mappers/templates.ts | 26 -- extensions/anytype/src/models/export.ts | 8 - extensions/anytype/src/models/template.ts | 13 - 13 files changed, 1308 deletions(-) delete mode 100644 extensions/anytype/src/api/export/getExport.ts delete mode 100644 extensions/anytype/src/components/CollectionList.tsx delete mode 100644 extensions/anytype/src/components/CreateObjectForm.tsx delete mode 100644 extensions/anytype/src/components/CreateSpaceForm.tsx delete mode 100644 extensions/anytype/src/components/EmptyViewObject.tsx delete mode 100644 extensions/anytype/src/components/EmptyViewSpace.tsx delete mode 100644 extensions/anytype/src/components/ObjectActions.tsx delete mode 100644 extensions/anytype/src/components/SpaceActions.tsx delete mode 100644 extensions/anytype/src/components/TemplateList.tsx delete mode 100644 extensions/anytype/src/hooks/useExport.ts delete mode 100644 extensions/anytype/src/mappers/templates.ts delete mode 100644 extensions/anytype/src/models/export.ts delete mode 100644 extensions/anytype/src/models/template.ts 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/components/CollectionList.tsx b/extensions/anytype/src/components/CollectionList.tsx deleted file mode 100644 index 17ee3d6ca53..00000000000 --- a/extensions/anytype/src/components/CollectionList.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Icon, List, showToast, Toast } from "@raycast/api"; -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"; - -type CollectionListProps = { - space: Space; - listId: string; - listName: string; -}; - -export function CollectionList({ space, listId, listName }: CollectionListProps) { - const [searchText, setSearchText] = useState(""); - const { views, viewsError, isLoadingViews, mutateViews } = useListViews(space.id, listId); - const [viewId, setViewId] = useState(views?.[0]?.id); - const { objects, objectsError, isLoadingObjects, mutateObjects, objectsPagination } = useObjectsInList( - space.id, - listId, - viewId, - ); - - useEffect(() => { - if (viewsError || objectsError) { - showToast(Toast.Style.Failure, "Failed to fetch objects", viewsError?.message || objectsError?.message); - } - }, [viewsError, objectsError]); - - const filteredObjects = objects - ?.filter((object) => object.name.toLowerCase().includes(searchText.toLowerCase())) - .map((object) => { - return processObject(object, false, mutateObjects); - }); - - const resolveLayoutIcon = (layout: string) => { - switch (layout.toLowerCase()) { - case ViewLayout.Grid: - return { source: "icons/dataview/grid.svg" }; - case ViewLayout.List: - return { source: "icons/dataview/list.svg" }; - case ViewLayout.Gallery: - return { source: "icons/dataview/gallery.svg" }; - case ViewLayout.Kanban: - return { source: "icons/dataview/kanban.svg" }; - case ViewLayout.Calendar: - return { source: "icons/dataview/calendar.svg" }; - case ViewLayout.Graph: - return { source: "icons/dataview/graph.svg" }; - default: - return { source: Icon.AppWindowGrid3x3, tintColor: defaultTintColor }; - } - }; - - return ( - setViewId(newValue)}> - {views.map((view) => ( - - ))} - - } - > - {filteredObjects && filteredObjects.length > 0 ? ( - - {filteredObjects.map((object) => ( - - ))} - - ) : ( - - )} - - ); -} 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/CreateSpaceForm.tsx b/extensions/anytype/src/components/CreateSpaceForm.tsx deleted file mode 100644 index cfad4636541..00000000000 --- a/extensions/anytype/src/components/CreateSpaceForm.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Action, ActionPanel, Form, Icon, popToRoot, showToast, Toast } from "@raycast/api"; -import { useForm } from "@raycast/utils"; -import { createSpace } from "../api"; - -export interface CreateSpaceFormValues { - name?: string; - description?: string; -} -interface CreateSpaceFormProps { - draftValues: CreateSpaceFormValues; -} - -export function CreateSpaceForm({ draftValues }: CreateSpaceFormProps) { - const { handleSubmit, itemProps } = useForm({ - initialValues: draftValues, - onSubmit: async (values) => { - try { - await showToast({ style: Toast.Style.Animated, title: "Creating space..." }); - - await createSpace({ - name: values.name || "", - description: values.description || "", - }); - - 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"); - } - } - }, - validation: { - name: (value) => { - if (!value) { - return "Name is required"; - } - }, - }, - }); - - return ( -
- - - } - > - - - - ); -} 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/EmptyViewSpace.tsx b/extensions/anytype/src/components/EmptyViewSpace.tsx deleted file mode 100644 index 9f0c75fa70b..00000000000 --- a/extensions/anytype/src/components/EmptyViewSpace.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Action, ActionPanel, Icon, List } from "@raycast/api"; -import { CreateSpaceForm, CreateSpaceFormValues } from "."; - -type EmptyViewSpaceProps = { - title: string; - contextValues: CreateSpaceFormValues; -}; - -export function EmptyViewSpace({ title, contextValues }: EmptyViewSpaceProps) { - return ( - - } icon={Icon.Plus} /> - - } - /> - ); -} diff --git a/extensions/anytype/src/components/ObjectActions.tsx b/extensions/anytype/src/components/ObjectActions.tsx deleted file mode 100644 index 064e930a473..00000000000 --- a/extensions/anytype/src/components/ObjectActions.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import { - Action, - ActionPanel, - Clipboard, - Color, - confirmAlert, - getPreferenceValues, - Icon, - Keyboard, - showToast, - Toast, -} 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 { - addPinned, - localStorageKeys, - moveDownInPinned, - moveUpInPinned, - pluralize, - removePinned, - typeIsList, -} from "../utils"; - -type ObjectActionsProps = { - space: Space; - objectId: string; - title: string; - objectExport?: Export; - mutate?: MutatePromise[]; - mutateTemplates?: MutatePromise; - mutateObject?: MutatePromise; - mutateExport?: MutatePromise; - mutateViews?: MutatePromise; - layout: string; - member?: Member | undefined; - viewType: ViewType; - isGlobalSearch: boolean; - isNoPinView: boolean; - isPinned: boolean; - showDetails?: boolean; - onToggleDetails?: () => void; -}; - -export function ObjectActions({ - space, - objectId, - title, - mutate, - objectExport, - mutateTemplates, - mutateObject, - mutateExport, - mutateViews, - layout, - viewType, - isGlobalSearch, - isNoPinView, - isPinned, - showDetails, - onToggleDetails, -}: ObjectActionsProps) { - 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 isType = viewType === ViewType.types; - const isMember = viewType === ViewType.members; - - const getContextLabel = (isSingular = true) => (isDetailView || isSingular ? viewType : pluralize(2, viewType)); - - async function handleCopyLink() { - await Clipboard.copy(objectUrl); - await showToast({ - title: "Link copied", - message: `The ${getContextLabel().toLowerCase()} link has been copied to your clipboard.`, - style: Toast.Style.Success, - }); - } - - async function handleDeleteObject() { - const confirm = await confirmAlert({ - title: `Delete ${getContextLabel()}`, - message: `Are you sure you want to delete "${title}"?`, - icon: { source: Icon.Trash, tintColor: Color.Red }, - }); - - if (confirm) { - try { - await deleteObject(space.id, objectId); - if (mutate) { - for (const m of mutate) { - await m(); - } - } - if (mutateTemplates) { - await mutateTemplates(); - } - if (mutateObject) { - await mutateObject(); - } - if (mutateExport) { - await mutateExport(); - } - if (mutateViews) { - await mutateViews(); - } - await showToast({ - style: Toast.Style.Success, - title: `${getContextLabel()} deleted`, - 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.", - }); - } - } - } - - async function handleMoveUpInFavorites() { - await moveUpInPinned(space.id, objectId, pinSuffixForView); - if (mutate) { - for (const m of mutate) { - await m(); - } - } - await showToast({ - style: Toast.Style.Success, - title: "Moved Up in Pinned", - }); - } - - async function handleMoveDownInFavorites() { - await moveDownInPinned(space.id, objectId, pinSuffixForView); - if (mutate) { - for (const m of mutate) { - await m(); - } - } - - await showToast({ - style: Toast.Style.Success, - title: "Moved Down in Pinned", - }); - } - - async function handlePin() { - if (isPinned) { - await removePinned(space.id, objectId, pinSuffixForView, title, getContextLabel()); - } else { - await addPinned(space.id, objectId, pinSuffixForView, title, getContextLabel()); - } - if (mutate) { - for (const m of mutate) { - await m(); - } - } - } - - async function handleRefresh() { - const label = getContextLabel(false); - await showToast({ - style: Toast.Style.Animated, - title: `Refreshing ${label}...`, - }); - try { - if (mutate) { - for (const m of mutate) { - await m(); - } - } - if (mutateTemplates) { - await mutateTemplates(); - } - 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.", - }); - } - } - - //! Member management not enabled yet - // async function handleApproveMember(identity: string, name: string, role: UpdateMemberRole) { - // try { - // await updateMember(space.id, identity, { status: MemberStatus.Active, role }); - // if (mutate) { - // for (const m of mutate) { - // await m(); - // } - // } - // await showToast({ - // style: Toast.Style.Success, - // title: `Member approved`, - // 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.", - // }); - // } - // } - - // async function handleRejectMember(identity: string, name: string, spaceName: string) { - // const confirm = await confirmAlert({ - // title: `Reject Member`, - // message: `Are you sure you want to reject ${name} from ${spaceName}?`, - // icon: { source: Icon.XMarkCircleHalfDash, tintColor: Color.Red }, - // }); - - // if (confirm) { - // try { - // await updateMember(space.id, identity, { status: MemberStatus.Declined }); - // if (mutate) { - // for (const m of mutate) { - // await m(); - // } - // } - // await showToast({ - // style: Toast.Style.Success, - // title: "Member rejected", - // 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.", - // }); - // } - // } - // } - - // async function handleRemoveMember(identity: string, memberName: string, spaceName: string) { - // const confirm = await confirmAlert({ - // title: `Remove Member`, - // message: `Are you sure you want to remove ${memberName} from ${spaceName}?`, - // icon: { source: Icon.RemovePerson, tintColor: Color.Red }, - // }); - - // if (confirm) { - // try { - // await updateMember(space.id, identity, { status: MemberStatus.Removed }); - // if (mutate) { - // for (const m of mutate) { - // await m(); - // } - // } - // await showToast({ - // style: Toast.Style.Success, - // title: "Member removed", - // 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.", - // }); - // } - // } - // } - - // async function handleChangeMemberRole(identity: string, name: string, role: UpdateMemberRole) { - // try { - // await updateMember(space.id, identity, { status: MemberStatus.Active, role }); - // if (mutate) { - // for (const m of mutate) { - // await m(); - // } - // } - // await showToast({ - // style: Toast.Style.Success, - // title: `Role changed`, - // 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.", - // }); - // } - // } - - const canShowDetails = !isType && !isList && !isDetailView; - const showDetailsAction = canShowDetails && ( - - } - /> - ); - - const openObjectAction = ( - - ); - - const firstPrimaryAction = primaryAction === "show_details" ? showDetailsAction : openObjectAction; - const secondPrimaryAction = primaryAction === "show_details" ? openObjectAction : showDetailsAction; - - return ( - - - {firstPrimaryAction} - {isList && ( - } - /> - )} - {isType && ( - - } - /> - )} - {secondPrimaryAction} - - - {objectExport && ( - - )} - - {!isMember && ( - - )} - {!isDetailView && !isNoPinView && ( - <> - - {isPinned && ( - <> - - - - )} - - )} - - - - {isDetailView && ( - - )} - - - - ); -} diff --git a/extensions/anytype/src/components/SpaceActions.tsx b/extensions/anytype/src/components/SpaceActions.tsx deleted file mode 100644 index b2ad7df9b6a..00000000000 --- a/extensions/anytype/src/components/SpaceActions.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Action, ActionPanel, Clipboard, Icon, Keyboard, showToast, Toast } from "@raycast/api"; -import { MutatePromise } from "@raycast/utils"; -import { ObjectList } from "."; -import { Space } from "../models"; -import { - addPinned, - anytypeSpaceDeeplink, - localStorageKeys, - moveDownInPinned, - moveUpInPinned, - removePinned, -} from "../utils"; - -type SpaceActionsProps = { - space: Space; - mutate: MutatePromise[]; - isPinned: boolean; -}; - -export function SpaceActions({ space, mutate, isPinned }: SpaceActionsProps) { - const spaceDeeplink = anytypeSpaceDeeplink(space.id); - const pinSuffix = localStorageKeys.suffixForSpaces; - - async function handleCopyLink() { - await Clipboard.copy(spaceDeeplink); - await showToast({ - title: "Link copied", - message: "The space link has been copied to your clipboard", - style: Toast.Style.Success, - }); - } - - async function handleMoveUpInFavorites() { - await moveUpInPinned(space.id, space.id, pinSuffix); - await Promise.all(mutate.map((mutateFunc) => mutateFunc())); - await showToast({ - style: Toast.Style.Success, - title: "Moved Up in Pinned", - }); - } - - async function handleMoveDownInFavorites() { - await moveDownInPinned(space.id, space.id, pinSuffix); - await Promise.all(mutate.map((mutateFunc) => mutateFunc())); - await showToast({ - style: Toast.Style.Success, - title: "Moved Down in Pinned", - }); - } - - async function handlePin() { - if (isPinned) { - await removePinned(space.id, space.id, pinSuffix, space.name, "Space"); - } else { - await addPinned(space.id, space.id, pinSuffix, space.name, "Space"); - } - await Promise.all(mutate.map((mutateFunc) => mutateFunc())); - } - - async function handleRefresh() { - await showToast({ style: Toast.Style.Animated, title: "Refreshing spaces" }); - if (mutate) { - try { - 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.", - }); - } - } - } - - return ( - - - } /> - - - - - - - {isPinned && ( - <> - - - - )} - - - - - - ); -} diff --git a/extensions/anytype/src/components/TemplateList.tsx b/extensions/anytype/src/components/TemplateList.tsx deleted file mode 100644 index b62872ce11f..00000000000 --- a/extensions/anytype/src/components/TemplateList.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { List, showToast, Toast } from "@raycast/api"; -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"; - -type TemplatesListProps = { - space: Space; - typeId: string; - isGlobalSearch: boolean; - isPinned: boolean; -}; - -export function TemplateList({ space, typeId, isGlobalSearch, isPinned }: TemplatesListProps) { - const [searchText, setSearchText] = useState(""); - const { templates, templatesError, isLoadingTemplates, mutateTemplates, templatesPagination } = useTemplates( - space.id, - typeId, - ); - const { objects, objectsError, isLoadingObjects, mutateObjects, objectsPagination } = useSearch( - space.id, - searchText, - [typeId], - ); - - useEffect(() => { - if (templatesError) { - showToast(Toast.Style.Failure, "Failed to fetch templates", templatesError.message); - } - }, [templatesError]); - - useEffect(() => { - if (objectsError) { - showToast(Toast.Style.Failure, "Failed to fetch objects", objectsError.message); - } - }, [objectsError]); - - const filteredTemplates = templates?.filter((template: Template) => - template.name.toLowerCase().includes(searchText.toLowerCase()), - ); - - const filteredObjects = objects - ?.filter((object) => object.name.toLowerCase().includes(searchText.toLowerCase())) - .map((object) => { - return processObject(object, false, mutateObjects); - }); - - return ( - - {filteredTemplates && filteredTemplates.length > 0 && ( - - {filteredTemplates.map((template: Template) => ( - - } - /> - ))} - - )} - - {filteredObjects && filteredObjects.length > 0 && ( - - {filteredObjects.map((object) => ( - - ))} - - )} - - {(!filteredTemplates || filteredTemplates.length === 0) && (!filteredObjects || filteredObjects.length === 0) && ( - - )} - - ); -} 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/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