mirror of
https://github.com/Lumither/nocturne.git
synced 2026-10-17 10:26:21 +00:00
feat(client) add client
This commit is contained in:
11 files changed
+342
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
.venv/
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
.DS_store
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|
||||||
|
content/*
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```shell
|
||||||
|
.
|
||||||
|
├── README.md
|
||||||
|
├── cli
|
||||||
|
├── content
|
||||||
|
│ ├── pages
|
||||||
|
│ │ └── about
|
||||||
|
│ │ └── <static_page>.md
|
||||||
|
│ └── posts
|
||||||
|
│ └── <year>
|
||||||
|
│ └── <identifier>
|
||||||
|
│ ├── assets
|
||||||
|
│ │ └── <assets>
|
||||||
|
│ ├── translate_<lang>.md
|
||||||
|
│ └── index.md
|
||||||
|
├── requirements.txt
|
||||||
|
├── templates
|
||||||
|
│ ├── <template>.py
|
||||||
|
│ └── ...
|
||||||
|
└── utils
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### About `posts`
|
||||||
|
|
||||||
|
- Will render to `https://<DOMAIN>/blog/post/<identifier>`.
|
||||||
|
- Files under `assets/` (where images etc. located) should be pushed to OSS.
|
||||||
|
- For identifiers, use prefix `-` to indicate private (hidden) visibility. EX: `-hello-world`.
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import rich
|
||||||
|
|
||||||
|
from templates.post import BlogPost
|
||||||
|
from utils.cache import init_cache
|
||||||
|
from utils.index import build_post_idx
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# if args.new:
|
||||||
|
# new_post()
|
||||||
|
init_cache()
|
||||||
|
build_post_idx()
|
||||||
|
bp = BlogPost()
|
||||||
|
bp.init()
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-n", "--new", help="Create a post with build-in template", type=str)
|
||||||
|
args = parser.parse_args(namespace=rich)
|
||||||
|
# todo: cli
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
rich~=13.9.4
|
||||||
|
prompt_toolkit~=3.0.50
|
||||||
|
pypinyin~=0.53.0
|
||||||
|
python-frontmatter~=1.1.0
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime as dt
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from templates.utils import Template, create_all_files
|
||||||
|
from utils.autocompletion import input_autocompletion, fuzzy_pinyin_matcher
|
||||||
|
from utils.cache import get_tags_cache, get_categories_cache
|
||||||
|
from utils.lang import eprint, wprint
|
||||||
|
|
||||||
|
POST_TEMPLATE = '''
|
||||||
|
---
|
||||||
|
title: {{ title }}
|
||||||
|
subtitle: {{ subtitle }}
|
||||||
|
published_date: {{ published_date }}
|
||||||
|
last_updated: {{ last_update }}
|
||||||
|
category: {{ category }}
|
||||||
|
tags: {{ tags }}
|
||||||
|
uuid: {{ uuid }}
|
||||||
|
---
|
||||||
|
|
||||||
|
WIP
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def get_title():
|
||||||
|
while True:
|
||||||
|
i = input("title > ")
|
||||||
|
if i.strip() == "":
|
||||||
|
eprint("empty title")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
return i.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_subtitle():
|
||||||
|
return input("subtitle > ")
|
||||||
|
|
||||||
|
|
||||||
|
def get_published_date():
|
||||||
|
while True:
|
||||||
|
d = input("published_date (YYYY-MM-DD) > ")
|
||||||
|
if d == "":
|
||||||
|
wprint("using today's date")
|
||||||
|
return dt.today().strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
_validate = dt.strptime(d, "%Y-%m-%d")
|
||||||
|
return d
|
||||||
|
except ValueError:
|
||||||
|
eprint("invalid date")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def get_tags():
|
||||||
|
# todo: multi tags support
|
||||||
|
return input_autocompletion("tags > ", get_tags_cache(), fuzzy_pinyin_matcher)
|
||||||
|
|
||||||
|
|
||||||
|
def get_category():
|
||||||
|
while True:
|
||||||
|
i = input_autocompletion("category > ", get_categories_cache(), fuzzy_pinyin_matcher)
|
||||||
|
if i.strip() == "":
|
||||||
|
eprint("empty category")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
return i.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class BlogPost(Template):
|
||||||
|
WORK_PATH: str = "content/posts/{year}/{identifier}/"
|
||||||
|
ITEMS: Dict[str, Optional[str]] = {"index.md": POST_TEMPLATE, "assets/": None}
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
year = date.today().year
|
||||||
|
|
||||||
|
index_md = POST_TEMPLATE.format(
|
||||||
|
title=get_title(),
|
||||||
|
subtitle=get_subtitle(),
|
||||||
|
published_date=get_published_date(),
|
||||||
|
last_update=dt.today().strftime("%Y-%m-%d"),
|
||||||
|
tags=get_tags(),
|
||||||
|
category=get_category(),
|
||||||
|
uuid=uuid.uuid4()
|
||||||
|
)
|
||||||
|
create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Template(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def init(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_all_files(relative_work_path: str, items: Dict[str, Optional[str]]):
|
||||||
|
absolute_path = os.path.abspath(relative_work_path)
|
||||||
|
for item in items:
|
||||||
|
if item.endswith("/"):
|
||||||
|
print(absolute_path + item)
|
||||||
|
# todo: create files
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class Ansi:
|
||||||
|
HEADER = '\033[95m'
|
||||||
|
BLUE = '\033[94m'
|
||||||
|
GREEN = '\033[92m'
|
||||||
|
WARNING = '\033[93m'
|
||||||
|
FAIL = '\033[91m'
|
||||||
|
BOLD = '\033[1m'
|
||||||
|
UNDERLINE = '\033[4m'
|
||||||
|
END = '\033[0m'
|
||||||
|
|
||||||
|
def disable(self) -> None:
|
||||||
|
self.HEADER = ''
|
||||||
|
self.BLUE = ''
|
||||||
|
self.GREEN = ''
|
||||||
|
self.WARNING = ''
|
||||||
|
self.FAIL = ''
|
||||||
|
self.BOLD = ''
|
||||||
|
self.UNDERLINE = ''
|
||||||
|
self.END = ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def blend(color: list[str], text: str) -> str:
|
||||||
|
return f"{"".join(color)}{text}{Ansi.END}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def bold(text: str) -> str:
|
||||||
|
return f"{Ansi.BOLD}{text}{Ansi.END}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def underline(text: str) -> str:
|
||||||
|
return f"{Ansi.UNDERLINE}{text}{Ansi.END}"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from typing import List, Iterable
|
||||||
|
|
||||||
|
import prompt_toolkit
|
||||||
|
from prompt_toolkit.completion import Completer, CompleteEvent, Completion
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
from pypinyin import lazy_pinyin, Style
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCompleter(Completer):
|
||||||
|
def __init__(self, candidates: List[str], matching_algorithm: Callable[[str, str], bool]):
|
||||||
|
self.candidates = candidates
|
||||||
|
self.matching_algorithm = matching_algorithm
|
||||||
|
|
||||||
|
def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterable[Completion]:
|
||||||
|
curr_word = document.text_before_cursor
|
||||||
|
for c in self.candidates:
|
||||||
|
if self.matching_algorithm(curr_word, c):
|
||||||
|
yield Completion(c, start_position=-len(curr_word))
|
||||||
|
|
||||||
|
|
||||||
|
def exact_matcher(word: str, candidate: str, case_sensitive: bool = False) -> bool:
|
||||||
|
if case_sensitive:
|
||||||
|
return word == candidate
|
||||||
|
else:
|
||||||
|
return word.lower() == candidate.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy_matcher(word: str, candidate: str) -> bool:
|
||||||
|
return word.lower() in candidate.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy_pinyin_matcher(word: str, candidate: str) -> bool:
|
||||||
|
try:
|
||||||
|
word_matcher = ''.join(lazy_pinyin(word, style=Style.NORMAL))
|
||||||
|
candidate_matcher = ''.join(lazy_pinyin(candidate, style=Style.NORMAL))
|
||||||
|
return candidate_matcher.startswith(word_matcher)
|
||||||
|
|
||||||
|
except e:
|
||||||
|
return fuzzy_matcher(word, candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def input_autocompletion(prompt: str, candidates: List[str],
|
||||||
|
matching_algorithm: Callable[[str, str], bool] = exact_matcher) -> str:
|
||||||
|
completer = AutoCompleter(candidates, matching_algorithm)
|
||||||
|
return prompt_toolkit.prompt(prompt, completer=completer, complete_in_thread=True)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import TextIO
|
||||||
|
|
||||||
|
CACHE_DIR = ".cache"
|
||||||
|
ITEMS = {"register": "register.json"}
|
||||||
|
|
||||||
|
|
||||||
|
def overwrite_json_file(file: TextIO, json_data: dict):
|
||||||
|
file.seek(0)
|
||||||
|
file.write(json.dumps(json_data, indent=4, ensure_ascii=False))
|
||||||
|
file.truncate()
|
||||||
|
|
||||||
|
|
||||||
|
def init_cache():
|
||||||
|
if not os.path.exists(CACHE_DIR):
|
||||||
|
os.makedirs(CACHE_DIR)
|
||||||
|
for item in ITEMS.values():
|
||||||
|
item_path = os.path.join(CACHE_DIR, item)
|
||||||
|
if not os.path.exists(item_path):
|
||||||
|
with open(item_path, 'w') as cache_file:
|
||||||
|
match item:
|
||||||
|
case "register.json":
|
||||||
|
overwrite_json_file(cache_file, {})
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def write_tags_cache(tags: list[str]):
|
||||||
|
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r+') as tags_cache_file:
|
||||||
|
cache = json.load(tags_cache_file)
|
||||||
|
cache["tags"] = tags
|
||||||
|
overwrite_json_file(tags_cache_file, cache)
|
||||||
|
|
||||||
|
|
||||||
|
def write_category_cache(categories: list[str]):
|
||||||
|
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r+') as categories_cache_file:
|
||||||
|
cache = json.load(categories_cache_file)
|
||||||
|
cache["categories"] = categories
|
||||||
|
overwrite_json_file(categories_cache_file, cache)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tags_cache() -> list[str]:
|
||||||
|
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r') as tags_cache_file:
|
||||||
|
return json.load(tags_cache_file)["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories_cache() -> list[str]:
|
||||||
|
with open(os.path.join(CACHE_DIR, ITEMS["register"]), 'r') as categories_cache_file:
|
||||||
|
return json.load(categories_cache_file)["categories"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
|
||||||
|
from utils.cache import write_tags_cache, write_category_cache
|
||||||
|
from utils.lang import list_exclude
|
||||||
|
|
||||||
|
EXCLUDE = [".DS_Store"]
|
||||||
|
POSTS_DIR = "content/posts"
|
||||||
|
|
||||||
|
|
||||||
|
def build_post_idx():
|
||||||
|
tags = set()
|
||||||
|
categories = set()
|
||||||
|
for year in list_exclude(os.listdir(POSTS_DIR), EXCLUDE):
|
||||||
|
for post_dir in list_exclude(os.listdir(os.path.join(POSTS_DIR, year)), EXCLUDE):
|
||||||
|
target_file = os.path.join(POSTS_DIR, year, post_dir, "index.md")
|
||||||
|
with open(target_file, "r") as f:
|
||||||
|
post = frontmatter.loads(f.read())
|
||||||
|
try:
|
||||||
|
for tag in post.metadata["tags"]:
|
||||||
|
tags.add(tag)
|
||||||
|
categories.add(post.metadata["category"])
|
||||||
|
except KeyError:
|
||||||
|
print(f"failed to parse {target_file}")
|
||||||
|
write_tags_cache(list(tags))
|
||||||
|
write_category_cache(list(categories))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from utils.ansi import Ansi
|
||||||
|
|
||||||
|
|
||||||
|
def list_exclude(source: list, exclude: list):
|
||||||
|
return filter(lambda item: item not in exclude, source)
|
||||||
|
|
||||||
|
|
||||||
|
def eprint(msg: str):
|
||||||
|
print(Ansi.blend([Ansi.FAIL], "error: ") + msg)
|
||||||
|
|
||||||
|
|
||||||
|
def wprint(msg: str):
|
||||||
|
print(Ansi.blend([Ansi.WARNING], "warn: ") + msg)
|
||||||
Reference in new issue
Block a user