diff --git a/setup.cfg b/setup.cfg index 6889983..cf66edf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,6 +56,7 @@ retk = models/search_engine/*.txt plugins/official_plugins/**/* core/ai/llm/knowledge/*.md + markdown.css [options.extras_require] build = diff --git a/src/retk/application.py b/src/retk/application.py index 8fa90a7..190ea6b 100644 --- a/src/retk/application.py +++ b/src/retk/application.py @@ -43,7 +43,7 @@ async def lifespan(app: FastAPI): allow_credentials=True, allow_methods=["*"], allow_headers=["*"], - expose_headers=["X-Captcha-Token"], + expose_headers=["X-Captcha-Token", "Content-Disposition"], ) app.add_middleware(safety.CSPMiddleware) app.add_middleware(safety.FrameOptionsMiddleware) diff --git a/src/retk/const/settings.py b/src/retk/const/settings.py index 9fab65b..2c09137 100644 --- a/src/retk/const/settings.py +++ b/src/retk/const/settings.py @@ -4,6 +4,7 @@ RETHINK_DIR = Path(__file__).parent.parent DOT_DATA = ".data" FRONTEND_DIR = RETHINK_DIR / "dist-local" +LOCAL_FILE_URL_PRE_DIR = "files" MD_MAX_LENGTH = 100_000 REQUEST_ID_MAX_LENGTH = 50 UID_MAX_LENGTH = 30 diff --git a/src/retk/controllers/node/node_ops.py b/src/retk/controllers/node/node_ops.py index d578861..295de32 100644 --- a/src/retk/controllers/node/node_ops.py +++ b/src/retk/controllers/node/node_ops.py @@ -1,8 +1,10 @@ -from typing import List +from typing import List, Literal + +from fastapi.responses import StreamingResponse from retk import const, core from retk.controllers import schemas -from retk.controllers.utils import maybe_raise_json_exception +from retk.controllers.utils import maybe_raise_json_exception, json_exception from retk.models.tps import AuthedUser, Node from retk.utils import contain_only_http_link, get_title_description_from_link, datetime2str @@ -223,3 +225,55 @@ async def get_favorite_nodes( requestId=au.request_id, data=_get_node_search_response_data(nodes=nodes, total=total), ) + + +async def stream_md_export( + au: AuthedUser, + nid: str, + format_: Literal["md", "html", "pdf"], +) -> StreamingResponse: + media_type, title, file, code = await core.node.md_export( + au=au, + nid=nid, + format_=format_, + ) + maybe_raise_json_exception(au=au, code=code) + # stream send file chunk + if media_type == "application/zip": + headers = { + "Content-Disposition": f"attachment; filename={title}.zip", + } + elif media_type == "text/markdown": + headers = { + "Content-Disposition": f"attachment; filename={title}.md", + } + elif media_type == "text/html": + headers = { + "Content-Disposition": f"attachment; filename={title}.html", + } + elif media_type == "application/pdf": + headers = { + "Content-Disposition": f"attachment; filename={title}.pdf", + } + else: + raise json_exception( + request_id=au.request_id, + uid=au.u.id, + code=const.CodeEnum.OPERATION_FAILED, + language=au.language, + ) + headers["Request-Id"] = au.request_id + + # iter file by chunk size + async def iter_file(): + while True: + chunk = file.read(1024) + if not chunk: + break + yield chunk + + return StreamingResponse( + content=iter_file(), + media_type=media_type, + headers=headers, + ) diff --git a/src/retk/core/ai/llm/knowledge/system_extend.md b/src/retk/core/ai/llm/knowledge/system_extend.md index a20750b..42cb8e9 100644 --- a/src/retk/core/ai/llm/knowledge/system_extend.md +++ b/src/retk/core/ai/llm/knowledge/system_extend.md @@ -7,7 +7,11 @@ 接下来,我将展示我最近接触到的一条信息, 请依据你的内在丰富的知识网络,帮我推荐出一条我会感兴趣的 **新知识**。 -请注意,你返回的结果必须为下面案例展示的 JSON 格式。 +请注意: + +1. 你返回的结果必须为下面案例展示的 JSON 格式。 +2. 在每次生成 JSON 结果的时候,生成的 value 请遵循我的主语言,比如我展示的信息使用 English,那么就生成 English + 内容,若主要使用中文,那么就生成中文内容。 # 案例 1: @@ -68,3 +72,34 @@ ``` """ + +# 案例 3: + +## 我展示的信息 + +""" +Title: Dual Process Theory in "Thinking, Fast and Slow" + +Key Points: + +1. The book is a popular science work by psychologist Daniel Kahneman. +2. Published in 2011. +3. Main thesis differentiates between two modes of thought. +4. "System 1" is characterized as fast, instinctive, and emotional. +5. "System 2" is described as slower, more deliberative, and more logical. + +""" + +## 你返回的结果 + +""" + +```json +{ + "title": "The Influence of Cognitive Biases on Decision Making", + "content": "- Cognitive biases are systematic errors in thinking that affect the decisions and judgments that people make.\n- Some of these biases are related to memory. The way you remember an event may be biased for a number of reasons and that in turn can lead to biased thinking and decision-making.\n- Other cognitive biases might be related to problems with attention. Since attention is a limited resource, people have to be selective about what they pay attention to in the world around them.\n- Because of these biases, people often create their own 'subjective social reality' that may not align with the objective world.\n- Understanding these biases can help improve decision making skills and lead to better outcomes in life.", + "searchTerms": "Cognitive biases, Decision making, Subjective social reality" +} +``` + +""" diff --git a/src/retk/core/ai/llm/knowledge/system_summary.md b/src/retk/core/ai/llm/knowledge/system_summary.md index e8e7460..06c5105 100644 --- a/src/retk/core/ai/llm/knowledge/system_summary.md +++ b/src/retk/core/ai/llm/knowledge/system_summary.md @@ -1,6 +1,9 @@ 你是一个博学多才的人,拥有非常丰富的知识,十分善于用简练的语言总结复杂的概念。 -接下来,我将展示我最近接触到的一些知识或信息,请帮我提炼总结这段认知的关键信息,总结一个简短标题,并简短罗列出知识点来。 +接下来,我将展示我最近接触到的一些知识或信息,请帮我: + +1. 提炼总结这段认知的关键信息,总结一个简短标题,并简短罗列出知识点来。 +2. 请遵循我的主语言,比如我展示的信息是用 English,那么就生成 English 总结,若主语言为中文,那么就生成中文总结。 案例 1: @@ -51,3 +54,30 @@ 5. 生命过程中水的作用:输送养分和排除废物 """ + +案例 3: + +# 我展示的信息: + +""" +Thinking, Fast and Slow + +Thinking, Fast and Slow is a 2011 popular science book by psychologist Daniel Kahneman. The book's main thesis is a +differentiation between two modes of thought: "System 1" is fast, instinctive and emotional; "System 2" is slower, more +deliberative, and more logical +""" + +# 你要返回的总结格式: + +""" +Title: Dual Process Theory in "Thinking, Fast and Slow" + +Key Points: + +1. The book is a popular science work by psychologist Daniel Kahneman. +2. Published in 2011. +3. Main thesis differentiates between two modes of thought. +4. "System 1" is characterized as fast, instinctive, and emotional. +5. "System 2" is described as slower, more deliberative, and more logical. + +""" diff --git a/src/retk/core/files/saver.py b/src/retk/core/files/saver.py index 4c5f975..c79449a 100644 --- a/src/retk/core/files/saver.py +++ b/src/retk/core/files/saver.py @@ -6,15 +6,11 @@ from PIL import Image, UnidentifiedImageError from bson import ObjectId -try: - from qcloud_cos import CosConfig, CosServiceError, CosS3Client -except ImportError: - pass - from retk.config import get_settings, is_local_db from retk.const.app import FileTypesEnum -from retk.const.settings import IMG_RESIZE_THRESHOLD, DOT_DATA +from retk.const.settings import IMG_RESIZE_THRESHOLD, DOT_DATA, LOCAL_FILE_URL_PRE_DIR from retk.core.user import update_used_space +from retk.core.utils.cos import cos_client from retk.logger import logger from retk.models.client import client from retk.models.tps import UserFile @@ -96,7 +92,7 @@ async def save_local(self, uid: str, file: File) -> str: if path.exists(): # skip the same image - return f"/files/{file.hashed_filename}" + return f"/{LOCAL_FILE_URL_PRE_DIR}/{file.hashed_filename}" try: if file.type == FileTypesEnum.IMAGE: @@ -116,62 +112,30 @@ async def save_local(self, uid: str, file: File) -> str: return "" await add_to_db(uid=uid, file=file) - return f"/files/{file.hashed_filename}" + return f"/{LOCAL_FILE_URL_PRE_DIR}/{file.hashed_filename}" async def save_remote(self, uid: str, file: File): - # to cos - token = None - - settings = get_settings() - secret_id = settings.COS_SECRET_ID - secret_key = settings.COS_SECRET_KEY - region = settings.COS_REGION - domain = settings.COS_DOMAIN - cos_conf = CosConfig( - Region=region, - SecretId=secret_id, - SecretKey=secret_key, - Token=token, - Domain=domain, - Scheme='https', - ) - cos_client = CosS3Client(cos_conf) - - key = f"userData/{uid}/{file.hashed_filename}" - - domain = settings.COS_DOMAIN or f"{settings.COS_BUCKET_NAME}.cos.{region}.myqcloud.com" - url = f"https://{domain}/{key}" + key = cos_client.get_user_data_key(uid=uid, filename=file.hashed_filename) + url = f"https://{cos_client.domain}/{key}" doc = await client.coll.user_file.find_one({"uid": uid, "fid": file.hashed_filename}) if doc: return url - try: - _ = cos_client.head_object( - Bucket=settings.COS_BUCKET_NAME, - Key=key - ) + if await cos_client.async_has_file(uid=uid, filename=file.hashed_filename): return url - except CosServiceError as e: - if e.get_status_code() != 404: - return url if file.type == FileTypesEnum.IMAGE: file.image_resize(resize_threshold=self.resize_threshold) # can raise error - try: - _ = cos_client.put_object( - Bucket=settings.COS_BUCKET_NAME, - Body=file.data, - Key=key, - StorageClass='STANDARD', # 'STANDARD'|'STANDARD_IA'|'ARCHIVE', - EnableMD5=False, - # ContentType=content_type, - ) - except CosServiceError as e: - logger.error(f"failed to save file to cos: {e}") + if not await cos_client.async_put( + file=file.data, + uid=uid, + filename=file.hashed_filename, + ): return "" + await add_to_db(uid=uid, file=file) return url diff --git a/src/retk/core/node/node.py b/src/retk/core/node/node.py index e4c2a6b..20cff25 100644 --- a/src/retk/core/node/node.py +++ b/src/retk/core/node/node.py @@ -1,6 +1,8 @@ import copy import datetime -from typing import List, Optional, Tuple, Dict, Any +import urllib.parse +from io import BytesIO +from typing import List, Optional, Tuple, Dict, Any, Literal from bson import ObjectId from bson.tz_util import utc @@ -8,6 +10,7 @@ from retk import config, const, utils, regex from retk import plugins from retk.core import user, ai +from retk.core.utils import md_tools from retk.logger import logger from retk.models import tps, db_ops from retk.models.client import client @@ -446,3 +449,22 @@ async def get_hist_edition_md(au: tps.AuthedUser, nid: str, version: str) -> Tup if version not in n["history"]: return "", const.CodeEnum.NODE_NOT_EXIST return backup.get_md(uid=au.u.id, nid=nid, version=version) + + +async def md_export( + au: tps.AuthedUser, + nid: str, + format_: Literal["md", "html", "pdf"], +) -> Tuple[str, str, Optional[BytesIO], const.CodeEnum]: + n, code = await get(au=au, nid=nid) + if code != const.CodeEnum.OK: + return "", "", None, code + + media_type, file = await md_tools.md_export( + uid=au.u.id, + title=n["title"], + md=n["md"], + format_=format_, + ) + title = urllib.parse.quote(n["title"]) + return media_type, title, file, const.CodeEnum.OK diff --git a/src/retk/core/notice.py b/src/retk/core/notice.py index 39edc04..3995abb 100644 --- a/src/retk/core/notice.py +++ b/src/retk/core/notice.py @@ -35,7 +35,7 @@ async def post_in_manager_delivery( senderType=au.u.type, senderId=au.u.id, title=title, - html=md2html(content), + html=md2html(content, with_css=True), snippet=md2txt(content)[:20], recipientType=recipient_type, # send to which user type, 0: all, 1: batch, 2: admin, 3: manager batchTypeIds=batch_type_ids, # if recipient=batch, put user id here diff --git a/src/retk/core/self_hosted.py b/src/retk/core/self_hosted.py index adc3648..9b9a19a 100644 --- a/src/retk/core/self_hosted.py +++ b/src/retk/core/self_hosted.py @@ -179,7 +179,7 @@ async def notice_new_pkg_version(): __new_version_content_temp_zh if language == "zh" else __new_version_content_temp_en ).format(local_version_str, remote_version_str) for notice in res: - if notice["title"] == title and notice["html"] == md2html(content): + if notice["title"] == title and notice["html"] == md2html(content, with_css=True): return await post_in_manager_delivery( au=_local_system_authed_user, diff --git a/src/retk/core/utils/cos.py b/src/retk/core/utils/cos.py new file mode 100644 index 0000000..65f6b38 --- /dev/null +++ b/src/retk/core/utils/cos.py @@ -0,0 +1,192 @@ +import asyncio +from datetime import datetime +from typing import Optional, BinaryIO + +import httpx + +try: + from qcloud_cos import CosConfig, CosS3Client, CosServiceError, CosClientError +except ImportError: + pass + +from retk.config import get_settings +from retk.logger import logger + + +class COSClient: + def __init__(self): + self._client = None + self.domain = None + self.bucket = None + + def init(self): + settings = get_settings() + try: + cos_conf = CosConfig( + Region=settings.COS_REGION, + SecretId=settings.COS_SECRET_ID, + SecretKey=settings.COS_SECRET_KEY, + Token=None, + Domain=settings.COS_DOMAIN, + Scheme='https', + ) + except CosClientError: + logger.info("COSClient | COS settings not found") + raise ModuleNotFoundError("COS settings not found") + self.domain = settings.COS_DOMAIN or f"{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com" + self.bucket = settings.COS_BUCKET_NAME + self._client = CosS3Client(cos_conf) + + @staticmethod + def get_user_data_key( + uid: str, + filename: str, + ) -> str: + return f"userData/{uid}/{filename}" + + def get_auth_headers(self, method: str, key: str) -> dict: + auth_str = self._client.get_auth( + Method=method.upper(), + Bucket=self.bucket, + Key=key, + ) + headers = { + "Authorization": auth_str, + "Date": datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT"), + } + return headers + + async def async_has_file(self, uid: str, filename: str) -> bool: + key = self.get_user_data_key(uid=uid, filename=filename) + async with httpx.AsyncClient() as client: + try: + resp = await client.head( + url=f"https://{self.domain}/{key}", + headers=self.get_auth_headers("head", key), + ) + except ( + httpx.ConnectTimeout, + httpx.ConnectError, + httpx.ReadTimeout, + httpx.HTTPError + ) as e: + logger.error(f"has_file | error: {e}") + return False + if resp.status_code != 200: + return False + return True + + async def async_batch_has_file(self, uid: str, filenames: list[str]) -> dict[str, bool]: + async def has_file(filename: str): + return await self.async_has_file(uid=uid, filename=filename) + + tasks = [has_file(filename) for filename in filenames] + results = await asyncio.gather(*tasks) + return {filename: result for filename, result in zip(filenames, results)} + + def has_file(self, uid: str, filename: str) -> bool: + try: + _ = self._client.head_object( + Bucket=self.bucket, + Key=self.get_user_data_key(uid=uid, filename=filename) + ) + return True + except CosServiceError as e: + if e.get_status_code() != 404: + return True + return False + + async def async_put(self, file: BinaryIO, uid: str, filename: str) -> bool: + key = self.get_user_data_key(uid=uid, filename=filename) + try: + async with httpx.AsyncClient() as client: + resp = await client.put( + url=f"https://{self.domain}/{key}", + headers=self.get_auth_headers("put", key), + content=file.read(), + ) + if resp.status_code != 200: + logger.error(f"put_cos_object | error: {resp.text}") + return False + except ( + httpx.ConnectTimeout, + httpx.ConnectError, + httpx.ReadTimeout, + httpx.HTTPError + ) as e: + logger.error(f"put_cos_object | error: {e}") + return False + return True + + async def async_batch_put(self, uid: str, files: dict[str, BinaryIO]) -> dict[str, bool]: + async def put_file(filename: str, file: BinaryIO): + return await self.async_put(file=file, uid=uid, filename=filename) + + tasks = [put_file(filename, file) for filename, file in files.items()] + batch_size = 5 + results = [] + for i in range(0, len(tasks), batch_size): + results.extend(await asyncio.gather(*tasks[i:i + batch_size])) + return {filename: result for filename, result in zip(files.keys(), results)} + + def put(self, file: BinaryIO, uid: str, filename: str) -> bool: + try: + _ = self._client.put_object( + Bucket=self.bucket, + Body=file, + Key=self.get_user_data_key(uid=uid, filename=filename), + StorageClass='STANDARD', # 'STANDARD'|'STANDARD_IA'|'ARCHIVE', + EnableMD5=False, + # ContentType=content_type, + ) + except CosServiceError as e: + logger.error(f"failed to save file to cos: {e}") + return False + return True + + async def async_get(self, uid: str, filename: str) -> Optional[bytes]: + key = self.get_user_data_key(uid=uid, filename=filename) + async with httpx.AsyncClient() as client: + try: + resp = await client.get( + url=f"https://{self.domain}/{key}", + headers=self.get_auth_headers("get", key), + ) + except ( + httpx.ConnectTimeout, + httpx.ConnectError, + httpx.ReadTimeout, + httpx.HTTPError + ) as e: + logger.error(f"get_cos_object | error: {e}") + return None + if resp.status_code != 200: + logger.error(f"get_cos_object | error: {resp.text}") + return None + return resp.content + + async def async_batch_get(self, uid: str, filenames: list[str]) -> dict[str, bytes]: + async def get_file(filename: str): + return await self.async_get(uid=uid, filename=filename) + + tasks = [get_file(filename) for filename in filenames] + batch_size = 5 + results = [] + for i in range(0, len(tasks), batch_size): + results.extend(await asyncio.gather(*tasks[i:i + batch_size])) + return {filename: result for filename, result in zip(filenames, results)} + + def get(self, uid: str, filename: str) -> Optional[bytes]: + try: + obj = self._client.get_object( + Bucket=self.bucket, + Key=self.get_user_data_key(uid=uid, filename=filename) + ) + except CosServiceError as e: + logger.error(f"failed to get file from cos: {e}") + return None + stream_body = obj["Body"] + return stream_body.read() + + +cos_client = COSClient() diff --git a/src/retk/core/utils/md_tools.py b/src/retk/core/utils/md_tools.py new file mode 100644 index 0000000..7f131bd --- /dev/null +++ b/src/retk/core/utils/md_tools.py @@ -0,0 +1,81 @@ +import os +import re +import zipfile +from io import BytesIO +from typing import Tuple, Set, AsyncIterable, Literal + +from retk import const, config +from retk.core.utils.cos import cos_client +from retk.utils import md2html + + +def replace_app_files_in_md(uid: str, md: str) -> Tuple[str, Set[str]]: + # return new_md and file_links + if config.is_local_db(): + filenames = re.findall(rf"\[.*]\(/{const.settings.LOCAL_FILE_URL_PRE_DIR}/(.*)\)", md) + new_md = md + else: + url = f"https://{cos_client.domain}/{cos_client.get_user_data_key(uid, '')}" + filenames = re.findall(rf"\[.*]\({url}(.*)\)", md) + new_md = re.sub( + rf"(\[.*]\(){url}(.*)\)", + rf"\1{const.settings.LOCAL_FILE_URL_PRE_DIR}/\2)", + md, + ) + return new_md, set(filenames) + + +async def iter_remote_files(uid: str, filenames: Set[str]) -> AsyncIterable[Tuple[str, bytes]]: + settings = config.get_settings() + if config.is_local_db(): + data_dir = os.path.join( + settings.RETHINK_LOCAL_STORAGE_PATH, + const.settings.DOT_DATA, const.settings.LOCAL_FILE_URL_PRE_DIR + ) + for filename in filenames: + path = os.path.join(data_dir, filename) + try: + with open(path, "rb") as file: + b = file.read() + except FileNotFoundError: + continue + yield filename, b + else: + files = await cos_client.async_batch_get(uid=uid, filenames=filenames) + for filename, b in files.items(): + yield filename, b + + +async def md_export( + uid: str, + title: str, + md: str, + format_: Literal["md", "html", "pdf"], +) -> Tuple[str, BytesIO]: + buffer = BytesIO() + content, filenames = replace_app_files_in_md(uid, md) + if format_ == "md": + out_filename = f"{title}.md" + media_type = "text/markdown" + elif format_ == "html": + content = md2html(content, with_css=True) + out_filename = f"{title}.html" + media_type = "text/html" + # elif format_ == "pdf": + # out_filename = f"{title}.pdf" + # media_type = "application/pdf" + else: + raise ValueError(f"unknown format: {format_}") + + if len(filenames) > 0: + with zipfile.ZipFile(buffer, "w") as z: + async for name, file in iter_remote_files(uid, filenames): + z.writestr(f"/{const.settings.LOCAL_FILE_URL_PRE_DIR}/{name}", file) + z.writestr(out_filename, content) + media_type = "application/zip" + else: + # only contain a single md file + buffer.write(content.encode("utf-8")) + + buffer.seek(0) + return media_type, buffer diff --git a/src/retk/depend/sso/base.py b/src/retk/depend/sso/base.py index 0cba26c..1ee67df 100644 --- a/src/retk/depend/sso/base.py +++ b/src/retk/depend/sso/base.py @@ -280,12 +280,12 @@ async def process_login( auth = httpx.BasicAuth(self.client_id, self.client_secret) async with httpx.AsyncClient() as client: response = await client.post(token_url, headers=headers, content=body, auth=auth) - content = response.json() - self._refresh_token = content.get("refresh_token") - self.oauth_client.parse_request_body_response(json.dumps(content)) + content = response.json() + self._refresh_token = content.get("refresh_token") + self.oauth_client.parse_request_body_response(json.dumps(content)) - uri, headers, _ = self.oauth_client.add_token(await self.userinfo_endpoint) - response = await client.get(uri, headers=headers) - content = response.json() + uri, headers, _ = self.oauth_client.add_token(await self.userinfo_endpoint) + response = await client.get(uri, headers=headers) + content = response.json() return await self.openid_from_response(content) diff --git a/src/retk/markdown.css b/src/retk/markdown.css new file mode 100644 index 0000000..c6b9357 --- /dev/null +++ b/src/retk/markdown.css @@ -0,0 +1,1227 @@ +.markdown-body { + --base-size-4: 0.25rem; + --base-size-8: 0.5rem; + --base-size-16: 1rem; + --base-text-weight-normal: 400; + --base-text-weight-medium: 500; + --base-text-weight-semibold: 600; + --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; +} + +@media (prefers-color-scheme: dark) { + .markdown-body, + [data-theme="dark"] { + /*dark*/ + color-scheme: dark; + --focus-outlineColor: #1f6feb; + --fgColor-default: #e6edf3; + --fgColor-muted: #8d96a0; + --fgColor-accent: #4493f8; + --fgColor-success: #3fb950; + --fgColor-attention: #d29922; + --fgColor-danger: #f85149; + --fgColor-done: #ab7df8; + --bgColor-default: #0d1117; + --bgColor-muted: #161b22; + --bgColor-neutral-muted: #6e768166; + --bgColor-attention-muted: #bb800926; + --borderColor-default: #30363d; + --borderColor-muted: #30363db3; + --borderColor-neutral-muted: #6e768166; + --borderColor-accent-emphasis: #1f6feb; + --borderColor-success-emphasis: #238636; + --borderColor-attention-emphasis: #9e6a03; + --borderColor-danger-emphasis: #da3633; + --borderColor-done-emphasis: #8957e5; + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + } +} + +@media (prefers-color-scheme: light) { + .markdown-body, + [data-theme="light"] { + /*light*/ + color-scheme: light; + --focus-outlineColor: #0969da; + --fgColor-default: #1f2328; + --fgColor-muted: #636c76; + --fgColor-accent: #0969da; + --fgColor-success: #1a7f37; + --fgColor-attention: #9a6700; + --fgColor-danger: #d1242f; + --fgColor-done: #8250df; + --bgColor-default: #ffffff; + --bgColor-muted: #f6f8fa; + --bgColor-neutral-muted: #afb8c133; + --bgColor-attention-muted: #fff8c5; + --borderColor-default: #d0d7de; + --borderColor-muted: #d0d7deb3; + --borderColor-neutral-muted: #afb8c133; + --borderColor-accent-emphasis: #0969da; + --borderColor-success-emphasis: #1a7f37; + --borderColor-attention-emphasis: #bf8700; + --borderColor-danger-emphasis: #cf222e; + --borderColor-done-emphasis: #8250df; + --color-prettylights-syntax-comment: #57606a; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #0550ae; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + } +} + + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: var(--fgColor-default); + background-color: var(--bgColor-default); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; + scroll-behavior: auto; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--fgColor-accent); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body mark { + background-color: var(--bgColor-attention-muted); + color: var(--fgColor-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + background-color: var(--bgColor-default); +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 40px; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--borderColor-muted); + height: .25em; + padding: 0; + margin: 24px 0; + background-color: var(--borderColor-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--fgColor-muted); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body details:not([open]) > *:not(summary) { + display: none; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--focus-outlineColor); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + line-height: 10px; + color: var(--fgColor-default); + vertical-align: middle; + background-color: var(--bgColor-muted); + border: solid 1px var(--borderColor-neutral-muted); + border-bottom-color: var(--borderColor-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--borderColor-muted); +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--fgColor-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--fgColor-muted); + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} + +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--fgColor-danger); +} + +.markdown-body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--fgColor-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div > ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li > p { + margin-top: 16px; +} + +.markdown-body li + li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--borderColor-default); +} + +.markdown-body table td > :last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: var(--bgColor-default); + border-top: 1px solid var(--borderColor-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: var(--bgColor-muted); +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame > span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--borderColor-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--fgColor-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center > span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right > span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right > span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--bgColor-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--fgColor-default); + background-color: var(--bgColor-muted); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 8px 9px; + text-align: right; + background: var(--bgColor-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--bgColor-muted); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--fgColor-muted); + border-top: 1px solid var(--borderColor-default); +} + +.markdown-body .footnotes ol { + padding-left: 16px; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 16px; + margin-top: 16px; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -24px; + pointer-events: none; + content: ""; + border: 2px solid var(--borderColor-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--fgColor-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: var(--base-size-4); +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list { + position: relative; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: var(--base-size-16); + color: inherit; + border-left: .25em solid var(--borderColor-default); +} + +.markdown-body .markdown-alert > :first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert > :last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--borderColor-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--fgColor-accent); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--borderColor-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--fgColor-done); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--borderColor-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--fgColor-attention); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--borderColor-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--fgColor-success); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--borderColor-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--fgColor-danger); +} + +.markdown-body > *:first-child > .heading-element:first-child { + margin-top: 0 !important; +} diff --git a/src/retk/routes/node.py b/src/retk/routes/node.py index fec411c..eb56212 100644 --- a/src/retk/routes/node.py +++ b/src/retk/routes/node.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from fastapi.params import Path, Query +from fastapi.responses import StreamingResponse from typing_extensions import Annotated from retk import const @@ -268,3 +269,21 @@ async def delete_node_favorite( nid=nid, favorite=False, ) + + +@router.get( + path="/{nid}/export/{format_}", + status_code=200, +) +@utils.measure_time_spend +async def stream_export_md_node( + au: utils.ANNOTATED_AUTHED_USER, + nid: str = utils.ANNOTATED_NID, + format_: Literal["md", "html", "pdf"] = Annotated[Literal["md", "html", "pdf"], Path()], + referer: Optional[str] = utils.DEPENDS_REFERER, +) -> StreamingResponse: + return await node_ops.stream_md_export( + au=au, + nid=nid, + format_=format_, + ) diff --git a/src/retk/routes/utils.py b/src/retk/routes/utils.py index 47130f5..ce5c2a8 100644 --- a/src/retk/routes/utils.py +++ b/src/retk/routes/utils.py @@ -16,6 +16,7 @@ from retk.core import scheduler from retk.core.files.importing import async_tasks from retk.core.self_hosted import notice_new_pkg_version +from retk.core.utils.cos import cos_client from retk.logger import logger, add_rotating_file_handler from retk.models.client import client from retk.models.tps import AuthedUser, convert_user_dict_to_authed_user @@ -89,6 +90,12 @@ async def on_startup(): logger.debug(f'startup_event RETHINK_DEFAULT_LANGUAGE: {os.environ.get("RETHINK_DEFAULT_LANGUAGE")}') await client.init() + # cos client + try: + cos_client.init() + except ModuleNotFoundError: + logger.info("cos client not init") + # schedule job scheduler.start() scheduler.init_tasks() diff --git a/src/retk/utils.py b/src/retk/utils.py index 6219a0b..da5d35d 100644 --- a/src/retk/utils.py +++ b/src/retk/utils.py @@ -131,7 +131,33 @@ def __unmark_element(element, stream=None): __md_html = Markdown( output_format="html", ) - +with open(os.path.join(os.path.dirname(__file__), "markdown.css"), "r") as css_file: + __css = css_file.read() + +__markdown_html_template = """ + + + + +{html} + + +""" def md2txt(md: str) -> str: for found in list(regex.MD_CODE.finditer(md))[::-1]: @@ -149,11 +175,13 @@ def preprocess_md(md: str, snippet_len: int = 200) -> Tuple[str, str, str]: return title, body, snippet -def md2html(md: str) -> str: +def md2html(md: str, with_css=False) -> str: _html = __md_html.convert(md) # prevent XSS and other security issues _html = re.sub(r"]*>.*?", "", _html, flags=re.DOTALL) - return _html + if not with_css: + return _html + return __markdown_html_template.format(css=__css, html=_html) def get_at_node_md_link(title: str, nid: str) -> str: diff --git a/tests/test_api.py b/tests/test_api.py index 37901ba..dda213e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1414,7 +1414,7 @@ async def test_system_notice(self): self.assertEqual(1, rj["total"]) self.assertEqual(1, len(rj["notices"])) self.assertEqual("title", rj["notices"][0]["title"]) - self.assertEqual("

content

", rj["notices"][0]["html"]) + self.assertTrue(rj["notices"][0]["html"].startswith('\n\ncontent

", docs[0]["html"]) + self.assertTrue(docs[0]["html"].startswith('\n\ncontent

", rj["notice"]["html"]) + self.assertTrue(docs[0]["html"].startswith('\n\ncontent

", n["html"]) + self.assertTrue(n["html"].startswith('\n\ncontent

", n["html"]) + self.assertTrue(n["html"].startswith('\n\n