diff --git a/common/constants.py b/common/constants.py
index 83a18f9ba..26526f868 100644
--- a/common/constants.py
+++ b/common/constants.py
@@ -129,6 +129,7 @@ class FileSource(StrEnum):
OCI_STORAGE = "oci_storage"
GOOGLE_CLOUD_STORAGE = "google_cloud_storage"
AIRTABLE = "airtable"
+ ASANA = "asana"
class PipelineTaskType(StrEnum):
diff --git a/common/data_source/__init__.py b/common/data_source/__init__.py
index 914233460..f79f349b3 100644
--- a/common/data_source/__init__.py
+++ b/common/data_source/__init__.py
@@ -37,6 +37,7 @@ from .teams_connector import TeamsConnector
from .webdav_connector import WebDAVConnector
from .moodle_connector import MoodleConnector
from .airtable_connector import AirtableConnector
+from .asana_connector import AsanaConnector
from .config import BlobType, DocumentSource
from .models import Document, TextSection, ImageSection, BasicExpertInfo
from .exceptions import (
@@ -73,4 +74,5 @@ __all__ = [
"InsufficientPermissionsError",
"UnexpectedValidationError",
"AirtableConnector",
+ "AsanaConnector",
]
diff --git a/common/data_source/asana_connector.py b/common/data_source/asana_connector.py
new file mode 100644
index 000000000..1dddcb6df
--- /dev/null
+++ b/common/data_source/asana_connector.py
@@ -0,0 +1,454 @@
+from collections.abc import Iterator
+import time
+from datetime import datetime
+import logging
+from typing import Any, Dict
+import asana
+import requests
+from common.data_source.config import CONTINUE_ON_CONNECTOR_FAILURE, INDEX_BATCH_SIZE, DocumentSource
+from common.data_source.interfaces import LoadConnector, PollConnector
+from common.data_source.models import Document, GenerateDocumentsOutput, SecondsSinceUnixEpoch
+from common.data_source.utils import extract_size_bytes, get_file_ext
+
+
+
+# https://github.com/Asana/python-asana/tree/master?tab=readme-ov-file#documentation-for-api-endpoints
+class AsanaTask:
+ def __init__(
+ self,
+ id: str,
+ title: str,
+ text: str,
+ link: str,
+ last_modified: datetime,
+ project_gid: str,
+ project_name: str,
+ ) -> None:
+ self.id = id
+ self.title = title
+ self.text = text
+ self.link = link
+ self.last_modified = last_modified
+ self.project_gid = project_gid
+ self.project_name = project_name
+
+ def __str__(self) -> str:
+ return f"ID: {self.id}\nTitle: {self.title}\nLast modified: {self.last_modified}\nText: {self.text}"
+
+
+class AsanaAPI:
+ def __init__(
+ self, api_token: str, workspace_gid: str, team_gid: str | None
+ ) -> None:
+ self._user = None
+ self.workspace_gid = workspace_gid
+ self.team_gid = team_gid
+
+ self.configuration = asana.Configuration()
+ self.api_client = asana.ApiClient(self.configuration)
+ self.tasks_api = asana.TasksApi(self.api_client)
+ self.attachments_api = asana.AttachmentsApi(self.api_client)
+ self.stories_api = asana.StoriesApi(self.api_client)
+ self.users_api = asana.UsersApi(self.api_client)
+ self.project_api = asana.ProjectsApi(self.api_client)
+ self.project_memberships_api = asana.ProjectMembershipsApi(self.api_client)
+ self.workspaces_api = asana.WorkspacesApi(self.api_client)
+
+ self.api_error_count = 0
+ self.configuration.access_token = api_token
+ self.task_count = 0
+
+ def get_tasks(
+ self, project_gids: list[str] | None, start_date: str
+ ) -> Iterator[AsanaTask]:
+ """Get all tasks from the projects with the given gids that were modified since the given date.
+ If project_gids is None, get all tasks from all projects in the workspace."""
+ logging.info("Starting to fetch Asana projects")
+ projects = self.project_api.get_projects(
+ opts={
+ "workspace": self.workspace_gid,
+ "opt_fields": "gid,name,archived,modified_at",
+ }
+ )
+ start_seconds = int(time.mktime(datetime.now().timetuple()))
+ projects_list = []
+ project_count = 0
+ for project_info in projects:
+ project_gid = project_info["gid"]
+ if project_gids is None or project_gid in project_gids:
+ projects_list.append(project_gid)
+ else:
+ logging.debug(
+ f"Skipping project: {project_gid} - not in accepted project_gids"
+ )
+ project_count += 1
+ if project_count % 100 == 0:
+ logging.info(f"Processed {project_count} projects")
+ logging.info(f"Found {len(projects_list)} projects to process")
+ for project_gid in projects_list:
+ for task in self._get_tasks_for_project(
+ project_gid, start_date, start_seconds
+ ):
+ yield task
+ logging.info(f"Completed fetching {self.task_count} tasks from Asana")
+ if self.api_error_count > 0:
+ logging.warning(
+ f"Encountered {self.api_error_count} API errors during task fetching"
+ )
+
+ def _get_tasks_for_project(
+ self, project_gid: str, start_date: str, start_seconds: int
+ ) -> Iterator[AsanaTask]:
+ project = self.project_api.get_project(project_gid, opts={})
+ project_name = project.get("name", project_gid)
+ team = project.get("team") or {}
+ team_gid = team.get("gid")
+
+ if project.get("archived"):
+ logging.info(f"Skipping archived project: {project_name} ({project_gid})")
+ return
+ if not team_gid:
+ logging.info(
+ f"Skipping project without a team: {project_name} ({project_gid})"
+ )
+ return
+ if project.get("privacy_setting") == "private":
+ if self.team_gid and team_gid != self.team_gid:
+ logging.info(
+ f"Skipping private project not in configured team: {project_name} ({project_gid})"
+ )
+ return
+ logging.info(
+ f"Processing private project in configured team: {project_name} ({project_gid})"
+ )
+
+ simple_start_date = start_date.split(".")[0].split("+")[0]
+ logging.info(
+ f"Fetching tasks modified since {simple_start_date} for project: {project_name} ({project_gid})"
+ )
+
+ opts = {
+ "opt_fields": "name,memberships,memberships.project,completed_at,completed_by,created_at,"
+ "created_by,custom_fields,dependencies,due_at,due_on,external,html_notes,liked,likes,"
+ "modified_at,notes,num_hearts,parent,projects,resource_subtype,resource_type,start_on,"
+ "workspace,permalink_url",
+ "modified_since": start_date,
+ }
+ tasks_from_api = self.tasks_api.get_tasks_for_project(project_gid, opts)
+ for data in tasks_from_api:
+ self.task_count += 1
+ if self.task_count % 10 == 0:
+ end_seconds = time.mktime(datetime.now().timetuple())
+ runtime_seconds = end_seconds - start_seconds
+ if runtime_seconds > 0:
+ logging.info(
+ f"Processed {self.task_count} tasks in {runtime_seconds:.0f} seconds "
+ f"({self.task_count / runtime_seconds:.2f} tasks/second)"
+ )
+
+ logging.debug(f"Processing Asana task: {data['name']}")
+
+ text = self._construct_task_text(data)
+
+ try:
+ text += self._fetch_and_add_comments(data["gid"])
+
+ last_modified_date = self.format_date(data["modified_at"])
+ text += f"Last modified: {last_modified_date}\n"
+
+ task = AsanaTask(
+ id=data["gid"],
+ title=data["name"],
+ text=text,
+ link=data["permalink_url"],
+ last_modified=datetime.fromisoformat(data["modified_at"]),
+ project_gid=project_gid,
+ project_name=project_name,
+ )
+ yield task
+ except Exception:
+ logging.error(
+ f"Error processing task {data['gid']} in project {project_gid}",
+ exc_info=True,
+ )
+ self.api_error_count += 1
+
+ def _construct_task_text(self, data: Dict) -> str:
+ text = f"{data['name']}\n\n"
+
+ if data["notes"]:
+ text += f"{data['notes']}\n\n"
+
+ if data["created_by"] and data["created_by"]["gid"]:
+ creator = self.get_user(data["created_by"]["gid"])["name"]
+ created_date = self.format_date(data["created_at"])
+ text += f"Created by: {creator} on {created_date}\n"
+
+ if data["due_on"]:
+ due_date = self.format_date(data["due_on"])
+ text += f"Due date: {due_date}\n"
+
+ if data["completed_at"]:
+ completed_date = self.format_date(data["completed_at"])
+ text += f"Completed on: {completed_date}\n"
+
+ text += "\n"
+ return text
+
+ def _fetch_and_add_comments(self, task_gid: str) -> str:
+ text = ""
+ stories_opts: Dict[str, str] = {}
+ story_start = time.time()
+ stories = self.stories_api.get_stories_for_task(task_gid, stories_opts)
+
+ story_count = 0
+ comment_count = 0
+
+ for story in stories:
+ story_count += 1
+ if story["resource_subtype"] == "comment_added":
+ comment = self.stories_api.get_story(
+ story["gid"], opts={"opt_fields": "text,created_by,created_at"}
+ )
+ commenter = self.get_user(comment["created_by"]["gid"])["name"]
+ text += f"Comment by {commenter}: {comment['text']}\n\n"
+ comment_count += 1
+
+ story_duration = time.time() - story_start
+ logging.debug(
+ f"Processed {story_count} stories (including {comment_count} comments) in {story_duration:.2f} seconds"
+ )
+
+ return text
+
+ def get_attachments(self, task_gid: str) -> list[dict]:
+ """
+ Fetch full attachment info (including download_url) for a task.
+ """
+ attachments: list[dict] = []
+
+ try:
+ # Step 1: list attachment compact records
+ for att in self.attachments_api.get_attachments_for_object(
+ parent=task_gid,
+ opts={}
+ ):
+ gid = att.get("gid")
+ if not gid:
+ continue
+
+ try:
+ # Step 2: expand to full attachment
+ full = self.attachments_api.get_attachment(
+ attachment_gid=gid,
+ opts={
+ "opt_fields": "name,download_url,size,created_at"
+ }
+ )
+
+ if full.get("download_url"):
+ attachments.append(full)
+
+ except Exception:
+ logging.exception(
+ f"Failed to fetch attachment detail {gid} for task {task_gid}"
+ )
+ self.api_error_count += 1
+
+ except Exception:
+ logging.exception(f"Failed to list attachments for task {task_gid}")
+ self.api_error_count += 1
+
+ return attachments
+
+ def get_accessible_emails(
+ self,
+ workspace_id: str,
+ project_ids: list[str] | None,
+ team_id: str | None,
+ ):
+
+ ws_users = self.users_api.get_users(
+ opts={
+ "workspace": workspace_id,
+ "opt_fields": "gid,name,email"
+ }
+ )
+
+ workspace_users = {
+ u["gid"]: u.get("email")
+ for u in ws_users
+ if u.get("email")
+ }
+
+ if not project_ids:
+ return set(workspace_users.values())
+
+
+ project_emails = set()
+
+ for pid in project_ids:
+ project = self.project_api.get_project(
+ pid,
+ opts={"opt_fields": "team,privacy_setting"}
+ )
+
+ if project["privacy_setting"] == "private":
+ if team_id and project.get("team", {}).get("gid") != team_id:
+ continue
+
+ memberships = self.project_memberships_api.get_project_membership(
+ pid,
+ opts={"opt_fields": "user.gid,user.email"}
+ )
+
+ for m in memberships:
+ email = m["user"].get("email")
+ if email:
+ project_emails.add(email)
+
+ return project_emails
+
+ def get_user(self, user_gid: str) -> Dict:
+ if self._user is not None:
+ return self._user
+ self._user = self.users_api.get_user(user_gid, {"opt_fields": "name,email"})
+
+ if not self._user:
+ logging.warning(f"Unable to fetch user information for user_gid: {user_gid}")
+ return {"name": "Unknown"}
+ return self._user
+
+ def format_date(self, date_str: str) -> str:
+ date = datetime.fromisoformat(date_str)
+ return time.strftime("%Y-%m-%d", date.timetuple())
+
+ def get_time(self) -> str:
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+
+
+class AsanaConnector(LoadConnector, PollConnector):
+ def __init__(
+ self,
+ asana_workspace_id: str,
+ asana_project_ids: str | None = None,
+ asana_team_id: str | None = None,
+ batch_size: int = INDEX_BATCH_SIZE,
+ continue_on_failure: bool = CONTINUE_ON_CONNECTOR_FAILURE,
+ ) -> None:
+ self.workspace_id = asana_workspace_id
+ self.project_ids_to_index: list[str] | None = (
+ asana_project_ids.split(",") if asana_project_ids else None
+ )
+ self.asana_team_id = asana_team_id if asana_team_id else None
+ self.batch_size = batch_size
+ self.continue_on_failure = continue_on_failure
+ self.size_threshold = None
+ logging.info(
+ f"AsanaConnector initialized with workspace_id: {asana_workspace_id}"
+ )
+
+ def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
+ self.api_token = credentials["asana_api_token_secret"]
+ self.asana_client = AsanaAPI(
+ api_token=self.api_token,
+ workspace_gid=self.workspace_id,
+ team_gid=self.asana_team_id,
+ )
+ self.workspace_users_email = self.asana_client.get_accessible_emails(self.workspace_id, self.project_ids_to_index, self.asana_team_id)
+ logging.info("Asana credentials loaded and API client initialized")
+ return None
+
+ def poll_source(
+ self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch | None
+ ) -> GenerateDocumentsOutput:
+ start_time = datetime.fromtimestamp(start).isoformat()
+ logging.info(f"Starting Asana poll from {start_time}")
+ docs_batch: list[Document] = []
+ tasks = self.asana_client.get_tasks(self.project_ids_to_index, start_time)
+ for task in tasks:
+ docs = self._task_to_documents(task)
+ docs_batch.extend(docs)
+
+ if len(docs_batch) >= self.batch_size:
+ logging.info(f"Yielding batch of {len(docs_batch)} documents")
+ yield docs_batch
+ docs_batch = []
+
+ if docs_batch:
+ logging.info(f"Yielding final batch of {len(docs_batch)} documents")
+ yield docs_batch
+
+ logging.info("Asana poll completed")
+
+ def load_from_state(self) -> GenerateDocumentsOutput:
+ logging.info("Starting full index of all Asana tasks")
+ return self.poll_source(start=0, end=None)
+
+ def _task_to_documents(self, task: AsanaTask) -> list[Document]:
+ docs: list[Document] = []
+
+ attachments = self.asana_client.get_attachments(task.id)
+
+ for att in attachments:
+ try:
+ resp = requests.get(att["download_url"], timeout=30)
+ resp.raise_for_status()
+ file_blob = resp.content
+ filename = att.get("name", "attachment")
+ size_bytes = extract_size_bytes(att)
+ if (
+ self.size_threshold is not None
+ and isinstance(size_bytes, int)
+ and size_bytes > self.size_threshold
+ ):
+ logging.warning(
+ f"{filename} exceeds size threshold of {self.size_threshold}. Skipping."
+ )
+ continue
+ docs.append(
+ Document(
+ id=f"asana:{task.id}:{att['gid']}",
+ blob=file_blob,
+ extension=get_file_ext(filename) or "",
+ size_bytes=size_bytes,
+ doc_updated_at=task.last_modified,
+ source=DocumentSource.ASANA,
+ semantic_identifier=filename,
+ primary_owners=list(self.workspace_users_email),
+ )
+ )
+ except Exception:
+ logging.exception(
+ f"Failed to download attachment {att.get('gid')} for task {task.id}"
+ )
+
+ return docs
+
+
+
+if __name__ == "__main__":
+ import time
+ import os
+
+ logging.info("Starting Asana connector test")
+ connector = AsanaConnector(
+ os.environ["WORKSPACE_ID"],
+ os.environ["PROJECT_IDS"],
+ os.environ["TEAM_ID"],
+ )
+ connector.load_credentials(
+ {
+ "asana_api_token_secret": os.environ["API_TOKEN"],
+ }
+ )
+ logging.info("Loading all documents from Asana")
+ all_docs = connector.load_from_state()
+ current = time.time()
+ one_day_ago = current - 24 * 60 * 60 # 1 day
+ logging.info("Polling for documents updated in the last 24 hours")
+ latest_docs = connector.poll_source(one_day_ago, current)
+ for docs in all_docs:
+ for doc in docs:
+ print(doc.id)
+ logging.info("Asana connector test completed")
\ No newline at end of file
diff --git a/common/data_source/config.py b/common/data_source/config.py
index cbf5aade0..e36ee404b 100644
--- a/common/data_source/config.py
+++ b/common/data_source/config.py
@@ -54,6 +54,7 @@ class DocumentSource(str, Enum):
DROPBOX = "dropbox"
BOX = "box"
AIRTABLE = "airtable"
+ ASANA = "asana"
class FileOrigin(str, Enum):
"""File origins"""
@@ -256,6 +257,10 @@ AIRTABLE_CONNECTOR_SIZE_THRESHOLD = int(
os.environ.get("AIRTABLE_CONNECTOR_SIZE_THRESHOLD", 10 * 1024 * 1024)
)
+ASANA_CONNECTOR_SIZE_THRESHOLD = int(
+ os.environ.get("ASANA_CONNECTOR_SIZE_THRESHOLD", 10 * 1024 * 1024)
+)
+
_USER_NOT_FOUND = "Unknown Confluence User"
_COMMENT_EXPANSION_FIELDS = ["body.storage.value"]
diff --git a/pyproject.toml b/pyproject.toml
index aa02097f4..c8a8755ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -149,6 +149,7 @@ dependencies = [
# "cryptography==46.0.3",
# "jinja2>=3.1.0",
"pyairtable>=3.3.0",
+ "asana>=5.2.2",
]
[dependency-groups]
diff --git a/rag/svr/sync_data_source.py b/rag/svr/sync_data_source.py
index 3e2172d4b..f3e30c8cc 100644
--- a/rag/svr/sync_data_source.py
+++ b/rag/svr/sync_data_source.py
@@ -38,8 +38,7 @@ from api.db.services.connector_service import ConnectorService, SyncLogsService
from api.db.services.knowledgebase_service import KnowledgebaseService
from common import settings
from common.config_utils import show_configs
-from common.data_source import BlobStorageConnector, NotionConnector, DiscordConnector, GoogleDriveConnector, \
- MoodleConnector, JiraConnector, DropboxConnector, WebDAVConnector, AirtableConnector
+from common.data_source import BlobStorageConnector, NotionConnector, DiscordConnector, GoogleDriveConnector, MoodleConnector, JiraConnector, DropboxConnector, WebDAVConnector, AirtableConnector, AsanaConnector
from common.constants import FileSource, TaskStatus
from common.data_source.config import INDEX_BATCH_SIZE
from common.data_source.confluence_connector import ConfluenceConnector
@@ -801,6 +800,48 @@ class Airtable(SyncBase):
return document_generator
+class Asana(SyncBase):
+ SOURCE_NAME: str = FileSource.ASANA
+
+ async def _generate(self, task: dict):
+ self.connector = AsanaConnector(
+ self.conf.get("asana_workspace_id"),
+ self.conf.get("asana_project_ids"),
+ self.conf.get("asana_team_id"),
+ )
+ credentials = self.conf.get("credentials", {})
+ if "asana_api_token_secret" not in credentials:
+ raise ValueError("Missing asana_api_token_secret in credentials")
+
+ self.connector.load_credentials(
+ {"asana_api_token_secret": credentials["asana_api_token_secret"]}
+ )
+
+ if task.get("reindex") == "1" or not task.get("poll_range_start"):
+ document_generator = self.connector.load_from_state()
+ begin_info = "totally"
+ else:
+ poll_start = task.get("poll_range_start")
+ if poll_start is None:
+ document_generator = self.connector.load_from_state()
+ begin_info = "totally"
+ else:
+ document_generator = self.connector.poll_source(
+ poll_start.timestamp(),
+ datetime.now(timezone.utc).timestamp(),
+ )
+ begin_info = f"from {poll_start}"
+
+ logging.info(
+ "Connect to Asana: workspace_id(%s), project_ids(%s), team_id(%s) %s",
+ self.conf.get("asana_workspace_id"),
+ self.conf.get("asana_project_ids"),
+ self.conf.get("asana_team_id"),
+ begin_info,
+ )
+
+ return document_generator
+
func_factory = {
FileSource.S3: S3,
@@ -821,6 +862,7 @@ func_factory = {
FileSource.WEBDAV: WebDAV,
FileSource.BOX: BOX,
FileSource.AIRTABLE: Airtable,
+ FileSource.ASANA: Asana
}
diff --git a/uv.lock b/uv.lock
index 075e6e03a..cfaaa401f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -345,6 +345,21 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/7b/7bf42178d227b26d3daf94cdd22a72a4ed5bf235548c4f5aea49c51c6458/arxiv-2.1.3-py3-none-any.whl", hash = "sha256:6f43673ab770a9e848d7d4fc1894824df55edeac3c3572ea280c9ba2e3c0f39f", size = 11478, upload-time = "2024-06-25T02:56:17.032Z" },
]
+[[package]]
+name = "asana"
+version = "5.2.2"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "python-dateutil" },
+ { name = "six" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/59/af14efdd03d332c33d4a77aed8f1f7151e3de5c2441e4bea3b1c6dbcc9d7/asana-5.2.2.tar.gz", hash = "sha256:d280ce2e8edf0355ccf21e548d887617ca8c926e1cb41309b8a173ca3181632c", size = 126424, upload-time = "2025-09-24T21:31:04.055Z" }
+wheels = [
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/5e/337125441af40aba86b087dee3dbe829413b6e42eac74defae2076926dbe/asana-5.2.2-py3-none-any.whl", hash = "sha256:1c8d15949a6cb9aa12363a5b7cfc6c0544cb3ae77290dd2e3255c0ec70668458", size = 203161, upload-time = "2025-09-24T21:31:02.401Z" },
+]
+
[[package]]
name = "aspose-slides"
version = "24.7.0"
@@ -6089,6 +6104,7 @@ dependencies = [
{ name = "akshare" },
{ name = "anthropic" },
{ name = "arxiv" },
+ { name = "asana" },
{ name = "aspose-slides", marker = "platform_machine == 'x86_64' or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
{ name = "atlassian-python-api" },
{ name = "azure-identity" },
@@ -6219,6 +6235,7 @@ requires-dist = [
{ name = "akshare", specifier = ">=1.15.78,<2.0.0" },
{ name = "anthropic", specifier = "==0.34.1" },
{ name = "arxiv", specifier = "==2.1.3" },
+ { name = "asana", specifier = ">=5.2.2" },
{ name = "aspose-slides", marker = "platform_machine == 'x86_64' or (platform_machine == 'arm64' and sys_platform == 'darwin')", specifier = "==24.7.0" },
{ name = "atlassian-python-api", specifier = "==4.0.7" },
{ name = "azure-identity", specifier = "==1.17.1" },
diff --git a/web/src/assets/svg/data-source/asana.svg b/web/src/assets/svg/data-source/asana.svg
new file mode 100644
index 000000000..01b755d97
--- /dev/null
+++ b/web/src/assets/svg/data-source/asana.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts
index 9407cc7b4..4cd15d5c6 100644
--- a/web/src/locales/en.ts
+++ b/web/src/locales/en.ts
@@ -933,6 +933,8 @@ Example: Virtual Hosted Style`,
boxDescription: 'Connect your Box drive to sync files and folders.',
airtableDescription:
'Connect to Airtable and synchronize files from a specified table within a designated workspace.',
+ asanaDescription:
+ 'Connect to Asana and synchronize files from a specified workspace.',
dropboxAccessTokenTip:
'Generate a long-lived access token in the Dropbox App Console with files.metadata.read, files.content.read, and sharing.read scopes.',
moodleDescription:
diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts
index 32fd04d5d..ea736a466 100644
--- a/web/src/locales/ru.ts
+++ b/web/src/locales/ru.ts
@@ -749,6 +749,8 @@ export default {
'Подключите ваш диск Box для синхронизации файлов и папок.',
airtableDescription:
'Подключите Airtable и синхронизируйте файлы из указанной таблицы в заданном рабочем пространстве.',
+ asanaDescription:
+ 'Подключите Asana и синхронизируйте файлы из рабочего пространства.',
google_driveDescription:
'Подключите ваш Google Drive через OAuth и синхронизируйте определенные папки или диски.',
gmailDescription:
diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts
index a90f7b194..c6a6237d7 100644
--- a/web/src/locales/zh.ts
+++ b/web/src/locales/zh.ts
@@ -862,6 +862,7 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于
dropboxDescription: '连接 Dropbox,同步指定账号下的文件与文件夹。',
boxDescription: '连接你的 Box 云盘以同步文件和文件夹。',
airtableDescription: '连接 Airtable,同步指定工作区下指定表格中的文件。',
+ asanaDescription: '连接 Asana,同步工作区中的文件。',
r2Description: '连接你的 Cloudflare R2 存储桶以导入和同步文件。',
dropboxAccessTokenTip:
'请在 Dropbox App Console 生成 Access Token,并勾选 files.metadata.read、files.content.read、sharing.read 等必要权限。',
diff --git a/web/src/pages/user-setting/data-source/constant/index.tsx b/web/src/pages/user-setting/data-source/constant/index.tsx
index ab8bb730f..b7ff9380c 100644
--- a/web/src/pages/user-setting/data-source/constant/index.tsx
+++ b/web/src/pages/user-setting/data-source/constant/index.tsx
@@ -25,6 +25,7 @@ export enum DataSourceKey {
OCI_STORAGE = 'oci_storage',
GOOGLE_CLOUD_STORAGE = 'google_cloud_storage',
AIRTABLE = 'airtable',
+ ASANA = 'asana',
// SHAREPOINT = 'sharepoint',
// SLACK = 'slack',
// TEAMS = 'teams',
@@ -109,6 +110,11 @@ export const generateDataSourceInfo = (t: TFunction) => {
description: t(`setting.${DataSourceKey.AIRTABLE}Description`),
icon: ,
},
+ [DataSourceKey.ASANA]: {
+ name: 'Asana',
+ description: t(`setting.${DataSourceKey.ASANA}Description`),
+ icon: ,
+ },
};
};
@@ -652,6 +658,32 @@ export const DataSourceFormFields = {
required: true,
},
],
+ [DataSourceKey.ASANA]: [
+ {
+ label: 'API Token',
+ name: 'config.credentials.asana_api_token_secret',
+ type: FormFieldType.Text,
+ required: true,
+ },
+ {
+ label: 'Workspace ID',
+ name: 'config.asana_workspace_id',
+ type: FormFieldType.Text,
+ required: true,
+ },
+ {
+ label: 'Project IDs',
+ name: 'config.asana_project_ids',
+ type: FormFieldType.Text,
+ required: false,
+ },
+ {
+ label: 'Team ID',
+ name: 'config.asana_team_id',
+ type: FormFieldType.Text,
+ required: false,
+ },
+ ],
};
export const DataSourceFormDefaultValues = {
@@ -851,4 +883,17 @@ export const DataSourceFormDefaultValues = {
},
},
},
+ [DataSourceKey.ASANA]: {
+ name: '',
+ source: DataSourceKey.ASANA,
+ config: {
+ name: '',
+ asana_workspace_id: '',
+ asana_project_ids: '',
+ asana_team_id: '',
+ credentials: {
+ asana_api_token_secret: '',
+ },
+ },
+ },
};