From 167602059ef1fe5e528d41b7b4c195f2deec6c2e Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sun, 1 Feb 2026 23:22:26 +0800 Subject: [PATCH 1/6] chore: Rename sphinxnotes-snippet to sphinxnotes-picker Updates project name from sphinxnotes-snippet to sphinxnotes-picker. Executed cruft command: $ cruft update --variables-to-update '{"name": "picker", "full_name": "sphinxnotes-picker", "pypi_name": "sphinxnotes-picker"}' --skip-apply-ask Co-authored-by: MiniMax --- docs/_static/custom.css | 10 + src/sphinxnotes/picker/__init__.py | 36 ++ src/sphinxnotes/picker/cache.py | 108 ++++++ src/sphinxnotes/picker/cli.py | 364 ++++++++++++++++++ src/sphinxnotes/picker/config/__init__.py | 38 ++ src/sphinxnotes/picker/config/default.py | 31 ++ src/sphinxnotes/picker/ext.py | 204 ++++++++++ .../picker/integration/__init__.py | 11 + .../picker/integration/binding.nvim | 54 +++ src/sphinxnotes/picker/integration/binding.sh | 55 +++ .../picker/integration/binding.vim | 45 +++ .../picker/integration/binding.zsh | 35 ++ src/sphinxnotes/picker/integration/plugin.sh | 23 ++ src/sphinxnotes/picker/integration/plugin.vim | 109 ++++++ src/sphinxnotes/picker/keyword.py | 104 +++++ src/sphinxnotes/picker/meta.py | 39 ++ src/sphinxnotes/picker/picker.py | 116 ++++++ src/sphinxnotes/picker/py.typed | 0 src/sphinxnotes/picker/snippets.py | 240 ++++++++++++ src/sphinxnotes/picker/table.py | 57 +++ src/sphinxnotes/picker/utils/__init__.py | 7 + src/sphinxnotes/picker/utils/ellipsis.py | 56 +++ src/sphinxnotes/picker/utils/pdict.py | 145 +++++++ src/sphinxnotes/picker/utils/titlepath.py | 61 +++ 24 files changed, 1948 insertions(+) create mode 100644 docs/_static/custom.css create mode 100644 src/sphinxnotes/picker/__init__.py create mode 100644 src/sphinxnotes/picker/cache.py create mode 100644 src/sphinxnotes/picker/cli.py create mode 100644 src/sphinxnotes/picker/config/__init__.py create mode 100644 src/sphinxnotes/picker/config/default.py create mode 100644 src/sphinxnotes/picker/ext.py create mode 100644 src/sphinxnotes/picker/integration/__init__.py create mode 100644 src/sphinxnotes/picker/integration/binding.nvim create mode 100644 src/sphinxnotes/picker/integration/binding.sh create mode 100644 src/sphinxnotes/picker/integration/binding.vim create mode 100644 src/sphinxnotes/picker/integration/binding.zsh create mode 100644 src/sphinxnotes/picker/integration/plugin.sh create mode 100644 src/sphinxnotes/picker/integration/plugin.vim create mode 100644 src/sphinxnotes/picker/keyword.py create mode 100644 src/sphinxnotes/picker/meta.py create mode 100644 src/sphinxnotes/picker/picker.py create mode 100644 src/sphinxnotes/picker/py.typed create mode 100644 src/sphinxnotes/picker/snippets.py create mode 100644 src/sphinxnotes/picker/table.py create mode 100644 src/sphinxnotes/picker/utils/__init__.py create mode 100644 src/sphinxnotes/picker/utils/ellipsis.py create mode 100644 src/sphinxnotes/picker/utils/pdict.py create mode 100644 src/sphinxnotes/picker/utils/titlepath.py diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..814ba33 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,10 @@ +/* This file is generated from sphinx-notes/cookiecutter. + * DO NOT EDIT. + */ + +/* Missing style for nodes.system_message from Alabaster. */ +.system-message { + background-color: #fda; + padding: 5px; + border: 3px solid red; +} diff --git a/src/sphinxnotes/picker/__init__.py b/src/sphinxnotes/picker/__init__.py new file mode 100644 index 0000000..23ed9e2 --- /dev/null +++ b/src/sphinxnotes/picker/__init__.py @@ -0,0 +1,36 @@ +""" +sphinxnotes.picker +~~~~~~~~~~~~~~~~~~~ + +Sphinx extension entrypoint. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + + +def setup(app): + # **WARNING**: We don't import these packages globally, because the current + # package (sphinxnotes.picker) is always resloved when importing + # sphinxnotes.picker.*. If we import packages here, eventually we will + # load a lot of packages from the Sphinx. It will seriously **SLOW DOWN** + # the startup time of our CLI tool (sphinxnotes.picker.cli). + # + # .. seealso:: https://github.com/sphinx-notes/snippet/pull/31 + from .ext import ( + SnippetBuilder, + on_config_inited, + on_env_get_outdated, + on_doctree_resolved, + on_builder_finished, + ) + + app.add_builder(SnippetBuilder) + + app.add_config_value('snippet_config', {}, '') + app.add_config_value('snippet_patterns', {'*': ['.*']}, '') + + app.connect('config-inited', on_config_inited) + app.connect('env-get-outdated', on_env_get_outdated) + app.connect('doctree-resolved', on_doctree_resolved) + app.connect('build-finished', on_builder_finished) diff --git a/src/sphinxnotes/picker/cache.py b/src/sphinxnotes/picker/cache.py new file mode 100644 index 0000000..cb93aed --- /dev/null +++ b/src/sphinxnotes/picker/cache.py @@ -0,0 +1,108 @@ +""" +sphinxnotes.picker.cache +~~~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2021 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from dataclasses import dataclass + +from .snippets import Snippet +from .utils.pdict import PDict + + +@dataclass(frozen=True) +class Item(object): + """Item of snippet cache.""" + + snippet: Snippet + tags: str + excerpt: str + titlepath: list[str] + keywords: list[str] + + +DocID = tuple[str, str] # (project, docname) +IndexID = str # UUID +Index = tuple[str, str, list[str], list[str]] # (tags, excerpt, titlepath, keywords) + + +class Cache(PDict[DocID, list[Item]]): + """A DocID -> list[Item] Cache.""" + + indexes: dict[IndexID, Index] + index_id_to_doc_id: dict[IndexID, tuple[DocID, int]] + doc_id_to_index_ids: dict[DocID, list[IndexID]] + num_snippets_by_project: dict[str, int] + num_snippets_by_docid: dict[DocID, int] + + def __init__(self, dirname: str) -> None: + self.indexes = {} + self.index_id_to_doc_id = {} + self.doc_id_to_index_ids = {} + self.num_snippets_by_project = {} + self.num_snippets_by_docid = {} + super().__init__(dirname) + + def post_dump(self, key: DocID, value: list[Item]) -> None: + """Overwrite PDict.post_dump.""" + + # Remove old indexes and index IDs if exists + for old_index_id in self.doc_id_to_index_ids.setdefault(key, []): + del self.index_id_to_doc_id[old_index_id] + del self.indexes[old_index_id] + + # Add new index to every where + for i, item in enumerate(value): + index_id = self.gen_index_id() + self.indexes[index_id] = ( + item.tags, + item.excerpt, + item.titlepath, + item.keywords, + ) + self.index_id_to_doc_id[index_id] = (key, i) + self.doc_id_to_index_ids[key].append(index_id) + + # Update statistic + if key[0] not in self.num_snippets_by_project: + self.num_snippets_by_project[key[0]] = 0 + self.num_snippets_by_project[key[0]] += len(value) + if key not in self.num_snippets_by_docid: + self.num_snippets_by_docid[key] = 0 + self.num_snippets_by_docid[key] += len(value) + + def post_purge(self, key: DocID, value: list[Item]) -> None: + """Overwrite PDict.post_purge.""" + + # Purge indexes + for index_id in self.doc_id_to_index_ids.pop(key): + del self.index_id_to_doc_id[index_id] + del self.indexes[index_id] + + # Update statistic + self.num_snippets_by_project[key[0]] -= len(value) + if self.num_snippets_by_project[key[0]] == 0: + del self.num_snippets_by_project[key[0]] + self.num_snippets_by_docid[key] -= len(value) + if self.num_snippets_by_docid[key] == 0: + del self.num_snippets_by_docid[key] + + def get_by_index_id(self, key: IndexID) -> Item | None: + """Like get(), but use IndexID as key.""" + doc_id, item_index = self.index_id_to_doc_id.get(key, (None, None)) + if not doc_id or item_index is None: + return None + return self[doc_id][item_index] + + def gen_index_id(self) -> str: + """Generate unique ID for index.""" + import uuid + + return uuid.uuid4().hex[:7] + + def stringify(self, key: DocID, value: list[Item]) -> str: + """Overwrite PDict.stringify.""" + return key[1] # docname diff --git a/src/sphinxnotes/picker/cli.py b/src/sphinxnotes/picker/cli.py new file mode 100644 index 0000000..57283a1 --- /dev/null +++ b/src/sphinxnotes/picker/cli.py @@ -0,0 +1,364 @@ +""" +sphinxnotes.picker.cli +~~~~~~~~~~~~~~~~~~~~~~~ + +Command line entrypoint. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +# **NOTE**: Import new packages with caution: +# Importing complex packages (like sphinx.*) will directly slow down the +# startup of the CLI tool. +from __future__ import annotations +import sys +import os +from os import path +import argparse +from typing import Iterable +from textwrap import dedent +from shutil import get_terminal_size +import posixpath + +from xdg.BaseDirectory import xdg_config_home + +from .snippets import Document +from .config import Config +from .cache import Cache, IndexID, Index +from .table import tablify, COLUMNS + +DEFAULT_CONFIG_FILE = path.join(xdg_config_home, 'sphinxnotes', 'snippet', 'conf.py') + + +class HelpFormatter( + argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter +): + pass + + +def get_integration_file(fn: str) -> str: + """ + Get file path of integration files. + + .. seealso:: + + see ``[tool.setuptools.package-data]`` section of pyproject.toml to know + how files are included. + """ + # TODO: use https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files + prefix = path.abspath(path.dirname(__file__)) + return path.join(prefix, 'integration', fn) + + +def main(argv: list[str] = sys.argv[1:]): + """Command line entrypoint.""" + + parser = argparse.ArgumentParser( + prog=__name__, + description='Sphinx documentation snippets manager', + formatter_class=HelpFormatter, + epilog=dedent(""" + snippet tags: + d (document) a document + s (section) a section + c (code) a code block + * (any) wildcard for any snippet"""), + ) + parser.add_argument( + '--version', + # add_argument provides action='version', but it requires a version + # literal and doesn't support lazily obtaining version. + action='store_true', + help="show program's version number and exit", + ) + parser.add_argument( + '-c', '--config', default=DEFAULT_CONFIG_FILE, help='path to configuration file' + ) + + # Init subcommands + subparsers = parser.add_subparsers() + statparser = subparsers.add_parser( + 'stat', + aliases=['s'], + formatter_class=HelpFormatter, + help='show snippets statistic information', + ) + statparser.set_defaults(func=_on_command_stat) + + listparser = subparsers.add_parser( + 'list', + aliases=['l'], + formatter_class=HelpFormatter, + help='list snippet indexes, columns of indexes: %s' % COLUMNS, + ) + listparser.add_argument( + '--tags', '-t', type=str, default='*', help='list snippets with specified tags' + ) + listparser.add_argument( + '--docname', + '-d', + type=str, + default='**', + help='list snippets whose docname matches shell-style glob pattern', + ) + listparser.add_argument( + '--width', + '-w', + type=int, + default=get_terminal_size((120, 0)).columns, + help='width in characters of output', + ) + listparser.set_defaults(func=_on_command_list) + + getparser = subparsers.add_parser( + 'get', + aliases=['g'], + formatter_class=HelpFormatter, + help='get information of snippet by index ID', + ) + getparser.add_argument( + '--docname', '-d', action='store_true', help='get docname of snippet' + ) + getparser.add_argument( + '--file', '-f', action='store_true', help='get source file path of snippet' + ) + getparser.add_argument( + '--deps', action='store_true', help='get dependent files of document' + ) + getparser.add_argument( + '--line-start', + action='store_true', + help='get line number where snippet starts in source file', + ) + getparser.add_argument( + '--line-end', + action='store_true', + help='get line number where snippet ends in source file', + ) + getparser.add_argument( + '--text', + '-t', + action='store_true', + help='get text representation of snippet', + ) + getparser.add_argument( + '--src', + action='store_true', + help='get source text of snippet', + ) + getparser.add_argument( + '--url', + '-u', + action='store_true', + help='get URL of HTML documentation of snippet', + ) + getparser.add_argument('index_id', type=str, nargs='+', help='index ID') + getparser.set_defaults(func=_on_command_get) + + igparser = subparsers.add_parser( + 'integration', + aliases=['i'], + formatter_class=HelpFormatter, + help='integration related commands', + ) + igparser.add_argument( + '--sh', '-s', action='store_true', help='dump bash shell integration script' + ) + igparser.add_argument( + '--sh-binding', action='store_true', help='dump recommended bash key binding' + ) + igparser.add_argument( + '--zsh', '-z', action='store_true', help='dump zsh integration script' + ) + igparser.add_argument( + '--zsh-binding', action='store_true', help='dump recommended zsh key binding' + ) + igparser.add_argument( + '--vim', '-v', action='store_true', help='dump (neo)vim integration script' + ) + igparser.add_argument( + '--vim-binding', action='store_true', help='dump recommended vim key binding' + ) + igparser.add_argument( + '--nvim-binding', + action='store_true', + help='dump recommended neovim key binding', + ) + igparser.set_defaults(func=_on_command_integration, parser=igparser) + + # Parse command line arguments + args = parser.parse_args(argv) + + # Print version message. + # See parser.add_argument('--version', ...) for more detais. + if args.version: + # NOTE: Importing is slow, do it on demand. + from importlib.metadata import version + + pkgname = 'sphinxnotes.picker' + print(pkgname, version(pkgname)) + parser.exit() + + # Load config from file + if args.config == DEFAULT_CONFIG_FILE and not path.isfile(DEFAULT_CONFIG_FILE): + print( + 'the default configuration file does not exist, ignore it', file=sys.stderr + ) + cfg = Config({}) + else: + cfg = Config.load(args.config) + setattr(args, 'cfg', cfg) + + # Load snippet cache + cache = Cache(cfg.cache_dir) + cache.load() + setattr(args, 'cache', cache) + + # Call subcommand + if hasattr(args, 'func'): + args.func(args) + else: + parser.print_help() + + +def _on_command_stat(args: argparse.Namespace): + cache = args.cache + + num_projects = len(cache.num_snippets_by_project) + num_docs = len(cache.num_snippets_by_docid) + num_snippets = sum(cache.num_snippets_by_project.values()) + print(f'snippets are loaded from {cache.dirname}') + print(f'configuration are loaded from {args.config}') + print(f'integration files are located at {get_integration_file("")}') + print('') + print( + f'I have {num_projects} project(s), {num_docs} documentation(s) and {num_snippets} snippet(s)' + ) + for i, v in cache.num_snippets_by_project.items(): + print(f'project {i}:') + print(f'\t {v} snippets(s)') + + +def _filter_list_items( + cache: Cache, tags: str, docname_glob: str +) -> Iterable[tuple[IndexID, Index]]: + # NOTE: Importing is slow, do it on demand. + from sphinx.util.matching import patmatch + + for index_id, index in cache.indexes.items(): + # Filter by tags. + if index[0] not in tags and '*' not in tags: + continue + # Filter by docname. + (_, docname), _ = cache.index_id_to_doc_id[index_id] + if not patmatch(docname, docname_glob): + continue + yield (index_id, index) + + +def _on_command_list(args: argparse.Namespace): + items = _filter_list_items(args.cache, args.tags, args.docname) + for row in tablify(items, args.width): + print(row) + + +def _on_command_get(args: argparse.Namespace): + # Wrapper for warning when nothing is printed + printed = False + + def p(*args, **opts): + nonlocal printed + printed = True + print(*args, **opts) + + for index_id in args.index_id: + item = args.cache.get_by_index_id(index_id) + if not item: + p('no such index ID', file=sys.stderr) + sys.exit(1) + if args.text: + p('\n'.join(item.snippet.text)) + if args.src: + p('\n'.join(item.snippet.source)) + if args.docname: + p(item.snippet.docname) + if args.file: + p(item.snippet.file) + if args.deps: + if not isinstance(item.snippet, Document): + print( + f'{type(item.snippet)} ({index_id}) is not a document', + file=sys.stderr, + ) + sys.exit(1) + if len(item.snippet.deps) == 0: + p('') # prevent print nothing warning + for dep in item.snippet.deps: + p(dep) + if args.url: + # HACK: get doc id in better way + doc_id, _ = args.cache.index_id_to_doc_id.get(index_id) + base_url = args.cfg.base_urls.get(doc_id[0]) + if not base_url: + print( + f'base URL for project {doc_id[0]} not configurated', + file=sys.stderr, + ) + sys.exit(1) + url = posixpath.join(base_url, doc_id[1] + '.html') + if item.snippet.refid: + url += '#' + item.snippet.refid + p(url) + if args.line_start: + p(item.snippet.lineno[0]) + if args.line_end: + p(item.snippet.lineno[1]) + + if not printed: + print('please specify at least one argument', file=sys.stderr) + sys.exit(1) + + +def _on_command_integration(args: argparse.Namespace): + if args.sh: + with open(get_integration_file('plugin.sh'), 'r') as f: + print(f.read()) + if args.sh_binding: + with open(get_integration_file('binding.sh'), 'r') as f: + print(f.read()) + if args.zsh: + # Zsh plugin depends on Bash shell plugin + with open(get_integration_file('plugin.sh'), 'r') as f: + print(f.read()) + if args.zsh_binding: + # Zsh binding depends on Bash shell binding + with open(get_integration_file('binding.sh'), 'r') as f: + print(f.read()) + with open(get_integration_file('binding.zsh'), 'r') as f: + print(f.read()) + if args.vim: + with open(get_integration_file('plugin.vim'), 'r') as f: + print(f.read()) + if args.vim_binding: + with open(get_integration_file('binding.vim'), 'r') as f: + print(f.read()) + if args.nvim_binding: + # NeoVim binding depends on Vim binding + with open(get_integration_file('binding.vim'), 'r') as f: + print(f.read()) + with open(get_integration_file('binding.nvim'), 'r') as f: + print(f.read()) + + +if __name__ == '__main__': + # Prevent "[Errno 32] Broken pipe" error. + # https://docs.python.org/3/library/signal.html#note-on-sigpipe + try: + sys.exit(main()) + except BrokenPipeError: + # Python flushes standard streams on exit; redirect remaining output + # to devnull to avoid another BrokenPipeError at shutdown. + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + sys.exit(1) # Python exits with error code 1 on EPIPE diff --git a/src/sphinxnotes/picker/config/__init__.py b/src/sphinxnotes/picker/config/__init__.py new file mode 100644 index 0000000..3253390 --- /dev/null +++ b/src/sphinxnotes/picker/config/__init__.py @@ -0,0 +1,38 @@ +""" +sphinxnotes.picker.config +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2021 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from typing import Dict, Any + +from . import default + + +class Config(object): + """Snippet configuration object.""" + + def __init__(self, config: Dict[str, Any]) -> None: + # Load default + self.__dict__.update(default.__dict__) + for name in config: + if name.startswith('__') and name != '__file__': + # Ignore unrelated name + continue + if name in self.__dict__.keys(): + self.__dict__[name] = config[name] + + @classmethod + def load(cls, filename: str) -> 'Config': + """Load config from configuration file""" + with open(filename, 'rb') as f: + source = f.read() + # Compile to a code object + code = compile(source, filename, 'exec') + config = {'__file__': filename} + # Compile to a code object + exec(code, config) + return cls(config) diff --git a/src/sphinxnotes/picker/config/default.py b/src/sphinxnotes/picker/config/default.py new file mode 100644 index 0000000..e4a925d --- /dev/null +++ b/src/sphinxnotes/picker/config/default.py @@ -0,0 +1,31 @@ +""" +sphinxnotes.picker.config.default +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Snippet default configuration. + +:copyright: Copyright 2021 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +# NOTE: All imported name should starts with ``__`` to distinguish from +# configuration item +from os import path as __path +from xdg.BaseDirectory import xdg_cache_home as __xdg_cache_home + +""" +``cache_dir`` + (Type: ``str``) + (Default: ``"$XDG_CACHE_HOME/sphinxnotes/snippet"``) + Path to snippet cache directory. +""" +cache_dir = __path.join(__xdg_cache_home, 'sphinxnotes', 'snippet') + +""" +``base_urls`` + (Type: ``Dict[str,str]``) + (Default: ``{}``) + A "project name" -> "base URL" mapping. + Base URL is used to generate snippet URL. +""" +base_urls = {} diff --git a/src/sphinxnotes/picker/ext.py b/src/sphinxnotes/picker/ext.py new file mode 100644 index 0000000..8e248b7 --- /dev/null +++ b/src/sphinxnotes/picker/ext.py @@ -0,0 +1,204 @@ +""" +sphinxnotes.picker.ext +~~~~~~~~~~~~~~~~~~~~~~~ + +Sphinx extension implementation, but the entrypoint is located at __init__.py. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING +import re +from os import path +import time + +from docutils import nodes +from sphinx.locale import __ +from sphinx.util import logging +from sphinx.builders.dummy import DummyBuilder + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + from sphinx.config import Config as SphinxConfig + from collections.abc import Iterator + +from .config import Config +from .snippets import Snippet, WithTitle, Document, Section, Code +from .picker import pick +from .cache import Cache, Item +from .keyword import Extractor +from .utils import titlepath + + +logger = logging.getLogger(__name__) + +cache: Cache | None = None +extractor: Extractor = Extractor() + + +def extract_tags(s: Snippet) -> str: + tags = '' + if isinstance(s, Document): + tags += 'd' + elif isinstance(s, Section): + tags += 's' + elif isinstance(s, Code): + tags += 'c' + return tags + + +def extract_excerpt(s: Snippet) -> str: + if isinstance(s, Document) and s.title is not None: + return '<' + s.title + '>' + elif isinstance(s, Section) and s.title is not None: + return '[' + s.title + ']' + elif isinstance(s, Code): + return '`' + (s.lang + ':').ljust(8, ' ') + ' ' + s.desc + '`' + return '' + + +def extract_keywords(s: Snippet) -> list[str]: + keywords = [s.docname] + if isinstance(s, WithTitle) and s.title is not None: + keywords.extend(extractor.extract(s.title)) + if isinstance(s, Code): + keywords.extend(extractor.extract(s.desc)) + return keywords + + +def _get_document_allowed_tags(pats: dict[str, list[str]], docname: str) -> str: + """Return the tags of snippets that are allowed to be picked from the document.""" + allowed_tags = '' + for tags, ps in pats.items(): + for pat in ps: + if re.match(pat, docname): + allowed_tags += tags + return allowed_tags + + +def on_config_inited(app: Sphinx, appcfg: SphinxConfig) -> None: + global cache + cfg = Config(appcfg.snippet_config) + cache = Cache(cfg.cache_dir) + + try: + cache.load() + except Exception as e: + logger.warning('[snippet] failed to laod cache: %s' % e) + + +def on_env_get_outdated( + app: Sphinx, + env: BuildEnvironment, + added: set[str], + changed: set[str], + removed: set[str], +) -> list[str]: + # Remove purged indexes and snippetes from db + assert cache is not None + for docname in removed: + del cache[(app.config.project, docname)] + return [] + + +def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> None: + if not isinstance(doctree, nodes.document): + # XXX: It may caused by ablog + logger.debug( + '[snippet] node %s is not nodes.document', type(doctree), location=doctree + ) + return + + allowed_tags = _get_document_allowed_tags(app.config.snippet_patterns, docname) + if not allowed_tags: + logger.debug('[snippet] skip picking: no tag allowed for document %s', docname) + return + + doc = [] + snippets = pick(app, doctree, docname) + tags = [] + for s, n in snippets: + # FIXME: Better filter logic. + tags.append(extract_tags(s)) + if tags[-1] not in allowed_tags: + continue + tpath = [x.astext() for x in titlepath.resolve(app.env, docname, n)] + if isinstance(s, Section): + tpath = tpath[1:] + doc.append( + Item( + snippet=s, + tags=extract_tags(s), + excerpt=extract_excerpt(s), + keywords=extract_keywords(s), + titlepath=tpath, + ) + ) + + cache_key = (app.config.project, docname) + assert cache is not None + if len(doc) != 0: + cache[cache_key] = doc + elif cache_key in cache: + del cache[cache_key] + + logger.debug( + '[snippet] picked %s/%s snippets in %s, tags: %s, allowed tags: %s', + len(doc), + len(snippets), + docname, + tags, + allowed_tags, + ) + + +def on_builder_finished(app: Sphinx, exception) -> None: + assert cache is not None + cache.dump() + + +class SnippetBuilder(DummyBuilder): # DummyBuilder has dummy impls we need. + name = 'snippet' + epilog = __( + 'The snippet builder produces snippets (not to OUTPUTDIR) for use by snippet CLI tool' + ) + + def get_outdated_docs(self) -> Iterator[str]: + """Modified from :py:meth:`sphinx.builders.html.StandaloneHTMLBuilder.get_outdated_docs`.""" + for docname in self.env.found_docs: + if docname not in self.env.all_docs: + logger.debug('[build target] did not in env: %r', docname) + yield docname + continue + + assert cache is not None + targetname = cache.itemfile((self.app.config.project, docname)) + try: + targetmtime = path.getmtime(targetname) + except Exception: + targetmtime = 0 + try: + srcmtime = path.getmtime(self.env.doc2path(docname)) + if srcmtime > targetmtime: + logger.debug( + '[build target] targetname %r(%s), docname %r(%s)', + targetname, + _format_modified_time(targetmtime), + docname, + _format_modified_time( + path.getmtime(self.env.doc2path(docname)) + ), + ) + yield docname + except OSError: + # source doesn't exist anymore + pass + + +def _format_modified_time(timestamp: float) -> str: + """Return an RFC 3339 formatted string representing the given timestamp.""" + seconds, fraction = divmod(timestamp, 1) + return time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(seconds)) + f'.{fraction:.3f}' diff --git a/src/sphinxnotes/picker/integration/__init__.py b/src/sphinxnotes/picker/integration/__init__.py new file mode 100644 index 0000000..0350c10 --- /dev/null +++ b/src/sphinxnotes/picker/integration/__init__.py @@ -0,0 +1,11 @@ +""" +sphinxnotes.picker.integration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Dummpy module for including package_data. + +See also ``[tool.setuptools.package-data]`` section of pyproject.toml. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" diff --git a/src/sphinxnotes/picker/integration/binding.nvim b/src/sphinxnotes/picker/integration/binding.nvim new file mode 100644 index 0000000..125dd0d --- /dev/null +++ b/src/sphinxnotes/picker/integration/binding.nvim @@ -0,0 +1,54 @@ +" NeoVim key binding for sphinxnotes-picker +" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +" +" :Author: Shengyu Zhang +" :Date: 2021-11-14 +" :Version: 20211114 +" +" TODO: Support vim? + +function! g:SphinxNotesSnippetListAndView() + function! ListAndView_CB(id) + call g:SphinxNotesSnippetView(a:id) + endfunction + call g:SphinxNotesSnippetList('"*"', function('ListAndView_CB')) +endfunction + +" https://github.com/anhmv/vim-float-window/blob/master/plugin/float-window.vim +function! g:SphinxNotesSnippetView(id) + let height = float2nr((&lines - 2) / 1.5) + let row = float2nr((&lines - height) / 2) + let width = float2nr(&columns / 1.5) + let col = float2nr((&columns - width) / 2) + + " Main Window + let opts = { + \ 'relative': 'editor', + \ 'style': 'minimal', + \ 'width': width, + \ 'height': height, + \ 'col': col, + \ 'row': row, + \ } + + let buf = nvim_create_buf(v:false, v:true) + " Global for :call + let g:sphinx_notes_snippet_win = nvim_open_win(buf, v:true, opts) + + " The content is always reStructuredText for now + set filetype=rst + " Press enter to return + nmap :call nvim_win_close(g:sphinx_notes_snippet_win, v:true) + + let cmd = [s:snippet, 'get', '--src', a:id] + call append(line('$'), ['.. hint:: Press to return']) + execute '$read !' . '..' + execute '$read !' . join(cmd, ' ') + execute '$read !' . '..' + call append(line('$'), ['.. hint:: Press to return']) +endfunction + +nmap v :call g:SphinxNotesSnippetListAndView() + +" vim: set shiftwidth=2: +" vim: set ft=vim: diff --git a/src/sphinxnotes/picker/integration/binding.sh b/src/sphinxnotes/picker/integration/binding.sh new file mode 100644 index 0000000..38b5d25 --- /dev/null +++ b/src/sphinxnotes/picker/integration/binding.sh @@ -0,0 +1,55 @@ +# Bash Shell key binding for sphinxnotes-picker +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# :Author: Shengyu Zhang +# :Date: 2021-08-14 +# :Version: 20240828 + +function snippet_view() { + selection=$(snippet_list) + [ -z "$selection" ] && return + + # Make sure we have $PAGER + if [ -z "$PAGER" ]; then + if [ ! -z "$(where less)" ]; then + PAGER='less' + else + PAGER='cat' + fi + fi + + echo "$SNIPPET get --src $selection | $PAGER" +} + +function snippet_edit() { + selection=$(snippet_list --tags ds) + [ -z "$selection" ] && return + + echo "vim +\$($SNIPPET get --line-start $selection) \$($SNIPPET get --file $selection)" +} + +function snippet_url() { + selection=$(snippet_list --tags ds) + [ -z "$selection" ] && return + + echo "xdg-open \$($SNIPPET get --url $selection)" +} + +function snippet_sh_bind_wrapper() { + cmd=$($1) + if [ ! -z "$cmd" ]; then + eval "$cmd" + fi +} + +function snippet_sh_do_bind() { + bind -x '"\C-kv": snippet_sh_bind_wrapper snippet_view' + bind -x '"\C-ke": snippet_sh_bind_wrapper snippet_edit' + bind -x '"\C-ku": snippet_sh_bind_wrapper snippet_url' +} + +# Bind key if bind command exists +# (the script may sourced by Zsh) +command -v bind 2>&1 1>/dev/null && snippet_sh_do_bind + +# vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/binding.vim b/src/sphinxnotes/picker/integration/binding.vim new file mode 100644 index 0000000..b921aa5 --- /dev/null +++ b/src/sphinxnotes/picker/integration/binding.vim @@ -0,0 +1,45 @@ +" Vim key binding for sphinxnotes-picker +" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +" +" :Author: Shengyu Zhang +" :Date: 2021-04-12 +" :Version: 20211114 +" + +function g:SphinxNotesSnippetEdit(id) + let file = g:SphinxNotesSnippetGet(a:id, 'file')[0] + let line = g:SphinxNotesSnippetGet(a:id, 'line-start')[0] + if &modified + execute 'vsplit ' . file + else + execute 'edit ' . file + endif + execute line +endfunction + +function g:SphinxNotesSnippetListAndEdit() + function! ListAndEdit_CB(id) + call g:SphinxNotesSnippetEdit(a:id) + endfunction + call g:SphinxNotesSnippetList('ds', function('ListAndEdit_CB')) +endfunction + +function g:SphinxNotesSnippetUrl(id) + let url_list = g:SphinxNotesSnippetGet(a:id, 'url') + for url in url_list + echo system('xdg-open ' . shellescape(url)) + endfor +endfunction + +function g:SphinxNotesSnippetListAndUrl() + function! ListAndUrl_CB(id) + call g:SphinxNotesSnippetUrl(a:id) + endfunction + call g:SphinxNotesSnippetList('ds', function('ListAndUrl_CB')) +endfunction + +nmap e :call g:SphinxNotesSnippetListAndEdit() +nmap u :call g:SphinxNotesSnippetListAndUrl() +nmap i :call g:SphinxNotesSnippetListAndInput() + +" vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/binding.zsh b/src/sphinxnotes/picker/integration/binding.zsh new file mode 100644 index 0000000..f787abb --- /dev/null +++ b/src/sphinxnotes/picker/integration/binding.zsh @@ -0,0 +1,35 @@ +# Z Shell key binding for sphinxnotes-picker +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# :Author: Shengyu Zhang +# :Date: 2021-04-12 +# :Version: 20211114 + +# $1: One of snippet_* functions +function snippet_z_bind_wrapper() { + snippet_sh_bind_wrapper $1 + zle redisplay +} + +function snippet_z_view() { + snippet_z_bind_wrapper snippet_view +} + +function snippet_z_edit() { + snippet_z_bind_wrapper snippet_edit +} + +function snippet_z_url() { + snippet_z_bind_wrapper snippet_url +} + +# Define widgets +zle -N snippet_z_view +zle -N snippet_z_edit +zle -N snippet_z_url + +bindkey '^kv' snippet_z_view +bindkey '^ke' snippet_z_edit +bindkey '^ku' snippet_z_url + +# vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/plugin.sh b/src/sphinxnotes/picker/integration/plugin.sh new file mode 100644 index 0000000..24cd99b --- /dev/null +++ b/src/sphinxnotes/picker/integration/plugin.sh @@ -0,0 +1,23 @@ +# Bash Shell integration for sphinxnotes-picker +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# :Author: Shengyu Zhang +# :Date: 2021-03-20 +# :Version: 20240828 + +# Make sure we have $SNIPPET +[ -z "$SNIPPET"] && SNIPPET='snippet' + +# Arguments: $*: Extra opts of ``snippet list`` +# Returns: snippet_id +function snippet_list() { + $SNIPPET list --width $(($(tput cols) - 2)) "$@" | \ + fzf --with-nth 2.. \ + --no-hscroll \ + --header-lines 1 \ + --margin=2 \ + --border=rounded \ + --height=60% | cut -d ' ' -f1 +} + +# vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/plugin.vim b/src/sphinxnotes/picker/integration/plugin.vim new file mode 100644 index 0000000..4e03a73 --- /dev/null +++ b/src/sphinxnotes/picker/integration/plugin.vim @@ -0,0 +1,109 @@ +" Vim integration for sphinxnotes-picker +" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +" +" :Author: Shengyu Zhang +" :Date: 2021-04-01 +" :Version: 20211114 +" +" NOTE: junegunn/fzf.vim is required + +let s:snippet = 'snippet' +let s:width = 0.9 +let s:height = 0.6 + +" Use fzf to list all snippets, callback with argument id. +function g:SphinxNotesSnippetList(tags, callback) + let cmd = [s:snippet, 'list', + \ '--tags', a:tags, + \ '--width', float2nr(&columns * s:width) - 2, + \ ] + + " Use closure keyword so that inner function can access outer one's + " localvars (l:) and arguments (a:). + " https://vi.stackexchange.com/a/21807 + function! List_CB(selection) closure + let id = split(a:selection, ' ')[0] + call a:callback(id) + endfunction + + " https://github.com/junegunn/fzf/blob/master/README-VIM.md#fzfrun + call fzf#run({ + \ 'source': join(cmd, ' '), + \ 'sink': function('List_CB'), + \ 'options': ['--with-nth', '2..', '--no-hscroll', '--header-lines', '1'], + \ 'window': {'width': s:width, 'height': s:height}, + \ }) +endfunction + +" Return the attribute value of specific snippet. +function g:SphinxNotesSnippetGet(id, attr) + let cmd = [s:snippet, 'get', a:id, '--' . a:attr] + return systemlist(join(cmd, ' ')) +endfunction + +" Use fzf to list all attr of specific snippet, +" callback with arguments (attr_name, attr_value). +function g:SphinxNotesSnippetListSnippetAttrs(id, callback) + " Display attr -> Identify attr (also used as CLI option) + let attrs = { + \ 'Source': 'src', + \ 'URL': 'url', + \ 'Docname': 'docname', + \ 'Dependent files': 'deps', + \ 'Text': 'text', + \ 'Title': 'title', + \ } + let delim = ' ' + let table = ['OPTION' . delim . 'ATTRIBUTE'] + for name in keys(attrs) + call add(table, attrs[name] . delim . name) + endfor + + function! ListSnippetAttrs_CB(selection) closure + let opt = split(a:selection, ' ')[0] + let val = g:SphinxNotesSnippetGet(a:id, opt) + call a:callback(opt, val) " finally call user's cb + endfunction + + let preview_cmd = [s:snippet, 'get', a:id, '--$(echo {} | cut -d " " -f1)'] + let info_cmd = ['echo', 'Index ID:', a:id] + call fzf#run({ + \ 'source': table, + \ 'sink': function('ListSnippetAttrs_CB'), + \ 'options': [ + \ '--header-lines', '1', + \ '--with-nth', '2..', + \ '--preview', join(preview_cmd, ' '), + \ '--preview-window', ',wrap', + \ '--info-command', join(info_cmd, ' '), + \ ], + \ 'window': {'width': s:width, 'height': s:height}, + \ }) +endfunction + +function g:SphinxNotesSnippetInput(id) + function! Input_CB(attr, val) " TODO: became g:func. + if a:attr == 'docname' + " Create doc reference. + let content = ':doc:`/' . a:val[0] . '`' + elseif a:attr == 'title' + " Create local section reference. + let content = '`' . a:val[0] . '`_' + else + let content = join(a:val, '') + endif + execute 'normal! i' . content + endfunction + + call g:SphinxNotesSnippetListSnippetAttrs(a:id, function('Input_CB')) +endfunction + +function g:SphinxNotesSnippetListAndInput() + function! ListAndInput_CB(id) + call g:SphinxNotesSnippetInput(a:id) + endfunction + + call g:SphinxNotesSnippetList('"*"', function('ListAndInput_CB')) +endfunction + + " vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/keyword.py b/src/sphinxnotes/picker/keyword.py new file mode 100644 index 0000000..5019fa7 --- /dev/null +++ b/src/sphinxnotes/picker/keyword.py @@ -0,0 +1,104 @@ +""" +sphinxnotes.picker.keyword +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Helper functions for keywords extraction. + +:copyright: Copyright 2021 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +import string +from collections import Counter + + +class Extractor(object): + """ + Keyword extractor based on frequency statistic. + + TODO: extract date, time + """ + + def __init__(self): + # Import NLP libs here to prevent import overhead + import logging + from langid import rank + from jieba_next import cut_for_search, setLogLevel + from pypinyin import lazy_pinyin + from wordsegment import load, segment + + # Turn off jieba debug log. + # https://github.com/fxsjy/jieba/issues/255 + setLogLevel(logging.INFO) + + load() + self._detect_langs = rank + self._tokenize_zh_cn = cut_for_search + self._tokenize_en = segment + self._pinyin = lazy_pinyin + + self._punctuation = ( + string.punctuation + + '!?。。"#$%&'()*+,-/:;<=>@[\]^_`{|}~⦅⦆「」、、〃》「」『』【】〔〕〖〗〘〙〚〛〜〝〞〟〰〾〿–—‘’‛“”„‟…‧﹏.·' + ) + + def extract(self, text: str, top_n: int | None = None) -> list[str]: + """Return keywords of given text.""" + # TODO: zh -> en + # Normalize + text = self.normalize(text) + # Tokenize + words = self.tokenize(text) + # Invalid token removal + words = self.strip_invalid_token(words) + # Stopwords removal + if top_n: + # Get top n words as keyword + keywords = Counter(words).most_common(top_n) + # Remove rank + keywords = [word for word, _ in keywords] + else: + # Keep all keywords + keywords = words + # Generate a pinyin version + keywords_pinyin = [] + for word in keywords: + pinyin = self.trans_to_pinyin(word) + if pinyin: + keywords_pinyin.append(pinyin) + return keywords + keywords_pinyin + + def normalize(self, text: str) -> str: + # Convert text to lowercase + text = text.lower() + # Remove punctuation (both english and chinese) + text = text.translate(text.maketrans('', '', self._punctuation)) + # White spaces removals + text = text.strip() + # Replace newline to whitespace + text = text.replace('\n', ' ') + return text + + def tokenize(self, text: str) -> list[str]: + # Get top most 5 langs + langs = self._detect_langs(text)[:5] + tokens = [text] + new_tokens = [] + for lang in langs: + for token in tokens: + if lang[0] == 'zh': + new_tokens += self._tokenize_zh_cn(token) + elif lang[0] == 'en': + new_tokens += self._tokenize_en(token) + else: + new_tokens += token.split(' ') + tokens = new_tokens + new_tokens = [] + return tokens + + def trans_to_pinyin(self, word: str) -> str | None: + return ' '.join(self._pinyin(word, errors='ignore')) + + def strip_invalid_token(self, tokens: list[str]) -> list[str]: + return [token for token in tokens if token != ''] diff --git a/src/sphinxnotes/picker/meta.py b/src/sphinxnotes/picker/meta.py new file mode 100644 index 0000000..05d0066 --- /dev/null +++ b/src/sphinxnotes/picker/meta.py @@ -0,0 +1,39 @@ +# This file is generated from sphinx-notes/cookiecutter. +# DO NOT EDIT!!! + +################################################################################ +# Project meta infos. +################################################################################ + +from __future__ import annotations +from importlib import metadata + +from sphinx.application import Sphinx +from sphinx.util.typing import ExtensionMetadata + + +__project__ = 'sphinxnotes-picker' +__author__ = 'Shengyu Zhang' +__desc__ = 'Sphinx documentation snippets manager' + +try: + __version__ = metadata.version('sphinxnotes-picker') +except metadata.PackageNotFoundError: + __version__ = 'unknown' + + +################################################################################ +# Sphinx extension utils. +################################################################################ + + +def pre_setup(app: Sphinx) -> None: + app.require_sphinx('7.0') + + +def post_setup(app: Sphinx) -> ExtensionMetadata: + return { + 'version': __version__, + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/src/sphinxnotes/picker/picker.py b/src/sphinxnotes/picker/picker.py new file mode 100644 index 0000000..89e529c --- /dev/null +++ b/src/sphinxnotes/picker/picker.py @@ -0,0 +1,116 @@ +""" +sphinxnotes.picker.picker +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Snippet picker implementations. + +:copyright: Copyright 2020 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +from docutils import nodes + +from sphinx.util import logging + +from .snippets import Snippet, Section, Document, Code + +if TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + + +def pick( + app: Sphinx, doctree: nodes.document, docname: str +) -> list[tuple[Snippet, nodes.Element]]: + """ + Pick snippets from document, return a list of snippet and the related node. + + As :class:`Snippet` can not hold any refs to doctree, we additionly returns + the related nodes here. To ensure the caller can back reference to original + document node and do more things (e.g. generate title path). + """ + # FIXME: Why doctree.source is always None? + if not doctree.attributes.get('source'): + logger.debug('Skip document %s: no source', docname) + return [] + + metadata = app.env.metadata.get(docname, {}) + if 'no-search' in metadata or 'nosearch' in metadata: + logger.debug('Skip document %s: have :no[-]search: metadata', docname) + return [] + + # Walk doctree and pick snippets. + + picker = SnippetPicker(doctree) + doctree.walkabout(picker) + + return picker.snippets + + +class SnippetPicker(nodes.SparseNodeVisitor): + """Node visitor for picking snippets from document.""" + + #: List of picked snippets and the section it belongs to + snippets: list[tuple[Snippet, nodes.Element]] + + #: Stack of nested sections. + _sections: list[nodes.section] + + def __init__(self, doctree: nodes.document) -> None: + super().__init__(doctree) + self.snippets = [] + self._sections = [] + + ################### + # Visitor methods # + ################### + + def visit_literal_block(self, node: nodes.literal_block) -> None: + try: + code = Code(node) + except ValueError as e: + logger.debug(f'skip {node}: {e}') + raise nodes.SkipNode + self.snippets.append((code, node)) + + def visit_section(self, node: nodes.section) -> None: + self._sections.append(node) + + def depart_section(self, node: nodes.section) -> None: + section = self._sections.pop() + assert section == node + + # Always pick document. + if len(self._sections) == 0: + self.snippets.append((Document(self.document), node)) + return + # Skip non-leaf section without content + if self._is_empty_non_leaf_section(node): + return + self.snippets.append((Section(node), node)) + + def unknown_visit(self, node: nodes.Node) -> None: + pass # Ignore any unknown node + + def unknown_departure(self, node: nodes.Node) -> None: + pass # Ignore any unknown node + + ################## + # Helper methods # + ################## + + def _is_empty_non_leaf_section(self, node: nodes.section) -> bool: + """ + A section is a leaf section it has non-child section. + A section is empty when it has not non-section child node + (except the title). + """ + num_subsection = len( + list(node[0].traverse(nodes.section, descend=False, siblings=True)) + ) + num_nonsection_child = len(node) - num_subsection - 1 # -1 for title + return num_subsection != 0 and num_nonsection_child == 0 diff --git a/src/sphinxnotes/picker/py.typed b/src/sphinxnotes/picker/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sphinxnotes/picker/snippets.py b/src/sphinxnotes/picker/snippets.py new file mode 100644 index 0000000..cba60c2 --- /dev/null +++ b/src/sphinxnotes/picker/snippets.py @@ -0,0 +1,240 @@ +""" +sphinxnotes.picker.snippets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Definitions of various snippets. + +:copyright: Copyright 2024 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING +import itertools +from os import path +import sys +from pygments.lexers.shell import BashSessionLexer + +from docutils import nodes + +if TYPE_CHECKING: + from sphinx.environment import BuildEnvironment + + +class Snippet(object): + """ + Snippet is structured fragments extracted from a single Sphinx document + (usually, also a single reStructuredText file). + + :param nodes: nodes of doctree that make up this snippet. + + .. warning:: + + Snippet will be persisted to disk via pickle, to keep it simple, + it CAN NOT holds reference to any doctree ``nodes`` + (or even any non-std module). + """ + + #: docname where the snippet is located, can be referenced by + # :rst:role:`doc`. + docname: str + + #: Absolute path to the source file. + file: str + + #: Line number range of source file (:attr:`Snippet.file`), + #: left closed and right opened. + lineno: tuple[int, int] + + #: The source text read from source file (:attr:`Snippet.file`), + # in Markdown or reStructuredText. + source: list[str] + + #: Text representation of the snippet, usually generated form + # :meth:`nodes.Element.astext`. + text: list[str] + + #: The possible identifier key of snippet, which is picked from nodes' + #: (or nodes' parent's) `ids attr`_. + #: + #: .. _ids attr: https://docutils.sourceforge.io/docs/ref/doctree.html#ids + refid: str | None + + def __init__(self, *nodes: nodes.Element) -> None: + assert len(nodes) != 0 + + env: BuildEnvironment = nodes[0].document.settings.env # type: ignore + + file, docname = None, None + for node in nodes: + if (src := nodes[0].source) and path.exists(src): + file = src + docname = env.path2doc(file) + break + if not file or not docname: + raise ValueError(f'Nodes {nodes} lacks source file or docname') + self.file = file + self.docname = docname + + lineno = [sys.maxsize, -sys.maxsize] + for node in nodes: + if not node.line: + continue # Skip node that have None line, I dont know why + lineno[0] = min(lineno[0], _line_of_start(node)) + lineno[1] = max(lineno[1], _line_of_end(node)) + self.lineno = (lineno[0], lineno[1]) + + source = [] + with open(self.file, 'r') as f: + start = self.lineno[0] - 1 + stop = self.lineno[1] - 1 + for line in itertools.islice(f, start, stop): + source.append(line.strip('\n')) + self.source = source + + text = [] + for node in nodes: + text.extend(node.astext().split('\n')) + self.text = text + + # Find exactly one ID attr in nodes + self.refid = None + for node in nodes: + if node['ids']: + self.refid = node['ids'][0] + break + + # If no ID found, try parent + if not self.refid: + for node in nodes: + if node.parent['ids']: + self.refid = node.parent['ids'][0] + break + + +class Code(Snippet): + #: Language of code block + lang: str + #: Description of code block, usually the text of preceding paragraph + desc: str + + def __init__(self, node: nodes.literal_block) -> None: + assert isinstance(node, nodes.literal_block) + + self.lang = node['language'] + if self.lang not in BashSessionLexer.aliases: # TODO: support more language + raise ValueError( + f'Language {self.lang} is not supported', + ) + + self.desc = '' + # Use the preceding paragraph as descritpion. We usually write some + # descritpions before a code block. For example, The ``::`` syntax is + # a common way to create code block:: + # + # | Foo:: | + # | | Foo: + # | Bar | + # | | Bar + # + # In this case, the paragraph "Foo:" is the descritpion of the code block. + # This convention also applies to the code, code-block, sourcecode directive. + if isinstance(para := node.previous_sibling(), nodes.paragraph): + # For better display, the trailing colon is removed. + # TODO: https://en.wikipedia.org/wiki/Colon_(punctuation)#Computing + self.desc += para.astext().replace('\n', ' ').rstrip(':::︁︓﹕') + if caption := node.get('caption'): + # Use caption as descritpion. + # All of code-block, sourcecode and code directives have caption option. + # https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block + self.desc += caption + if not self.desc: + raise ValueError( + f'Node f{node} lacks description: a preceding paragraph or a caption' + ) + + if isinstance(para, nodes.paragraph): + # If we have a paragraph preceding code block, include it. + super().__init__(para, node) + # Fixup text field, it should be pure code. + self.text = node.astext().split('\n') + else: + super().__init__(node) + + +class WithTitle(object): + title: str + + def __init__(self, node: nodes.Element) -> None: + if not (title := node.next_node(nodes.title)): + raise ValueError(f'Node f{node} lacks title') + self.title = title.astext() + + +class Section(Snippet, WithTitle): + def __init__(self, node: nodes.section) -> None: + assert isinstance(node, nodes.section) + Snippet.__init__(self, node) + WithTitle.__init__(self, node) + + +class Document(Section): + #: A set of absolute paths of dependent files for document. + #: Obtained from :attr:`BuildEnvironment.dependencies`. + deps: set[str] + + def __init__(self, node: nodes.document) -> None: + assert isinstance(node, nodes.document) + super().__init__(node.next_node(nodes.section)) + + # Record document's dependent files + self.deps = set() + env: BuildEnvironment = node.settings.env + for dep in env.dependencies[self.docname]: + # Relative to documentation root -> Absolute path of file system. + self.deps.add(path.join(env.srcdir, dep)) + + +################ +# Nodes helper # +################ + + +def _line_of_start(node: nodes.Node) -> int: + assert node.line + if isinstance(node, nodes.title): + if isinstance(node.parent.parent, nodes.document): + # Spceial case for Document Title / Subtitle + return 1 + else: + # Spceial case for section title + return node.line - 1 + elif isinstance(node, nodes.section): + if isinstance(node.parent, nodes.document): + # Spceial case for top level section + return 1 + else: + # Spceial case for section + return node.line - 1 + return node.line + + +def _line_of_end(node: nodes.Node) -> int: + next_node = node.next_node(descend=False, siblings=True, ascend=True) + while next_node: + if next_node.line: + return _line_of_start(next_node) + next_node = next_node.next_node( + # Some nodes' line attr is always None, but their children has + # valid line attr + descend=True, + # If node and its children have not valid line attr, try use line + # of next node + ascend=True, + siblings=True, + ) + # No line found, return the max line of source file + if node.source and path.exists(node.source): + with open(node.source) as f: + return sum(1 for _ in f) + raise AttributeError('None source attr of node %s' % node) diff --git a/src/sphinxnotes/picker/table.py b/src/sphinxnotes/picker/table.py new file mode 100644 index 0000000..4bbc0cf --- /dev/null +++ b/src/sphinxnotes/picker/table.py @@ -0,0 +1,57 @@ +""" +sphinxnotes.picker.table +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2021 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from typing import Iterable + +from .cache import Index, IndexID +from .utils import ellipsis + +COLUMNS = ['id', 'tags', 'excerpt', 'path', 'keywords'] +VISIABLE_COLUMNS = COLUMNS[1:4] +COLUMN_DELIMITER = ' ' + + +def tablify(indexes: Iterable[tuple[IndexID, Index]], width: int) -> Iterable[str]: + """Create a table from sequence of indices""" + + # Calcuate width + width = width + tags_width = len(VISIABLE_COLUMNS[0]) + width -= tags_width + excerpt_width = max(int(width * 6 / 10), len(VISIABLE_COLUMNS[1])) + path_width = max(int(width * 4 / 10), len(VISIABLE_COLUMNS[2])) + path_comp_width = path_width // 3 + + # Write header + header = COLUMN_DELIMITER.join( + [ + COLUMNS[0].upper(), + ellipsis.ellipsis(COLUMNS[1].upper(), tags_width, blank_sym=' '), + ellipsis.ellipsis(COLUMNS[2].upper(), excerpt_width, blank_sym=' '), + ellipsis.ellipsis(COLUMNS[3].upper(), path_width, blank_sym=' '), + COLUMNS[4].upper(), + ] + ) + yield header + + # Write rows + for index_id, index in indexes: + # TODO: assert index? + row = COLUMN_DELIMITER.join( + [ + index_id, # ID + ellipsis.ellipsis(f'[{index[0]}]', tags_width, blank_sym=' '), # Kind + ellipsis.ellipsis(index[1], excerpt_width, blank_sym=' '), # Excerpt + ellipsis.join( + index[2], path_width, path_comp_width, blank_sym=' ' + ), # Titleppath + ','.join(index[3]), + ] + ) # Keywords + yield row diff --git a/src/sphinxnotes/picker/utils/__init__.py b/src/sphinxnotes/picker/utils/__init__.py new file mode 100644 index 0000000..64770a8 --- /dev/null +++ b/src/sphinxnotes/picker/utils/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +""" +sphinxnotes.utils +~~~~~~~~~~~~~~~~~ + +:copyright: Copyright 2020 by the Shengyu Zhang. +""" diff --git a/src/sphinxnotes/picker/utils/ellipsis.py b/src/sphinxnotes/picker/utils/ellipsis.py new file mode 100644 index 0000000..d46bda8 --- /dev/null +++ b/src/sphinxnotes/picker/utils/ellipsis.py @@ -0,0 +1,56 @@ +""" +sphinxnotes.utils.ellipsis +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Utils for ellipsis string. + +:copyright: Copyright 2020 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from wcwidth import wcswidth + + +def ellipsis( + text: str, width: int, ellipsis_sym: str = '..', blank_sym: str | None = None +) -> str: + text_width = wcswidth(text) + if text_width <= width: + if blank_sym: + # Padding with blank_sym + text += blank_sym * ((width - text_width) // wcswidth(blank_sym)) + return text + width -= wcswidth(ellipsis_sym) + if width > text_width: + width = text_width + i = 0 + new_text = '' + while wcswidth(new_text) < width: + new_text += text[i] + i += 1 + return new_text + ellipsis_sym + + +def join( + lst: list[str], + total_width: int, + title_width: int, + separate_sym: str = '/', + ellipsis_sym: str = '..', + blank_sym: str | None = None, +): + # TODO: position + total_width -= wcswidth(ellipsis_sym) + result = [] + for i, ln in enumerate(lst): + ln = ellipsis(ln, title_width, ellipsis_sym=ellipsis_sym, blank_sym=None) + l_width = wcswidth(ln) + (wcswidth(separate_sym) if i != 0 else 0) + if total_width - l_width < 0: + break + result.append(ln) + total_width -= l_width + s = separate_sym.join(result) + if blank_sym: + s += blank_sym * (total_width // wcswidth(blank_sym)) + return s diff --git a/src/sphinxnotes/picker/utils/pdict.py b/src/sphinxnotes/picker/utils/pdict.py new file mode 100644 index 0000000..ef93a38 --- /dev/null +++ b/src/sphinxnotes/picker/utils/pdict.py @@ -0,0 +1,145 @@ +""" +sphinxnotes.utils.pdict +~~~~~~~~~~~~~~~~~~~~~~~ + +A customized persistent KV store for Sphinx project. + +:copyright: Copyright 2020 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +import os +from os import path +from typing import Iterator, TypeVar +import pickle +from collections.abc import MutableMapping +from hashlib import sha1 + +K = TypeVar('K') +V = TypeVar('V') + + +# FIXME: PDict is buggy +class PDict(MutableMapping[K, V]): + """A persistent dict with event handlers.""" + + dirname: str + # The real in memory store of values + _store: dict[K, V | None] + # Items that need write back to store + _dirty_items: dict[K, V] + # Items that need purge from store + _orphan_items: dict[K, V] + + def __init__(self, dirname: str) -> None: + self.dirname = dirname + self._store = {} + self._dirty_items = {} + self._orphan_items = {} + + def __getitem__(self, key: K) -> V: + if key not in self._store: + raise KeyError + value = self._store[key] + if value is not None: + return value + # V haven't loaded yet, load it from disk + with open(self.itemfile(key), 'rb') as f: + value = pickle.load(f) + self._store[key] = value + return value + + def __setitem__(self, key: K, value: V) -> None: + assert value is not None + if key in self._store: + self.__delitem__(key) + self._dirty_items[key] = value + self._store[key] = value + + def __delitem__(self, key: K) -> None: + value = self.__getitem__(key) + del self._store[key] + if key in self._dirty_items: + del self._dirty_items[key] + else: + self._orphan_items[key] = value + + def __iter__(self) -> Iterator: + return iter(self._store) + + def __len__(self) -> int: + return len(self._store) + + def _keytransform(self, key: K) -> K: + # No used + return key + + def load(self) -> None: + with open(self.dictfile(), 'rb') as f: + obj = pickle.load(f) + self.__dict__.update(obj.__dict__) + + def dump(self): + """Dump store to disk.""" + # sphinx.util.status_iterator alias has been deprecated since sphinx 6.1 + # and will be removed in sphinx 8.0 + try: + from sphinx.util.display import status_iterator + except ImportError: + from sphinx.util import status_iterator + + # Makesure dir exists + if not path.exists(self.dirname): + os.makedirs(self.dirname) + + # Purge orphan items + for key, value in status_iterator( + self._orphan_items.items(), + 'purging orphan document(s)... ', + 'brown', + len(self._orphan_items), + 0, + stringify_func=lambda i: self.stringify(i[0], i[1]), + ): + os.remove(self.itemfile(key)) + self.post_purge(key, value) + + # Dump dirty items + for key, value in status_iterator( + self._dirty_items.items(), + 'dumping dirty document(s)... ', + 'brown', + len(self._dirty_items), + 0, + stringify_func=lambda i: self.stringify(i[0], i[1]), + ): + with open(self.itemfile(key), 'wb') as f: + pickle.dump(value, f) + self.post_dump(key, value) + + # Clear all in-memory items + self._orphan_items = {} + self._dirty_items = {} + self._store = {key: None for key in self._store} + + # Dump store itself + with open(self.dictfile(), 'wb') as f: + pickle.dump(self, f) + + def dictfile(self) -> str: + return path.join(self.dirname, 'dict.pickle') + + def itemfile(self, key: K) -> str: + hasher = sha1() + hasher.update(pickle.dumps(key)) + return path.join(self.dirname, hasher.hexdigest()[:7] + '.pickle') + + def post_dump(self, key: K, value: V) -> None: + pass + + def post_purge(self, key: K, value: V) -> None: + pass + + def stringify(self, key: K, value: V) -> str: + return str(key) diff --git a/src/sphinxnotes/picker/utils/titlepath.py b/src/sphinxnotes/picker/utils/titlepath.py new file mode 100644 index 0000000..eaa6bc3 --- /dev/null +++ b/src/sphinxnotes/picker/utils/titlepath.py @@ -0,0 +1,61 @@ +""" +sphinxnotes.utils.titlepath +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Utils for ellipsis string. + +:copyright: Copyright 2020 Shengyu Zhang +:license: BSD, see LICENSE for details. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +from docutils import nodes + +if TYPE_CHECKING: + from sphinx.environment import BuildEnvironment + + +def resolve( + env: BuildEnvironment, docname: str, node: nodes.Element +) -> list[nodes.title]: + return resolve_section(node) + resolve_document(env, docname) + + +def resolve_section(node: nodes.Element) -> list[nodes.title]: + titlenodes = [] + while node: + if len(node) > 0 and isinstance(node[0], nodes.title): + titlenodes.append(node[0]) + node = node.parent + return titlenodes + + +def resolve_document(env: BuildEnvironment, docname: str) -> list[nodes.title]: + """NOTE: Title of document itself does not included in the returned list""" + titles = [] + master_doc = env.config.master_doc + v = docname.split('/') + + # Exclude self + if v.pop() == master_doc and v: + # If self is master_doc, like: "a/b/c/index", we only return titles + # of "a/b/", so pop again + v.pop() + + # Collect master doc title in docname + while v: + master_docname = '/'.join(v + [master_doc]) + if master_docname in env.titles: + title = env.titles[master_docname] + else: + title = nodes.title(text=v[-1].title()) # FIXME: Create mock title for now + titles.append(title) + v.pop() + + # Include title of top-level master doc + if master_doc in env.titles: + titles.append(env.titles[master_doc]) + + return titles From e77a32a0fa9612d74b8774b068c4cc7b506878a4 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sun, 1 Feb 2026 23:23:45 +0800 Subject: [PATCH 2/6] refactor: Update module references from sphinxnotes.snippet to sphinxnotes.picker Co-authored-by: MiniMax --- .cruft.json | 10 +++++----- .github/workflows/pypi.yml | 2 +- .github/workflows/release.yml | 2 +- Makefile | 5 +++-- README.rst | 22 +++++++++++----------- docs/conf.py | 17 +++++++++-------- docs/conf.py.rej | 10 ++++++++++ docs/index.rst | 27 ++++++++++++++------------- pyproject.toml | 6 +++--- pyproject.toml.rej | 15 +++++++++++++++ utils/conf.py | 4 ++-- 11 files changed, 74 insertions(+), 46 deletions(-) create mode 100644 docs/conf.py.rej create mode 100644 pyproject.toml.rej diff --git a/.cruft.json b/.cruft.json index fe11e8c..6bfabe6 100644 --- a/.cruft.json +++ b/.cruft.json @@ -1,18 +1,18 @@ { "template": "https://github.com/sphinx-notes/template", - "commit": "251643b4d04376494409369c91f29ca047687a5d", + "commit": "a829f105e638f3cba98b799eabef94c0148cd9c0", "checkout": null, "context": { "cookiecutter": { "namespace": "sphinxnotes", - "name": "snippet", - "full_name": "sphinxnotes-snippet", + "name": "picker", + "full_name": "sphinxnotes-picker", "author": "Shengyu Zhang", "description": "Sphinx documentation snippets manager", "version": "1.2", "github_owner": "sphinx-notes", "github_repo": "snippet", - "pypi_name": "sphinxnotes-snippet", + "pypi_name": "sphinxnotes-picker", "pypi_owner": "SilverRainZ", "is_python_project": true, "python_version": "3.12", @@ -20,7 +20,7 @@ "sphinx_version": "7.0", "development_status": "3 - Alpha", "_template": "https://github.com/sphinx-notes/template", - "_commit": "251643b4d04376494409369c91f29ca047687a5d" + "_commit": "a829f105e638f3cba98b799eabef94c0148cd9c0" } }, "directory": null diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 94eda1a..30adcc8 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/sphinxnotes-snippet + url: https://pypi.org/p/sphinxnotes-picker permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c04c00..ebc84fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,4 +15,4 @@ jobs: - uses: ncipollo/release-action@v1 with: body: | - Changelog: https://sphinx.silverrainz.me/snippet/changelog.html + Changelog: https://sphinx.silverrainz.me/picker/changelog.html diff --git a/Makefile b/Makefile index e3d46d8..9daf528 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ clean: .PHONY: fmt fmt: - ruff format src/ && ruff check --fix src/ + ruff format src/ tests/ && ruff check --fix src/ tests/ .PHONY: test test: @@ -36,7 +36,8 @@ test: # Build distribution package, for "install" or "upload". .PHONY: dist dist: pyproject.toml clean - $(PY) -m build + # Use ``--no-isolation`` to prevent network accessing. + $(PY) -m build --no-isolation # Install distribution package to user directory. # diff --git a/README.rst b/README.rst index 2c1e79d..aa46ae1 100644 --- a/README.rst +++ b/README.rst @@ -1,21 +1,21 @@ .. This file is generated from sphinx-notes/cookiecutter. You need to consider modifying the TEMPLATE or modifying THIS FILE. -=================== -sphinxnotes-snippet -=================== +================== +sphinxnotes-picker +================== -.. |docs| image:: https://img.shields.io/github/deployments/sphinx-notes/snippet/github-pages - :target: https://sphinx.silverrainz.me/snippet +.. |docs| image:: https://img.shields.io/github/deployments/sphinx-notes/picker/github-pages + :target: https://sphinx.silverrainz.me/picker :alt: Documentation Status -.. |license| image:: https://img.shields.io/github/license/sphinx-notes/snippet +.. |license| image:: https://img.shields.io/github/license/sphinx-notes/picker :target: https://github.com/sphinx-notes/snippet/blob/master/LICENSE :alt: Open Source License -.. |pypi| image:: https://img.shields.io/pypi/v/sphinxnotes-snippet.svg - :target: https://pypi.python.org/pypi/sphinxnotes-snippet +.. |pypi| image:: https://img.shields.io/pypi/v/sphinxnotes-picker.svg + :target: https://pypi.python.org/pypi/sphinxnotes-picker :alt: PyPI Package -.. |download| image:: https://img.shields.io/pypi/dm/sphinxnotes-snippet - :target: https://pypistats.org/packages/sphinxnotes-snippet +.. |download| image:: https://img.shields.io/pypi/dm/sphinxnotes-picker + :target: https://pypistats.org/packages/sphinxnotes-picker :alt: PyPI Package Downloads |docs| |license| |pypi| |download| @@ -29,4 +29,4 @@ Sphinx documentation snippets manager. Please refer to Documentation_ for more details. -.. _Documentation: https://sphinx.silverrainz.me/snippet +.. _Documentation: https://sphinx.silverrainz.me/picker diff --git a/docs/conf.py b/docs/conf.py index f50b6ff..898a652 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,12 +55,6 @@ # html_theme = 'furo' -html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] html_theme_options = { "source_repository": "https://github.com/sphinx-notes/snippet/", "source_branch": "master", @@ -69,10 +63,17 @@ # The URL which points to the root of the HTML documentation. # It is used to indicate the location of document like canonical_url -html_baseurl = 'https://sphinx.silverrainz.me/snippet' +html_baseurl = 'https://sphinx.silverrainz.me/picker' html_logo = html_favicon = '_static/sphinx-notes.png' +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +html_css_files = ['custom.css'] + # -- Extensions ------------------------------------------------------------- extensions.append('sphinx.ext.extlinks') @@ -117,7 +118,7 @@ import os import sys sys.path.insert(0, os.path.abspath('../src/sphinxnotes')) -extensions.append('snippet') +extensions.append('picker') # CUSTOM CONFIGURATION diff --git a/docs/conf.py.rej b/docs/conf.py.rej new file mode 100644 index 0000000..a70162a --- /dev/null +++ b/docs/conf.py.rej @@ -0,0 +1,10 @@ +diff a/docs/conf.py b/docs/conf.py (rejected hunks) +@@ -9,7 +9,7 @@ + + # -- Project information ----------------------------------------------------- + +-project = 'sphinxnotes-snippet' ++project = 'sphinxnotes-picker' + author = 'Shengyu Zhang' + copyright = "2026, " + author + diff --git a/docs/index.rst b/docs/index.rst index 2894d52..0a3fc43 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,24 +1,24 @@ .. This file is generated from sphinx-notes/cookiecutter. You need to consider modifying the TEMPLATE or modifying THIS FILE. -=================== -sphinxnotes-snippet -=================== +================== +sphinxnotes-picker +================== -.. |docs| image:: https://img.shields.io/github/deployments/sphinx-notes/snippet/github-pages?label=docs - :target: https://sphinx.silverrainz.me/snippet +.. |docs| image:: https://img.shields.io/github/deployments/sphinx-notes/picker/github-pages?label=docs + :target: https://sphinx.silverrainz.me/picker :alt: Documentation Status -.. |license| image:: https://img.shields.io/github/license/sphinx-notes/snippet +.. |license| image:: https://img.shields.io/github/license/sphinx-notes/picker :target: https://github.com/sphinx-notes/snippet/blob/master/LICENSE :alt: Open Source License -.. |pypi| image:: https://img.shields.io/pypi/v/sphinxnotes-snippet.svg - :target: https://pypistats.org/packages/sphinxnotes-snippet +.. |pypi| image:: https://img.shields.io/pypi/v/sphinxnotes-picker.svg + :target: https://pypistats.org/packages/sphinxnotes-picker :alt: PyPI Package -.. |download| image:: https://img.shields.io/pypi/dm/sphinxnotes-snippet - :target: https://pypi.python.org/pypi/sphinxnotes-snippet +.. |download| image:: https://img.shields.io/pypi/dm/sphinxnotes-picker + :target: https://pypi.python.org/pypi/sphinxnotes-picker :alt: PyPI Package Downloads .. |github| image:: https://img.shields.io/badge/GitHub-181717?style=flat&logo=github&logoColor=white/ - :target: https://github.com/sphinx-notes/snippet + :target: https://github.com/sphinx-notes/picker :alt: GitHub Repository |docs| |license| |pypi| |download| |github| @@ -49,7 +49,7 @@ First, downloading extension from PyPI: .. code-block:: console - $ pip install sphinxnotes-snippet + $ pip install sphinxnotes-picker Then, add the extension name to ``extensions`` configuration item in your @@ -59,7 +59,7 @@ Then, add the extension name to ``extensions`` configuration item in your extensions = [ # … - 'sphinxnotes.snippet', + 'sphinxnotes.picker', # … ] @@ -95,6 +95,7 @@ as part of **The Sphinx Notes Project**. :caption: The Sphinx Notes Project Home + GitHub Blog PyPI diff --git a/pyproject.toml b/pyproject.toml index 2251a7a..e439000 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ # https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata [project] -name = "sphinxnotes-snippet" +name = "sphinxnotes-picker" description = "Sphinx documentation snippets manager" readme = "README.rst" license = "BSD-3-Clause" @@ -92,7 +92,7 @@ changelog = "https://sphinx.silverrainz.me/snippet/changelog.html" tracker = "https://github.com/sphinx-notes/snippet/issues" [project.scripts] -snippet = "sphinxnotes.snippet.cli:main" +picker = "sphinxnotes.picker.cli:main" [build-system] requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] @@ -113,4 +113,4 @@ where = ["src"] [tool.setuptools.package-data] # A maps from PACKAGE NAMES to lists of glob patterns, # see also https://setuptools.pypa.io/en/latest/userguide/datafiles.html -"sphinxnotes.snippet.integration" = ["*.*"] +"sphinxnotes.picker.integration" = ["*.*"] diff --git a/pyproject.toml.rej b/pyproject.toml.rej new file mode 100644 index 0000000..c456ad1 --- /dev/null +++ b/pyproject.toml.rej @@ -0,0 +1,15 @@ +diff a/pyproject.toml b/pyproject.toml (rejected hunks) +@@ -77,10 +77,10 @@ docs = [ + ] + + [project.urls] +-homepage = "https://sphinx.silverrainz.me/snippet" +-documentation = "https://sphinx.silverrainz.me/snippet" ++homepage = "https://sphinx.silverrainz.me/picker" ++documentation = "https://sphinx.silverrainz.me/picker" + repository = "https://github.com/sphinx-notes/snippet" +-changelog = "https://sphinx.silverrainz.me/snippet/changelog.html" ++changelog = "https://sphinx.silverrainz.me/picker/changelog.html" + tracker = "https://github.com/sphinx-notes/snippet/issues" + + [build-system] diff --git a/utils/conf.py b/utils/conf.py index 92103db..a7ac59a 100644 --- a/utils/conf.py +++ b/utils/conf.py @@ -2,9 +2,9 @@ from os import path -cache_dir = '/tmp/sphinxnotes-snippet' +cache_dir = '/tmp/sphinxnotes-picker' base_urls = { - 'sphinxnotes-snippet': 'file://' + 'sphinxnotes-picker': 'file://' + path.join(path.dirname(path.dirname(path.realpath(__file__))), 'doc/_build/html'), } From da7c01da39ebc12cd2d1a77f8d311817a235624c Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sun, 1 Feb 2026 23:30:25 +0800 Subject: [PATCH 3/6] refactor: Rename config values and variables from snippet to picker Co-authored-by: MiniMax --- src/sphinxnotes/picker/__init__.py | 4 +-- src/sphinxnotes/picker/cli.py | 34 +++++++++---------- src/sphinxnotes/picker/config/default.py | 2 +- src/sphinxnotes/picker/ext.py | 18 +++++----- .../picker/integration/binding.nvim | 6 ++-- src/sphinxnotes/picker/integration/binding.sh | 30 ++++++++-------- .../picker/integration/binding.zsh | 30 ++++++++-------- src/sphinxnotes/picker/integration/plugin.sh | 12 +++---- src/sphinxnotes/picker/integration/plugin.vim | 8 ++--- 9 files changed, 72 insertions(+), 72 deletions(-) diff --git a/src/sphinxnotes/picker/__init__.py b/src/sphinxnotes/picker/__init__.py index 23ed9e2..9aaf942 100644 --- a/src/sphinxnotes/picker/__init__.py +++ b/src/sphinxnotes/picker/__init__.py @@ -27,8 +27,8 @@ def setup(app): app.add_builder(SnippetBuilder) - app.add_config_value('snippet_config', {}, '') - app.add_config_value('snippet_patterns', {'*': ['.*']}, '') + app.add_config_value('picker_config', {}, '') + app.add_config_value('picker_patterns', {'*': ['.*']}, '') app.connect('config-inited', on_config_inited) app.connect('env-get-outdated', on_env_get_outdated) diff --git a/src/sphinxnotes/picker/cli.py b/src/sphinxnotes/picker/cli.py index 57283a1..38c1a77 100644 --- a/src/sphinxnotes/picker/cli.py +++ b/src/sphinxnotes/picker/cli.py @@ -28,7 +28,7 @@ from .cache import Cache, IndexID, Index from .table import tablify, COLUMNS -DEFAULT_CONFIG_FILE = path.join(xdg_config_home, 'sphinxnotes', 'snippet', 'conf.py') +DEFAULT_CONFIG_FILE = path.join(xdg_config_home, 'sphinxnotes', 'picker', 'conf.py') class HelpFormatter( @@ -59,11 +59,11 @@ def main(argv: list[str] = sys.argv[1:]): description='Sphinx documentation snippets manager', formatter_class=HelpFormatter, epilog=dedent(""" - snippet tags: - d (document) a document - s (section) a section - c (code) a code block - * (any) wildcard for any snippet"""), + picker tags: + d (document) a document + s (section) a section + c (code) a code block + * (any) wildcard for any picker"""), ) parser.add_argument( '--version', @@ -82,7 +82,7 @@ def main(argv: list[str] = sys.argv[1:]): 'stat', aliases=['s'], formatter_class=HelpFormatter, - help='show snippets statistic information', + help='show picker statistic information', ) statparser.set_defaults(func=_on_command_stat) @@ -90,7 +90,7 @@ def main(argv: list[str] = sys.argv[1:]): 'list', aliases=['l'], formatter_class=HelpFormatter, - help='list snippet indexes, columns of indexes: %s' % COLUMNS, + help='list picker indexes, columns of indexes: %s' % COLUMNS, ) listparser.add_argument( '--tags', '-t', type=str, default='*', help='list snippets with specified tags' @@ -100,7 +100,7 @@ def main(argv: list[str] = sys.argv[1:]): '-d', type=str, default='**', - help='list snippets whose docname matches shell-style glob pattern', + help='list pickers whose docname matches shell-style glob pattern', ) listparser.add_argument( '--width', @@ -115,13 +115,13 @@ def main(argv: list[str] = sys.argv[1:]): 'get', aliases=['g'], formatter_class=HelpFormatter, - help='get information of snippet by index ID', + help='get information of picker by index ID', ) getparser.add_argument( - '--docname', '-d', action='store_true', help='get docname of snippet' + '--docname', '-d', action='store_true', help='get docname of picker' ) getparser.add_argument( - '--file', '-f', action='store_true', help='get source file path of snippet' + '--file', '-f', action='store_true', help='get source file path of picker' ) getparser.add_argument( '--deps', action='store_true', help='get dependent files of document' @@ -129,29 +129,29 @@ def main(argv: list[str] = sys.argv[1:]): getparser.add_argument( '--line-start', action='store_true', - help='get line number where snippet starts in source file', + help='get line number where picker starts in source file', ) getparser.add_argument( '--line-end', action='store_true', - help='get line number where snippet ends in source file', + help='get line number where picker ends in source file', ) getparser.add_argument( '--text', '-t', action='store_true', - help='get text representation of snippet', + help='get text representation of picker', ) getparser.add_argument( '--src', action='store_true', - help='get source text of snippet', + help='get source text of picker', ) getparser.add_argument( '--url', '-u', action='store_true', - help='get URL of HTML documentation of snippet', + help='get URL of HTML documentation of picker', ) getparser.add_argument('index_id', type=str, nargs='+', help='index ID') getparser.set_defaults(func=_on_command_get) diff --git a/src/sphinxnotes/picker/config/default.py b/src/sphinxnotes/picker/config/default.py index e4a925d..d321a77 100644 --- a/src/sphinxnotes/picker/config/default.py +++ b/src/sphinxnotes/picker/config/default.py @@ -19,7 +19,7 @@ (Default: ``"$XDG_CACHE_HOME/sphinxnotes/snippet"``) Path to snippet cache directory. """ -cache_dir = __path.join(__xdg_cache_home, 'sphinxnotes', 'snippet') +cache_dir = __path.join(__xdg_cache_home, 'sphinxnotes', 'picker') """ ``base_urls`` diff --git a/src/sphinxnotes/picker/ext.py b/src/sphinxnotes/picker/ext.py index 8e248b7..6da32de 100644 --- a/src/sphinxnotes/picker/ext.py +++ b/src/sphinxnotes/picker/ext.py @@ -81,13 +81,13 @@ def _get_document_allowed_tags(pats: dict[str, list[str]], docname: str) -> str: def on_config_inited(app: Sphinx, appcfg: SphinxConfig) -> None: global cache - cfg = Config(appcfg.snippet_config) + cfg = Config(appcfg.picker_config) cache = Cache(cfg.cache_dir) try: cache.load() except Exception as e: - logger.warning('[snippet] failed to laod cache: %s' % e) + logger.warning('[picker] failed to laod cache: %s' % e) def on_env_get_outdated( @@ -108,13 +108,13 @@ def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> N if not isinstance(doctree, nodes.document): # XXX: It may caused by ablog logger.debug( - '[snippet] node %s is not nodes.document', type(doctree), location=doctree + '[picker] node %s is not nodes.document', type(doctree), location=doctree ) return - allowed_tags = _get_document_allowed_tags(app.config.snippet_patterns, docname) + allowed_tags = _get_document_allowed_tags(app.config.picker_patterns, docname) if not allowed_tags: - logger.debug('[snippet] skip picking: no tag allowed for document %s', docname) + logger.debug('[picker] skip picking: no tag allowed for document %s', docname) return doc = [] @@ -146,7 +146,7 @@ def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> N del cache[cache_key] logger.debug( - '[snippet] picked %s/%s snippets in %s, tags: %s, allowed tags: %s', + '[picker] picked %s/%s pickers in %s, tags: %s, allowed tags: %s', len(doc), len(snippets), docname, @@ -160,10 +160,10 @@ def on_builder_finished(app: Sphinx, exception) -> None: cache.dump() -class SnippetBuilder(DummyBuilder): # DummyBuilder has dummy impls we need. - name = 'snippet' +class PickerBuilder(DummyBuilder): # DummyBuilder has dummy impls we need. + name = 'picker' epilog = __( - 'The snippet builder produces snippets (not to OUTPUTDIR) for use by snippet CLI tool' + 'The picker builder produces pickers (not to OUTPUTDIR) for use by picker CLI tool' ) def get_outdated_docs(self) -> Iterator[str]: diff --git a/src/sphinxnotes/picker/integration/binding.nvim b/src/sphinxnotes/picker/integration/binding.nvim index 125dd0d..a5f7fd8 100644 --- a/src/sphinxnotes/picker/integration/binding.nvim +++ b/src/sphinxnotes/picker/integration/binding.nvim @@ -33,14 +33,14 @@ function! g:SphinxNotesSnippetView(id) let buf = nvim_create_buf(v:false, v:true) " Global for :call - let g:sphinx_notes_snippet_win = nvim_open_win(buf, v:true, opts) + let g:sphinx_notes_picker_win = nvim_open_win(buf, v:true, opts) " The content is always reStructuredText for now set filetype=rst " Press enter to return - nmap :call nvim_win_close(g:sphinx_notes_snippet_win, v:true) + nmap :call nvim_win_close(g:sphinx_notes_picker_win, v:true) - let cmd = [s:snippet, 'get', '--src', a:id] + let cmd = [s:picker, 'get', '--src', a:id] call append(line('$'), ['.. hint:: Press to return']) execute '$read !' . '..' execute '$read !' . join(cmd, ' ') diff --git a/src/sphinxnotes/picker/integration/binding.sh b/src/sphinxnotes/picker/integration/binding.sh index 38b5d25..e245050 100644 --- a/src/sphinxnotes/picker/integration/binding.sh +++ b/src/sphinxnotes/picker/integration/binding.sh @@ -5,8 +5,8 @@ # :Date: 2021-08-14 # :Version: 20240828 -function snippet_view() { - selection=$(snippet_list) +function picker_view() { + selection=$(picker_list) [ -z "$selection" ] && return # Make sure we have $PAGER @@ -18,38 +18,38 @@ function snippet_view() { fi fi - echo "$SNIPPET get --src $selection | $PAGER" + echo "$PICKER get --src $selection | $PAGER" } -function snippet_edit() { - selection=$(snippet_list --tags ds) +function picker_edit() { + selection=$(picker_list --tags ds) [ -z "$selection" ] && return - echo "vim +\$($SNIPPET get --line-start $selection) \$($SNIPPET get --file $selection)" + echo "vim +\$($PICKER get --line-start $selection) \$($PICKER get --file $selection)" } -function snippet_url() { - selection=$(snippet_list --tags ds) +function picker_url() { + selection=$(picker_list --tags ds) [ -z "$selection" ] && return - echo "xdg-open \$($SNIPPET get --url $selection)" + echo "xdg-open \$($PICKER get --url $selection)" } -function snippet_sh_bind_wrapper() { +function picker_sh_bind_wrapper() { cmd=$($1) if [ ! -z "$cmd" ]; then eval "$cmd" fi } -function snippet_sh_do_bind() { - bind -x '"\C-kv": snippet_sh_bind_wrapper snippet_view' - bind -x '"\C-ke": snippet_sh_bind_wrapper snippet_edit' - bind -x '"\C-ku": snippet_sh_bind_wrapper snippet_url' +function picker_sh_do_bind() { + bind -x '"\C-kv": picker_sh_bind_wrapper picker_view' + bind -x '"\C-ke": picker_sh_bind_wrapper picker_edit' + bind -x '"\C-ku": picker_sh_bind_wrapper picker_url' } # Bind key if bind command exists # (the script may sourced by Zsh) -command -v bind 2>&1 1>/dev/null && snippet_sh_do_bind +command -v bind 2>&1 1>/dev/null && picker_sh_do_bind # vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/binding.zsh b/src/sphinxnotes/picker/integration/binding.zsh index f787abb..63fa659 100644 --- a/src/sphinxnotes/picker/integration/binding.zsh +++ b/src/sphinxnotes/picker/integration/binding.zsh @@ -5,31 +5,31 @@ # :Date: 2021-04-12 # :Version: 20211114 -# $1: One of snippet_* functions -function snippet_z_bind_wrapper() { - snippet_sh_bind_wrapper $1 +# $1: One of picker_* functions +function picker_z_bind_wrapper() { + picker_sh_bind_wrapper $1 zle redisplay } -function snippet_z_view() { - snippet_z_bind_wrapper snippet_view +function picker_z_view() { + picker_z_bind_wrapper picker_view } -function snippet_z_edit() { - snippet_z_bind_wrapper snippet_edit +function picker_z_edit() { + picker_z_bind_wrapper picker_edit } -function snippet_z_url() { - snippet_z_bind_wrapper snippet_url +function picker_z_url() { + picker_z_bind_wrapper picker_url } # Define widgets -zle -N snippet_z_view -zle -N snippet_z_edit -zle -N snippet_z_url +zle -N picker_z_view +zle -N picker_z_edit +zle -N picker_z_url -bindkey '^kv' snippet_z_view -bindkey '^ke' snippet_z_edit -bindkey '^ku' snippet_z_url +bindkey '^kv' picker_z_view +bindkey '^ke' picker_z_edit +bindkey '^ku' picker_z_url # vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/plugin.sh b/src/sphinxnotes/picker/integration/plugin.sh index 24cd99b..621fa46 100644 --- a/src/sphinxnotes/picker/integration/plugin.sh +++ b/src/sphinxnotes/picker/integration/plugin.sh @@ -5,13 +5,13 @@ # :Date: 2021-03-20 # :Version: 20240828 -# Make sure we have $SNIPPET -[ -z "$SNIPPET"] && SNIPPET='snippet' +# Make sure we have $PICKER +[ -z "$PICKER"] && PICKER='picker' -# Arguments: $*: Extra opts of ``snippet list`` -# Returns: snippet_id -function snippet_list() { - $SNIPPET list --width $(($(tput cols) - 2)) "$@" | \ +# Arguments: $*: Extra opts of ``picker list`` +# Returns: picker_id +function picker_list() { + $PICKER list --width $(($(tput cols) - 2)) "$@" | \ fzf --with-nth 2.. \ --no-hscroll \ --header-lines 1 \ diff --git a/src/sphinxnotes/picker/integration/plugin.vim b/src/sphinxnotes/picker/integration/plugin.vim index 4e03a73..b004f92 100644 --- a/src/sphinxnotes/picker/integration/plugin.vim +++ b/src/sphinxnotes/picker/integration/plugin.vim @@ -7,13 +7,13 @@ " " NOTE: junegunn/fzf.vim is required -let s:snippet = 'snippet' +let s:picker = 'picker' let s:width = 0.9 let s:height = 0.6 " Use fzf to list all snippets, callback with argument id. function g:SphinxNotesSnippetList(tags, callback) - let cmd = [s:snippet, 'list', + let cmd = [s:picker, 'list', \ '--tags', a:tags, \ '--width', float2nr(&columns * s:width) - 2, \ ] @@ -37,7 +37,7 @@ endfunction " Return the attribute value of specific snippet. function g:SphinxNotesSnippetGet(id, attr) - let cmd = [s:snippet, 'get', a:id, '--' . a:attr] + let cmd = [s:picker, 'get', a:id, '--' . a:attr] return systemlist(join(cmd, ' ')) endfunction @@ -65,7 +65,7 @@ function g:SphinxNotesSnippetListSnippetAttrs(id, callback) call a:callback(opt, val) " finally call user's cb endfunction - let preview_cmd = [s:snippet, 'get', a:id, '--$(echo {} | cut -d " " -f1)'] + let preview_cmd = [s:picker, 'get', a:id, '--$(echo {} | cut -d " " -f1)'] let info_cmd = ['echo', 'Index ID:', a:id] call fzf#run({ \ 'source': table, From fe979bbccab1700b03472aaa7f3b7a6f8c0ae76c Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Sun, 1 Feb 2026 23:46:26 +0800 Subject: [PATCH 4/6] fix: Keep builder name as 'snippet' for backwards compatibility Co-authored-by: MiniMax --- src/sphinxnotes/picker/ext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sphinxnotes/picker/ext.py b/src/sphinxnotes/picker/ext.py index 6da32de..ce4ff36 100644 --- a/src/sphinxnotes/picker/ext.py +++ b/src/sphinxnotes/picker/ext.py @@ -160,10 +160,10 @@ def on_builder_finished(app: Sphinx, exception) -> None: cache.dump() -class PickerBuilder(DummyBuilder): # DummyBuilder has dummy impls we need. - name = 'picker' +class SnippetBuilder(DummyBuilder): # DummyBuilder has dummy impls we need. + name = 'snippet' epilog = __( - 'The picker builder produces pickers (not to OUTPUTDIR) for use by picker CLI tool' + 'The snippet builder produces snippets (not to OUTPUTDIR) for use by snippet CLI tool' ) def get_outdated_docs(self) -> Iterator[str]: From 837fccd71747fd1447df0815ee4d80da0a5566c8 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Mon, 2 Feb 2026 00:02:47 +0800 Subject: [PATCH 5/6] chore: Remove old snippet directory after rename --- src/sphinxnotes/snippet/__init__.py | 36 -- src/sphinxnotes/snippet/cache.py | 108 ------ src/sphinxnotes/snippet/cli.py | 364 ------------------ src/sphinxnotes/snippet/config/__init__.py | 38 -- src/sphinxnotes/snippet/config/default.py | 31 -- src/sphinxnotes/snippet/ext.py | 204 ---------- .../snippet/integration/__init__.py | 11 - .../snippet/integration/binding.nvim | 54 --- .../snippet/integration/binding.sh | 55 --- .../snippet/integration/binding.vim | 45 --- .../snippet/integration/binding.zsh | 35 -- src/sphinxnotes/snippet/integration/plugin.sh | 23 -- .../snippet/integration/plugin.vim | 109 ------ src/sphinxnotes/snippet/keyword.py | 104 ----- src/sphinxnotes/snippet/meta.py | 39 -- src/sphinxnotes/snippet/picker.py | 116 ------ src/sphinxnotes/snippet/py.typed | 0 src/sphinxnotes/snippet/snippets.py | 240 ------------ src/sphinxnotes/snippet/table.py | 57 --- src/sphinxnotes/snippet/utils/__init__.py | 7 - src/sphinxnotes/snippet/utils/ellipsis.py | 56 --- src/sphinxnotes/snippet/utils/pdict.py | 145 ------- src/sphinxnotes/snippet/utils/titlepath.py | 61 --- 23 files changed, 1938 deletions(-) delete mode 100644 src/sphinxnotes/snippet/__init__.py delete mode 100644 src/sphinxnotes/snippet/cache.py delete mode 100644 src/sphinxnotes/snippet/cli.py delete mode 100644 src/sphinxnotes/snippet/config/__init__.py delete mode 100644 src/sphinxnotes/snippet/config/default.py delete mode 100644 src/sphinxnotes/snippet/ext.py delete mode 100644 src/sphinxnotes/snippet/integration/__init__.py delete mode 100644 src/sphinxnotes/snippet/integration/binding.nvim delete mode 100644 src/sphinxnotes/snippet/integration/binding.sh delete mode 100644 src/sphinxnotes/snippet/integration/binding.vim delete mode 100644 src/sphinxnotes/snippet/integration/binding.zsh delete mode 100644 src/sphinxnotes/snippet/integration/plugin.sh delete mode 100644 src/sphinxnotes/snippet/integration/plugin.vim delete mode 100644 src/sphinxnotes/snippet/keyword.py delete mode 100644 src/sphinxnotes/snippet/meta.py delete mode 100644 src/sphinxnotes/snippet/picker.py delete mode 100644 src/sphinxnotes/snippet/py.typed delete mode 100644 src/sphinxnotes/snippet/snippets.py delete mode 100644 src/sphinxnotes/snippet/table.py delete mode 100644 src/sphinxnotes/snippet/utils/__init__.py delete mode 100644 src/sphinxnotes/snippet/utils/ellipsis.py delete mode 100644 src/sphinxnotes/snippet/utils/pdict.py delete mode 100644 src/sphinxnotes/snippet/utils/titlepath.py diff --git a/src/sphinxnotes/snippet/__init__.py b/src/sphinxnotes/snippet/__init__.py deleted file mode 100644 index 0fd7955..0000000 --- a/src/sphinxnotes/snippet/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -sphinxnotes.snippet -~~~~~~~~~~~~~~~~~~~ - -Sphinx extension entrypoint. - -:copyright: Copyright 2024 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - - -def setup(app): - # **WARNING**: We don't import these packages globally, because the current - # package (sphinxnotes.snippet) is always resloved when importing - # sphinxnotes.snippet.*. If we import packages here, eventually we will - # load a lot of packages from the Sphinx. It will seriously **SLOW DOWN** - # the startup time of our CLI tool (sphinxnotes.snippet.cli). - # - # .. seealso:: https://github.com/sphinx-notes/snippet/pull/31 - from .ext import ( - SnippetBuilder, - on_config_inited, - on_env_get_outdated, - on_doctree_resolved, - on_builder_finished, - ) - - app.add_builder(SnippetBuilder) - - app.add_config_value('snippet_config', {}, '') - app.add_config_value('snippet_patterns', {'*': ['.*']}, '') - - app.connect('config-inited', on_config_inited) - app.connect('env-get-outdated', on_env_get_outdated) - app.connect('doctree-resolved', on_doctree_resolved) - app.connect('build-finished', on_builder_finished) diff --git a/src/sphinxnotes/snippet/cache.py b/src/sphinxnotes/snippet/cache.py deleted file mode 100644 index 78a2efa..0000000 --- a/src/sphinxnotes/snippet/cache.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -sphinxnotes.snippet.cache -~~~~~~~~~~~~~~~~~~~~~~~~~ - -:copyright: Copyright 2021 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from dataclasses import dataclass - -from .snippets import Snippet -from .utils.pdict import PDict - - -@dataclass(frozen=True) -class Item(object): - """Item of snippet cache.""" - - snippet: Snippet - tags: str - excerpt: str - titlepath: list[str] - keywords: list[str] - - -DocID = tuple[str, str] # (project, docname) -IndexID = str # UUID -Index = tuple[str, str, list[str], list[str]] # (tags, excerpt, titlepath, keywords) - - -class Cache(PDict[DocID, list[Item]]): - """A DocID -> list[Item] Cache.""" - - indexes: dict[IndexID, Index] - index_id_to_doc_id: dict[IndexID, tuple[DocID, int]] - doc_id_to_index_ids: dict[DocID, list[IndexID]] - num_snippets_by_project: dict[str, int] - num_snippets_by_docid: dict[DocID, int] - - def __init__(self, dirname: str) -> None: - self.indexes = {} - self.index_id_to_doc_id = {} - self.doc_id_to_index_ids = {} - self.num_snippets_by_project = {} - self.num_snippets_by_docid = {} - super().__init__(dirname) - - def post_dump(self, key: DocID, value: list[Item]) -> None: - """Overwrite PDict.post_dump.""" - - # Remove old indexes and index IDs if exists - for old_index_id in self.doc_id_to_index_ids.setdefault(key, []): - del self.index_id_to_doc_id[old_index_id] - del self.indexes[old_index_id] - - # Add new index to every where - for i, item in enumerate(value): - index_id = self.gen_index_id() - self.indexes[index_id] = ( - item.tags, - item.excerpt, - item.titlepath, - item.keywords, - ) - self.index_id_to_doc_id[index_id] = (key, i) - self.doc_id_to_index_ids[key].append(index_id) - - # Update statistic - if key[0] not in self.num_snippets_by_project: - self.num_snippets_by_project[key[0]] = 0 - self.num_snippets_by_project[key[0]] += len(value) - if key not in self.num_snippets_by_docid: - self.num_snippets_by_docid[key] = 0 - self.num_snippets_by_docid[key] += len(value) - - def post_purge(self, key: DocID, value: list[Item]) -> None: - """Overwrite PDict.post_purge.""" - - # Purge indexes - for index_id in self.doc_id_to_index_ids.pop(key): - del self.index_id_to_doc_id[index_id] - del self.indexes[index_id] - - # Update statistic - self.num_snippets_by_project[key[0]] -= len(value) - if self.num_snippets_by_project[key[0]] == 0: - del self.num_snippets_by_project[key[0]] - self.num_snippets_by_docid[key] -= len(value) - if self.num_snippets_by_docid[key] == 0: - del self.num_snippets_by_docid[key] - - def get_by_index_id(self, key: IndexID) -> Item | None: - """Like get(), but use IndexID as key.""" - doc_id, item_index = self.index_id_to_doc_id.get(key, (None, None)) - if not doc_id or item_index is None: - return None - return self[doc_id][item_index] - - def gen_index_id(self) -> str: - """Generate unique ID for index.""" - import uuid - - return uuid.uuid4().hex[:7] - - def stringify(self, key: DocID, value: list[Item]) -> str: - """Overwrite PDict.stringify.""" - return key[1] # docname diff --git a/src/sphinxnotes/snippet/cli.py b/src/sphinxnotes/snippet/cli.py deleted file mode 100644 index a52b17a..0000000 --- a/src/sphinxnotes/snippet/cli.py +++ /dev/null @@ -1,364 +0,0 @@ -""" -sphinxnotes.snippet.cli -~~~~~~~~~~~~~~~~~~~~~~~ - -Command line entrypoint. - -:copyright: Copyright 2024 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -# **NOTE**: Import new packages with caution: -# Importing complex packages (like sphinx.*) will directly slow down the -# startup of the CLI tool. -from __future__ import annotations -import sys -import os -from os import path -import argparse -from typing import Iterable -from textwrap import dedent -from shutil import get_terminal_size -import posixpath - -from xdg.BaseDirectory import xdg_config_home - -from .snippets import Document -from .config import Config -from .cache import Cache, IndexID, Index -from .table import tablify, COLUMNS - -DEFAULT_CONFIG_FILE = path.join(xdg_config_home, 'sphinxnotes', 'snippet', 'conf.py') - - -class HelpFormatter( - argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter -): - pass - - -def get_integration_file(fn: str) -> str: - """ - Get file path of integration files. - - .. seealso:: - - see ``[tool.setuptools.package-data]`` section of pyproject.toml to know - how files are included. - """ - # TODO: use https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files - prefix = path.abspath(path.dirname(__file__)) - return path.join(prefix, 'integration', fn) - - -def main(argv: list[str] = sys.argv[1:]): - """Command line entrypoint.""" - - parser = argparse.ArgumentParser( - prog=__name__, - description='Sphinx documentation snippets manager', - formatter_class=HelpFormatter, - epilog=dedent(""" - snippet tags: - d (document) a document - s (section) a section - c (code) a code block - * (any) wildcard for any snippet"""), - ) - parser.add_argument( - '--version', - # add_argument provides action='version', but it requires a version - # literal and doesn't support lazily obtaining version. - action='store_true', - help="show program's version number and exit", - ) - parser.add_argument( - '-c', '--config', default=DEFAULT_CONFIG_FILE, help='path to configuration file' - ) - - # Init subcommands - subparsers = parser.add_subparsers() - statparser = subparsers.add_parser( - 'stat', - aliases=['s'], - formatter_class=HelpFormatter, - help='show snippets statistic information', - ) - statparser.set_defaults(func=_on_command_stat) - - listparser = subparsers.add_parser( - 'list', - aliases=['l'], - formatter_class=HelpFormatter, - help='list snippet indexes, columns of indexes: %s' % COLUMNS, - ) - listparser.add_argument( - '--tags', '-t', type=str, default='*', help='list snippets with specified tags' - ) - listparser.add_argument( - '--docname', - '-d', - type=str, - default='**', - help='list snippets whose docname matches shell-style glob pattern', - ) - listparser.add_argument( - '--width', - '-w', - type=int, - default=get_terminal_size((120, 0)).columns, - help='width in characters of output', - ) - listparser.set_defaults(func=_on_command_list) - - getparser = subparsers.add_parser( - 'get', - aliases=['g'], - formatter_class=HelpFormatter, - help='get information of snippet by index ID', - ) - getparser.add_argument( - '--docname', '-d', action='store_true', help='get docname of snippet' - ) - getparser.add_argument( - '--file', '-f', action='store_true', help='get source file path of snippet' - ) - getparser.add_argument( - '--deps', action='store_true', help='get dependent files of document' - ) - getparser.add_argument( - '--line-start', - action='store_true', - help='get line number where snippet starts in source file', - ) - getparser.add_argument( - '--line-end', - action='store_true', - help='get line number where snippet ends in source file', - ) - getparser.add_argument( - '--text', - '-t', - action='store_true', - help='get text representation of snippet', - ) - getparser.add_argument( - '--src', - action='store_true', - help='get source text of snippet', - ) - getparser.add_argument( - '--url', - '-u', - action='store_true', - help='get URL of HTML documentation of snippet', - ) - getparser.add_argument('index_id', type=str, nargs='+', help='index ID') - getparser.set_defaults(func=_on_command_get) - - igparser = subparsers.add_parser( - 'integration', - aliases=['i'], - formatter_class=HelpFormatter, - help='integration related commands', - ) - igparser.add_argument( - '--sh', '-s', action='store_true', help='dump bash shell integration script' - ) - igparser.add_argument( - '--sh-binding', action='store_true', help='dump recommended bash key binding' - ) - igparser.add_argument( - '--zsh', '-z', action='store_true', help='dump zsh integration script' - ) - igparser.add_argument( - '--zsh-binding', action='store_true', help='dump recommended zsh key binding' - ) - igparser.add_argument( - '--vim', '-v', action='store_true', help='dump (neo)vim integration script' - ) - igparser.add_argument( - '--vim-binding', action='store_true', help='dump recommended vim key binding' - ) - igparser.add_argument( - '--nvim-binding', - action='store_true', - help='dump recommended neovim key binding', - ) - igparser.set_defaults(func=_on_command_integration, parser=igparser) - - # Parse command line arguments - args = parser.parse_args(argv) - - # Print version message. - # See parser.add_argument('--version', ...) for more detais. - if args.version: - # NOTE: Importing is slow, do it on demand. - from importlib.metadata import version - - pkgname = 'sphinxnotes.snippet' - print(pkgname, version(pkgname)) - parser.exit() - - # Load config from file - if args.config == DEFAULT_CONFIG_FILE and not path.isfile(DEFAULT_CONFIG_FILE): - print( - 'the default configuration file does not exist, ignore it', file=sys.stderr - ) - cfg = Config({}) - else: - cfg = Config.load(args.config) - setattr(args, 'cfg', cfg) - - # Load snippet cache - cache = Cache(cfg.cache_dir) - cache.load() - setattr(args, 'cache', cache) - - # Call subcommand - if hasattr(args, 'func'): - args.func(args) - else: - parser.print_help() - - -def _on_command_stat(args: argparse.Namespace): - cache = args.cache - - num_projects = len(cache.num_snippets_by_project) - num_docs = len(cache.num_snippets_by_docid) - num_snippets = sum(cache.num_snippets_by_project.values()) - print(f'snippets are loaded from {cache.dirname}') - print(f'configuration are loaded from {args.config}') - print(f'integration files are located at {get_integration_file("")}') - print('') - print( - f'I have {num_projects} project(s), {num_docs} documentation(s) and {num_snippets} snippet(s)' - ) - for i, v in cache.num_snippets_by_project.items(): - print(f'project {i}:') - print(f'\t {v} snippets(s)') - - -def _filter_list_items( - cache: Cache, tags: str, docname_glob: str -) -> Iterable[tuple[IndexID, Index]]: - # NOTE: Importing is slow, do it on demand. - from sphinx.util.matching import patmatch - - for index_id, index in cache.indexes.items(): - # Filter by tags. - if index[0] not in tags and '*' not in tags: - continue - # Filter by docname. - (_, docname), _ = cache.index_id_to_doc_id[index_id] - if not patmatch(docname, docname_glob): - continue - yield (index_id, index) - - -def _on_command_list(args: argparse.Namespace): - items = _filter_list_items(args.cache, args.tags, args.docname) - for row in tablify(items, args.width): - print(row) - - -def _on_command_get(args: argparse.Namespace): - # Wrapper for warning when nothing is printed - printed = False - - def p(*args, **opts): - nonlocal printed - printed = True - print(*args, **opts) - - for index_id in args.index_id: - item = args.cache.get_by_index_id(index_id) - if not item: - p('no such index ID', file=sys.stderr) - sys.exit(1) - if args.text: - p('\n'.join(item.snippet.text)) - if args.src: - p('\n'.join(item.snippet.source)) - if args.docname: - p(item.snippet.docname) - if args.file: - p(item.snippet.file) - if args.deps: - if not isinstance(item.snippet, Document): - print( - f'{type(item.snippet)} ({index_id}) is not a document', - file=sys.stderr, - ) - sys.exit(1) - if len(item.snippet.deps) == 0: - p('') # prevent print nothing warning - for dep in item.snippet.deps: - p(dep) - if args.url: - # HACK: get doc id in better way - doc_id, _ = args.cache.index_id_to_doc_id.get(index_id) - base_url = args.cfg.base_urls.get(doc_id[0]) - if not base_url: - print( - f'base URL for project {doc_id[0]} not configurated', - file=sys.stderr, - ) - sys.exit(1) - url = posixpath.join(base_url, doc_id[1] + '.html') - if item.snippet.refid: - url += '#' + item.snippet.refid - p(url) - if args.line_start: - p(item.snippet.lineno[0]) - if args.line_end: - p(item.snippet.lineno[1]) - - if not printed: - print('please specify at least one argument', file=sys.stderr) - sys.exit(1) - - -def _on_command_integration(args: argparse.Namespace): - if args.sh: - with open(get_integration_file('plugin.sh'), 'r') as f: - print(f.read()) - if args.sh_binding: - with open(get_integration_file('binding.sh'), 'r') as f: - print(f.read()) - if args.zsh: - # Zsh plugin depends on Bash shell plugin - with open(get_integration_file('plugin.sh'), 'r') as f: - print(f.read()) - if args.zsh_binding: - # Zsh binding depends on Bash shell binding - with open(get_integration_file('binding.sh'), 'r') as f: - print(f.read()) - with open(get_integration_file('binding.zsh'), 'r') as f: - print(f.read()) - if args.vim: - with open(get_integration_file('plugin.vim'), 'r') as f: - print(f.read()) - if args.vim_binding: - with open(get_integration_file('binding.vim'), 'r') as f: - print(f.read()) - if args.nvim_binding: - # NeoVim binding depends on Vim binding - with open(get_integration_file('binding.vim'), 'r') as f: - print(f.read()) - with open(get_integration_file('binding.nvim'), 'r') as f: - print(f.read()) - - -if __name__ == '__main__': - # Prevent "[Errno 32] Broken pipe" error. - # https://docs.python.org/3/library/signal.html#note-on-sigpipe - try: - sys.exit(main()) - except BrokenPipeError: - # Python flushes standard streams on exit; redirect remaining output - # to devnull to avoid another BrokenPipeError at shutdown. - devnull = os.open(os.devnull, os.O_WRONLY) - os.dup2(devnull, sys.stdout.fileno()) - sys.exit(1) # Python exits with error code 1 on EPIPE diff --git a/src/sphinxnotes/snippet/config/__init__.py b/src/sphinxnotes/snippet/config/__init__.py deleted file mode 100644 index f97482e..0000000 --- a/src/sphinxnotes/snippet/config/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -sphinxnotes.snippet.config -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:copyright: Copyright 2021 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from typing import Dict, Any - -from . import default - - -class Config(object): - """Snippet configuration object.""" - - def __init__(self, config: Dict[str, Any]) -> None: - # Load default - self.__dict__.update(default.__dict__) - for name in config: - if name.startswith('__') and name != '__file__': - # Ignore unrelated name - continue - if name in self.__dict__.keys(): - self.__dict__[name] = config[name] - - @classmethod - def load(cls, filename: str) -> 'Config': - """Load config from configuration file""" - with open(filename, 'rb') as f: - source = f.read() - # Compile to a code object - code = compile(source, filename, 'exec') - config = {'__file__': filename} - # Compile to a code object - exec(code, config) - return cls(config) diff --git a/src/sphinxnotes/snippet/config/default.py b/src/sphinxnotes/snippet/config/default.py deleted file mode 100644 index dd3967d..0000000 --- a/src/sphinxnotes/snippet/config/default.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -sphinxnotes.snippet.config.default -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Snippet default configuration. - -:copyright: Copyright 2021 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -# NOTE: All imported name should starts with ``__`` to distinguish from -# configuration item -from os import path as __path -from xdg.BaseDirectory import xdg_cache_home as __xdg_cache_home - -""" -``cache_dir`` - (Type: ``str``) - (Default: ``"$XDG_CACHE_HOME/sphinxnotes/snippet"``) - Path to snippet cache directory. -""" -cache_dir = __path.join(__xdg_cache_home, 'sphinxnotes', 'snippet') - -""" -``base_urls`` - (Type: ``Dict[str,str]``) - (Default: ``{}``) - A "project name" -> "base URL" mapping. - Base URL is used to generate snippet URL. -""" -base_urls = {} diff --git a/src/sphinxnotes/snippet/ext.py b/src/sphinxnotes/snippet/ext.py deleted file mode 100644 index 9941038..0000000 --- a/src/sphinxnotes/snippet/ext.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -sphinxnotes.snippet.ext -~~~~~~~~~~~~~~~~~~~~~~~ - -Sphinx extension implementation, but the entrypoint is located at __init__.py. - -:copyright: Copyright 2024 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from typing import TYPE_CHECKING -import re -from os import path -import time - -from docutils import nodes -from sphinx.locale import __ -from sphinx.util import logging -from sphinx.builders.dummy import DummyBuilder - -if TYPE_CHECKING: - from sphinx.application import Sphinx - from sphinx.environment import BuildEnvironment - from sphinx.config import Config as SphinxConfig - from collections.abc import Iterator - -from .config import Config -from .snippets import Snippet, WithTitle, Document, Section, Code -from .picker import pick -from .cache import Cache, Item -from .keyword import Extractor -from .utils import titlepath - - -logger = logging.getLogger(__name__) - -cache: Cache | None = None -extractor: Extractor = Extractor() - - -def extract_tags(s: Snippet) -> str: - tags = '' - if isinstance(s, Document): - tags += 'd' - elif isinstance(s, Section): - tags += 's' - elif isinstance(s, Code): - tags += 'c' - return tags - - -def extract_excerpt(s: Snippet) -> str: - if isinstance(s, Document) and s.title is not None: - return '<' + s.title + '>' - elif isinstance(s, Section) and s.title is not None: - return '[' + s.title + ']' - elif isinstance(s, Code): - return '`' + (s.lang + ':').ljust(8, ' ') + ' ' + s.desc + '`' - return '' - - -def extract_keywords(s: Snippet) -> list[str]: - keywords = [s.docname] - if isinstance(s, WithTitle) and s.title is not None: - keywords.extend(extractor.extract(s.title)) - if isinstance(s, Code): - keywords.extend(extractor.extract(s.desc)) - return keywords - - -def _get_document_allowed_tags(pats: dict[str, list[str]], docname: str) -> str: - """Return the tags of snippets that are allowed to be picked from the document.""" - allowed_tags = '' - for tags, ps in pats.items(): - for pat in ps: - if re.match(pat, docname): - allowed_tags += tags - return allowed_tags - - -def on_config_inited(app: Sphinx, appcfg: SphinxConfig) -> None: - global cache - cfg = Config(appcfg.snippet_config) - cache = Cache(cfg.cache_dir) - - try: - cache.load() - except Exception as e: - logger.warning('[snippet] failed to laod cache: %s' % e) - - -def on_env_get_outdated( - app: Sphinx, - env: BuildEnvironment, - added: set[str], - changed: set[str], - removed: set[str], -) -> list[str]: - # Remove purged indexes and snippetes from db - assert cache is not None - for docname in removed: - del cache[(app.config.project, docname)] - return [] - - -def on_doctree_resolved(app: Sphinx, doctree: nodes.document, docname: str) -> None: - if not isinstance(doctree, nodes.document): - # XXX: It may caused by ablog - logger.debug( - '[snippet] node %s is not nodes.document', type(doctree), location=doctree - ) - return - - allowed_tags = _get_document_allowed_tags(app.config.snippet_patterns, docname) - if not allowed_tags: - logger.debug('[snippet] skip picking: no tag allowed for document %s', docname) - return - - doc = [] - snippets = pick(app, doctree, docname) - tags = [] - for s, n in snippets: - # FIXME: Better filter logic. - tags.append(extract_tags(s)) - if tags[-1] not in allowed_tags: - continue - tpath = [x.astext() for x in titlepath.resolve(app.env, docname, n)] - if isinstance(s, Section): - tpath = tpath[1:] - doc.append( - Item( - snippet=s, - tags=extract_tags(s), - excerpt=extract_excerpt(s), - keywords=extract_keywords(s), - titlepath=tpath, - ) - ) - - cache_key = (app.config.project, docname) - assert cache is not None - if len(doc) != 0: - cache[cache_key] = doc - elif cache_key in cache: - del cache[cache_key] - - logger.debug( - '[snippet] picked %s/%s snippets in %s, tags: %s, allowed tags: %s', - len(doc), - len(snippets), - docname, - tags, - allowed_tags, - ) - - -def on_builder_finished(app: Sphinx, exception) -> None: - assert cache is not None - cache.dump() - - -class SnippetBuilder(DummyBuilder): # DummyBuilder has dummy impls we need. - name = 'snippet' - epilog = __( - 'The snippet builder produces snippets (not to OUTPUTDIR) for use by snippet CLI tool' - ) - - def get_outdated_docs(self) -> Iterator[str]: - """Modified from :py:meth:`sphinx.builders.html.StandaloneHTMLBuilder.get_outdated_docs`.""" - for docname in self.env.found_docs: - if docname not in self.env.all_docs: - logger.debug('[build target] did not in env: %r', docname) - yield docname - continue - - assert cache is not None - targetname = cache.itemfile((self.app.config.project, docname)) - try: - targetmtime = path.getmtime(targetname) - except Exception: - targetmtime = 0 - try: - srcmtime = path.getmtime(self.env.doc2path(docname)) - if srcmtime > targetmtime: - logger.debug( - '[build target] targetname %r(%s), docname %r(%s)', - targetname, - _format_modified_time(targetmtime), - docname, - _format_modified_time( - path.getmtime(self.env.doc2path(docname)) - ), - ) - yield docname - except OSError: - # source doesn't exist anymore - pass - - -def _format_modified_time(timestamp: float) -> str: - """Return an RFC 3339 formatted string representing the given timestamp.""" - seconds, fraction = divmod(timestamp, 1) - return time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(seconds)) + f'.{fraction:.3f}' diff --git a/src/sphinxnotes/snippet/integration/__init__.py b/src/sphinxnotes/snippet/integration/__init__.py deleted file mode 100644 index 4725262..0000000 --- a/src/sphinxnotes/snippet/integration/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -sphinxnotes.snippet.integration -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Dummpy module for including package_data. - -See also ``[tool.setuptools.package-data]`` section of pyproject.toml. - -:copyright: Copyright 2024 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" diff --git a/src/sphinxnotes/snippet/integration/binding.nvim b/src/sphinxnotes/snippet/integration/binding.nvim deleted file mode 100644 index 25f0f34..0000000 --- a/src/sphinxnotes/snippet/integration/binding.nvim +++ /dev/null @@ -1,54 +0,0 @@ -" NeoVim key binding for sphinxnotes-snippet -" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -" -" :Author: Shengyu Zhang -" :Date: 2021-11-14 -" :Version: 20211114 -" -" TODO: Support vim? - -function! g:SphinxNotesSnippetListAndView() - function! ListAndView_CB(id) - call g:SphinxNotesSnippetView(a:id) - endfunction - call g:SphinxNotesSnippetList('"*"', function('ListAndView_CB')) -endfunction - -" https://github.com/anhmv/vim-float-window/blob/master/plugin/float-window.vim -function! g:SphinxNotesSnippetView(id) - let height = float2nr((&lines - 2) / 1.5) - let row = float2nr((&lines - height) / 2) - let width = float2nr(&columns / 1.5) - let col = float2nr((&columns - width) / 2) - - " Main Window - let opts = { - \ 'relative': 'editor', - \ 'style': 'minimal', - \ 'width': width, - \ 'height': height, - \ 'col': col, - \ 'row': row, - \ } - - let buf = nvim_create_buf(v:false, v:true) - " Global for :call - let g:sphinx_notes_snippet_win = nvim_open_win(buf, v:true, opts) - - " The content is always reStructuredText for now - set filetype=rst - " Press enter to return - nmap :call nvim_win_close(g:sphinx_notes_snippet_win, v:true) - - let cmd = [s:snippet, 'get', '--src', a:id] - call append(line('$'), ['.. hint:: Press to return']) - execute '$read !' . '..' - execute '$read !' . join(cmd, ' ') - execute '$read !' . '..' - call append(line('$'), ['.. hint:: Press to return']) -endfunction - -nmap v :call g:SphinxNotesSnippetListAndView() - -" vim: set shiftwidth=2: -" vim: set ft=vim: diff --git a/src/sphinxnotes/snippet/integration/binding.sh b/src/sphinxnotes/snippet/integration/binding.sh deleted file mode 100644 index f831d58..0000000 --- a/src/sphinxnotes/snippet/integration/binding.sh +++ /dev/null @@ -1,55 +0,0 @@ -# Bash Shell key binding for sphinxnotes-snippet -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# :Author: Shengyu Zhang -# :Date: 2021-08-14 -# :Version: 20240828 - -function snippet_view() { - selection=$(snippet_list) - [ -z "$selection" ] && return - - # Make sure we have $PAGER - if [ -z "$PAGER" ]; then - if [ ! -z "$(where less)" ]; then - PAGER='less' - else - PAGER='cat' - fi - fi - - echo "$SNIPPET get --src $selection | $PAGER" -} - -function snippet_edit() { - selection=$(snippet_list --tags ds) - [ -z "$selection" ] && return - - echo "vim +\$($SNIPPET get --line-start $selection) \$($SNIPPET get --file $selection)" -} - -function snippet_url() { - selection=$(snippet_list --tags ds) - [ -z "$selection" ] && return - - echo "xdg-open \$($SNIPPET get --url $selection)" -} - -function snippet_sh_bind_wrapper() { - cmd=$($1) - if [ ! -z "$cmd" ]; then - eval "$cmd" - fi -} - -function snippet_sh_do_bind() { - bind -x '"\C-kv": snippet_sh_bind_wrapper snippet_view' - bind -x '"\C-ke": snippet_sh_bind_wrapper snippet_edit' - bind -x '"\C-ku": snippet_sh_bind_wrapper snippet_url' -} - -# Bind key if bind command exists -# (the script may sourced by Zsh) -command -v bind 2>&1 1>/dev/null && snippet_sh_do_bind - -# vim: set shiftwidth=2: diff --git a/src/sphinxnotes/snippet/integration/binding.vim b/src/sphinxnotes/snippet/integration/binding.vim deleted file mode 100644 index 6225080..0000000 --- a/src/sphinxnotes/snippet/integration/binding.vim +++ /dev/null @@ -1,45 +0,0 @@ -" Vim key binding for sphinxnotes-snippet -" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -" -" :Author: Shengyu Zhang -" :Date: 2021-04-12 -" :Version: 20211114 -" - -function g:SphinxNotesSnippetEdit(id) - let file = g:SphinxNotesSnippetGet(a:id, 'file')[0] - let line = g:SphinxNotesSnippetGet(a:id, 'line-start')[0] - if &modified - execute 'vsplit ' . file - else - execute 'edit ' . file - endif - execute line -endfunction - -function g:SphinxNotesSnippetListAndEdit() - function! ListAndEdit_CB(id) - call g:SphinxNotesSnippetEdit(a:id) - endfunction - call g:SphinxNotesSnippetList('ds', function('ListAndEdit_CB')) -endfunction - -function g:SphinxNotesSnippetUrl(id) - let url_list = g:SphinxNotesSnippetGet(a:id, 'url') - for url in url_list - echo system('xdg-open ' . shellescape(url)) - endfor -endfunction - -function g:SphinxNotesSnippetListAndUrl() - function! ListAndUrl_CB(id) - call g:SphinxNotesSnippetUrl(a:id) - endfunction - call g:SphinxNotesSnippetList('ds', function('ListAndUrl_CB')) -endfunction - -nmap e :call g:SphinxNotesSnippetListAndEdit() -nmap u :call g:SphinxNotesSnippetListAndUrl() -nmap i :call g:SphinxNotesSnippetListAndInput() - -" vim: set shiftwidth=2: diff --git a/src/sphinxnotes/snippet/integration/binding.zsh b/src/sphinxnotes/snippet/integration/binding.zsh deleted file mode 100644 index a53bd28..0000000 --- a/src/sphinxnotes/snippet/integration/binding.zsh +++ /dev/null @@ -1,35 +0,0 @@ -# Z Shell key binding for sphinxnotes-snippet -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# :Author: Shengyu Zhang -# :Date: 2021-04-12 -# :Version: 20211114 - -# $1: One of snippet_* functions -function snippet_z_bind_wrapper() { - snippet_sh_bind_wrapper $1 - zle redisplay -} - -function snippet_z_view() { - snippet_z_bind_wrapper snippet_view -} - -function snippet_z_edit() { - snippet_z_bind_wrapper snippet_edit -} - -function snippet_z_url() { - snippet_z_bind_wrapper snippet_url -} - -# Define widgets -zle -N snippet_z_view -zle -N snippet_z_edit -zle -N snippet_z_url - -bindkey '^kv' snippet_z_view -bindkey '^ke' snippet_z_edit -bindkey '^ku' snippet_z_url - -# vim: set shiftwidth=2: diff --git a/src/sphinxnotes/snippet/integration/plugin.sh b/src/sphinxnotes/snippet/integration/plugin.sh deleted file mode 100644 index 3d24a91..0000000 --- a/src/sphinxnotes/snippet/integration/plugin.sh +++ /dev/null @@ -1,23 +0,0 @@ -# Bash Shell integration for sphinxnotes-snippet -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# :Author: Shengyu Zhang -# :Date: 2021-03-20 -# :Version: 20240828 - -# Make sure we have $SNIPPET -[ -z "$SNIPPET"] && SNIPPET='snippet' - -# Arguments: $*: Extra opts of ``snippet list`` -# Returns: snippet_id -function snippet_list() { - $SNIPPET list --width $(($(tput cols) - 2)) "$@" | \ - fzf --with-nth 2.. \ - --no-hscroll \ - --header-lines 1 \ - --margin=2 \ - --border=rounded \ - --height=60% | cut -d ' ' -f1 -} - -# vim: set shiftwidth=2: diff --git a/src/sphinxnotes/snippet/integration/plugin.vim b/src/sphinxnotes/snippet/integration/plugin.vim deleted file mode 100644 index 3cf01c5..0000000 --- a/src/sphinxnotes/snippet/integration/plugin.vim +++ /dev/null @@ -1,109 +0,0 @@ -" Vim integration for sphinxnotes-snippet -" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -" -" :Author: Shengyu Zhang -" :Date: 2021-04-01 -" :Version: 20211114 -" -" NOTE: junegunn/fzf.vim is required - -let s:snippet = 'snippet' -let s:width = 0.9 -let s:height = 0.6 - -" Use fzf to list all snippets, callback with argument id. -function g:SphinxNotesSnippetList(tags, callback) - let cmd = [s:snippet, 'list', - \ '--tags', a:tags, - \ '--width', float2nr(&columns * s:width) - 2, - \ ] - - " Use closure keyword so that inner function can access outer one's - " localvars (l:) and arguments (a:). - " https://vi.stackexchange.com/a/21807 - function! List_CB(selection) closure - let id = split(a:selection, ' ')[0] - call a:callback(id) - endfunction - - " https://github.com/junegunn/fzf/blob/master/README-VIM.md#fzfrun - call fzf#run({ - \ 'source': join(cmd, ' '), - \ 'sink': function('List_CB'), - \ 'options': ['--with-nth', '2..', '--no-hscroll', '--header-lines', '1'], - \ 'window': {'width': s:width, 'height': s:height}, - \ }) -endfunction - -" Return the attribute value of specific snippet. -function g:SphinxNotesSnippetGet(id, attr) - let cmd = [s:snippet, 'get', a:id, '--' . a:attr] - return systemlist(join(cmd, ' ')) -endfunction - -" Use fzf to list all attr of specific snippet, -" callback with arguments (attr_name, attr_value). -function g:SphinxNotesSnippetListSnippetAttrs(id, callback) - " Display attr -> Identify attr (also used as CLI option) - let attrs = { - \ 'Source': 'src', - \ 'URL': 'url', - \ 'Docname': 'docname', - \ 'Dependent files': 'deps', - \ 'Text': 'text', - \ 'Title': 'title', - \ } - let delim = ' ' - let table = ['OPTION' . delim . 'ATTRIBUTE'] - for name in keys(attrs) - call add(table, attrs[name] . delim . name) - endfor - - function! ListSnippetAttrs_CB(selection) closure - let opt = split(a:selection, ' ')[0] - let val = g:SphinxNotesSnippetGet(a:id, opt) - call a:callback(opt, val) " finally call user's cb - endfunction - - let preview_cmd = [s:snippet, 'get', a:id, '--$(echo {} | cut -d " " -f1)'] - let info_cmd = ['echo', 'Index ID:', a:id] - call fzf#run({ - \ 'source': table, - \ 'sink': function('ListSnippetAttrs_CB'), - \ 'options': [ - \ '--header-lines', '1', - \ '--with-nth', '2..', - \ '--preview', join(preview_cmd, ' '), - \ '--preview-window', ',wrap', - \ '--info-command', join(info_cmd, ' '), - \ ], - \ 'window': {'width': s:width, 'height': s:height}, - \ }) -endfunction - -function g:SphinxNotesSnippetInput(id) - function! Input_CB(attr, val) " TODO: became g:func. - if a:attr == 'docname' - " Create doc reference. - let content = ':doc:`/' . a:val[0] . '`' - elseif a:attr == 'title' - " Create local section reference. - let content = '`' . a:val[0] . '`_' - else - let content = join(a:val, '') - endif - execute 'normal! i' . content - endfunction - - call g:SphinxNotesSnippetListSnippetAttrs(a:id, function('Input_CB')) -endfunction - -function g:SphinxNotesSnippetListAndInput() - function! ListAndInput_CB(id) - call g:SphinxNotesSnippetInput(a:id) - endfunction - - call g:SphinxNotesSnippetList('"*"', function('ListAndInput_CB')) -endfunction - - " vim: set shiftwidth=2: diff --git a/src/sphinxnotes/snippet/keyword.py b/src/sphinxnotes/snippet/keyword.py deleted file mode 100644 index ef262b6..0000000 --- a/src/sphinxnotes/snippet/keyword.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -sphinxnotes.snippet.keyword -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Helper functions for keywords extraction. - -:copyright: Copyright 2021 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -import string -from collections import Counter - - -class Extractor(object): - """ - Keyword extractor based on frequency statistic. - - TODO: extract date, time - """ - - def __init__(self): - # Import NLP libs here to prevent import overhead - import logging - from langid import rank - from jieba_next import cut_for_search, setLogLevel - from pypinyin import lazy_pinyin - from wordsegment import load, segment - - # Turn off jieba debug log. - # https://github.com/fxsjy/jieba/issues/255 - setLogLevel(logging.INFO) - - load() - self._detect_langs = rank - self._tokenize_zh_cn = cut_for_search - self._tokenize_en = segment - self._pinyin = lazy_pinyin - - self._punctuation = ( - string.punctuation - + '!?。。"#$%&'()*+,-/:;<=>@[\]^_`{|}~⦅⦆「」、、〃》「」『』【】〔〕〖〗〘〙〚〛〜〝〞〟〰〾〿–—‘’‛“”„‟…‧﹏.·' - ) - - def extract(self, text: str, top_n: int | None = None) -> list[str]: - """Return keywords of given text.""" - # TODO: zh -> en - # Normalize - text = self.normalize(text) - # Tokenize - words = self.tokenize(text) - # Invalid token removal - words = self.strip_invalid_token(words) - # Stopwords removal - if top_n: - # Get top n words as keyword - keywords = Counter(words).most_common(top_n) - # Remove rank - keywords = [word for word, _ in keywords] - else: - # Keep all keywords - keywords = words - # Generate a pinyin version - keywords_pinyin = [] - for word in keywords: - pinyin = self.trans_to_pinyin(word) - if pinyin: - keywords_pinyin.append(pinyin) - return keywords + keywords_pinyin - - def normalize(self, text: str) -> str: - # Convert text to lowercase - text = text.lower() - # Remove punctuation (both english and chinese) - text = text.translate(text.maketrans('', '', self._punctuation)) - # White spaces removals - text = text.strip() - # Replace newline to whitespace - text = text.replace('\n', ' ') - return text - - def tokenize(self, text: str) -> list[str]: - # Get top most 5 langs - langs = self._detect_langs(text)[:5] - tokens = [text] - new_tokens = [] - for lang in langs: - for token in tokens: - if lang[0] == 'zh': - new_tokens += self._tokenize_zh_cn(token) - elif lang[0] == 'en': - new_tokens += self._tokenize_en(token) - else: - new_tokens += token.split(' ') - tokens = new_tokens - new_tokens = [] - return tokens - - def trans_to_pinyin(self, word: str) -> str | None: - return ' '.join(self._pinyin(word, errors='ignore')) - - def strip_invalid_token(self, tokens: list[str]) -> list[str]: - return [token for token in tokens if token != ''] diff --git a/src/sphinxnotes/snippet/meta.py b/src/sphinxnotes/snippet/meta.py deleted file mode 100644 index 27cdb4b..0000000 --- a/src/sphinxnotes/snippet/meta.py +++ /dev/null @@ -1,39 +0,0 @@ -# This file is generated from sphinx-notes/cookiecutter. -# DO NOT EDIT!!! - -################################################################################ -# Project meta infos. -################################################################################ - -from __future__ import annotations -from importlib import metadata - -from sphinx.application import Sphinx -from sphinx.util.typing import ExtensionMetadata - - -__project__ = 'sphinxnotes-snippet' -__author__ = 'Shengyu Zhang' -__desc__ = 'Sphinx documentation snippets manager' - -try: - __version__ = metadata.version('sphinxnotes-snippet') -except metadata.PackageNotFoundError: - __version__ = 'unknown' - - -################################################################################ -# Sphinx extension utils. -################################################################################ - - -def pre_setup(app: Sphinx) -> None: - app.require_sphinx('7.0') - - -def post_setup(app: Sphinx) -> ExtensionMetadata: - return { - 'version': __version__, - 'parallel_read_safe': True, - 'parallel_write_safe': True, - } diff --git a/src/sphinxnotes/snippet/picker.py b/src/sphinxnotes/snippet/picker.py deleted file mode 100644 index ea1b4cf..0000000 --- a/src/sphinxnotes/snippet/picker.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -sphinxnotes.snippet.picker -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Snippet picker implementations. - -:copyright: Copyright 2020 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from typing import TYPE_CHECKING - -from docutils import nodes - -from sphinx.util import logging - -from .snippets import Snippet, Section, Document, Code - -if TYPE_CHECKING: - from sphinx.application import Sphinx - -logger = logging.getLogger(__name__) - - -def pick( - app: Sphinx, doctree: nodes.document, docname: str -) -> list[tuple[Snippet, nodes.Element]]: - """ - Pick snippets from document, return a list of snippet and the related node. - - As :class:`Snippet` can not hold any refs to doctree, we additionly returns - the related nodes here. To ensure the caller can back reference to original - document node and do more things (e.g. generate title path). - """ - # FIXME: Why doctree.source is always None? - if not doctree.attributes.get('source'): - logger.debug('Skip document %s: no source', docname) - return [] - - metadata = app.env.metadata.get(docname, {}) - if 'no-search' in metadata or 'nosearch' in metadata: - logger.debug('Skip document %s: have :no[-]search: metadata', docname) - return [] - - # Walk doctree and pick snippets. - - picker = SnippetPicker(doctree) - doctree.walkabout(picker) - - return picker.snippets - - -class SnippetPicker(nodes.SparseNodeVisitor): - """Node visitor for picking snippets from document.""" - - #: List of picked snippets and the section it belongs to - snippets: list[tuple[Snippet, nodes.Element]] - - #: Stack of nested sections. - _sections: list[nodes.section] - - def __init__(self, doctree: nodes.document) -> None: - super().__init__(doctree) - self.snippets = [] - self._sections = [] - - ################### - # Visitor methods # - ################### - - def visit_literal_block(self, node: nodes.literal_block) -> None: - try: - code = Code(node) - except ValueError as e: - logger.debug(f'skip {node}: {e}') - raise nodes.SkipNode - self.snippets.append((code, node)) - - def visit_section(self, node: nodes.section) -> None: - self._sections.append(node) - - def depart_section(self, node: nodes.section) -> None: - section = self._sections.pop() - assert section == node - - # Always pick document. - if len(self._sections) == 0: - self.snippets.append((Document(self.document), node)) - return - # Skip non-leaf section without content - if self._is_empty_non_leaf_section(node): - return - self.snippets.append((Section(node), node)) - - def unknown_visit(self, node: nodes.Node) -> None: - pass # Ignore any unknown node - - def unknown_departure(self, node: nodes.Node) -> None: - pass # Ignore any unknown node - - ################## - # Helper methods # - ################## - - def _is_empty_non_leaf_section(self, node: nodes.section) -> bool: - """ - A section is a leaf section it has non-child section. - A section is empty when it has not non-section child node - (except the title). - """ - num_subsection = len( - list(node[0].traverse(nodes.section, descend=False, siblings=True)) - ) - num_nonsection_child = len(node) - num_subsection - 1 # -1 for title - return num_subsection != 0 and num_nonsection_child == 0 diff --git a/src/sphinxnotes/snippet/py.typed b/src/sphinxnotes/snippet/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/src/sphinxnotes/snippet/snippets.py b/src/sphinxnotes/snippet/snippets.py deleted file mode 100644 index f14661a..0000000 --- a/src/sphinxnotes/snippet/snippets.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -sphinxnotes.snippet.snippets -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Definitions of various snippets. - -:copyright: Copyright 2024 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from typing import TYPE_CHECKING -import itertools -from os import path -import sys -from pygments.lexers.shell import BashSessionLexer - -from docutils import nodes - -if TYPE_CHECKING: - from sphinx.environment import BuildEnvironment - - -class Snippet(object): - """ - Snippet is structured fragments extracted from a single Sphinx document - (usually, also a single reStructuredText file). - - :param nodes: nodes of doctree that make up this snippet. - - .. warning:: - - Snippet will be persisted to disk via pickle, to keep it simple, - it CAN NOT holds reference to any doctree ``nodes`` - (or even any non-std module). - """ - - #: docname where the snippet is located, can be referenced by - # :rst:role:`doc`. - docname: str - - #: Absolute path to the source file. - file: str - - #: Line number range of source file (:attr:`Snippet.file`), - #: left closed and right opened. - lineno: tuple[int, int] - - #: The source text read from source file (:attr:`Snippet.file`), - # in Markdown or reStructuredText. - source: list[str] - - #: Text representation of the snippet, usually generated form - # :meth:`nodes.Element.astext`. - text: list[str] - - #: The possible identifier key of snippet, which is picked from nodes' - #: (or nodes' parent's) `ids attr`_. - #: - #: .. _ids attr: https://docutils.sourceforge.io/docs/ref/doctree.html#ids - refid: str | None - - def __init__(self, *nodes: nodes.Element) -> None: - assert len(nodes) != 0 - - env: BuildEnvironment = nodes[0].document.settings.env # type: ignore - - file, docname = None, None - for node in nodes: - if (src := nodes[0].source) and path.exists(src): - file = src - docname = env.path2doc(file) - break - if not file or not docname: - raise ValueError(f'Nodes {nodes} lacks source file or docname') - self.file = file - self.docname = docname - - lineno = [sys.maxsize, -sys.maxsize] - for node in nodes: - if not node.line: - continue # Skip node that have None line, I dont know why - lineno[0] = min(lineno[0], _line_of_start(node)) - lineno[1] = max(lineno[1], _line_of_end(node)) - self.lineno = (lineno[0], lineno[1]) - - source = [] - with open(self.file, 'r') as f: - start = self.lineno[0] - 1 - stop = self.lineno[1] - 1 - for line in itertools.islice(f, start, stop): - source.append(line.strip('\n')) - self.source = source - - text = [] - for node in nodes: - text.extend(node.astext().split('\n')) - self.text = text - - # Find exactly one ID attr in nodes - self.refid = None - for node in nodes: - if node['ids']: - self.refid = node['ids'][0] - break - - # If no ID found, try parent - if not self.refid: - for node in nodes: - if node.parent['ids']: - self.refid = node.parent['ids'][0] - break - - -class Code(Snippet): - #: Language of code block - lang: str - #: Description of code block, usually the text of preceding paragraph - desc: str - - def __init__(self, node: nodes.literal_block) -> None: - assert isinstance(node, nodes.literal_block) - - self.lang = node['language'] - if self.lang not in BashSessionLexer.aliases: # TODO: support more language - raise ValueError( - f'Language {self.lang} is not supported', - ) - - self.desc = '' - # Use the preceding paragraph as descritpion. We usually write some - # descritpions before a code block. For example, The ``::`` syntax is - # a common way to create code block:: - # - # | Foo:: | - # | | Foo: - # | Bar | - # | | Bar - # - # In this case, the paragraph "Foo:" is the descritpion of the code block. - # This convention also applies to the code, code-block, sourcecode directive. - if isinstance(para := node.previous_sibling(), nodes.paragraph): - # For better display, the trailing colon is removed. - # TODO: https://en.wikipedia.org/wiki/Colon_(punctuation)#Computing - self.desc += para.astext().replace('\n', ' ').rstrip(':::︁︓﹕') - if caption := node.get('caption'): - # Use caption as descritpion. - # All of code-block, sourcecode and code directives have caption option. - # https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block - self.desc += caption - if not self.desc: - raise ValueError( - f'Node f{node} lacks description: a preceding paragraph or a caption' - ) - - if isinstance(para, nodes.paragraph): - # If we have a paragraph preceding code block, include it. - super().__init__(para, node) - # Fixup text field, it should be pure code. - self.text = node.astext().split('\n') - else: - super().__init__(node) - - -class WithTitle(object): - title: str - - def __init__(self, node: nodes.Element) -> None: - if not (title := node.next_node(nodes.title)): - raise ValueError(f'Node f{node} lacks title') - self.title = title.astext() - - -class Section(Snippet, WithTitle): - def __init__(self, node: nodes.section) -> None: - assert isinstance(node, nodes.section) - Snippet.__init__(self, node) - WithTitle.__init__(self, node) - - -class Document(Section): - #: A set of absolute paths of dependent files for document. - #: Obtained from :attr:`BuildEnvironment.dependencies`. - deps: set[str] - - def __init__(self, node: nodes.document) -> None: - assert isinstance(node, nodes.document) - super().__init__(node.next_node(nodes.section)) - - # Record document's dependent files - self.deps = set() - env: BuildEnvironment = node.settings.env - for dep in env.dependencies[self.docname]: - # Relative to documentation root -> Absolute path of file system. - self.deps.add(path.join(env.srcdir, dep)) - - -################ -# Nodes helper # -################ - - -def _line_of_start(node: nodes.Node) -> int: - assert node.line - if isinstance(node, nodes.title): - if isinstance(node.parent.parent, nodes.document): - # Spceial case for Document Title / Subtitle - return 1 - else: - # Spceial case for section title - return node.line - 1 - elif isinstance(node, nodes.section): - if isinstance(node.parent, nodes.document): - # Spceial case for top level section - return 1 - else: - # Spceial case for section - return node.line - 1 - return node.line - - -def _line_of_end(node: nodes.Node) -> int: - next_node = node.next_node(descend=False, siblings=True, ascend=True) - while next_node: - if next_node.line: - return _line_of_start(next_node) - next_node = next_node.next_node( - # Some nodes' line attr is always None, but their children has - # valid line attr - descend=True, - # If node and its children have not valid line attr, try use line - # of next node - ascend=True, - siblings=True, - ) - # No line found, return the max line of source file - if node.source and path.exists(node.source): - with open(node.source) as f: - return sum(1 for _ in f) - raise AttributeError('None source attr of node %s' % node) diff --git a/src/sphinxnotes/snippet/table.py b/src/sphinxnotes/snippet/table.py deleted file mode 100644 index 8138573..0000000 --- a/src/sphinxnotes/snippet/table.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -sphinxnotes.snippet.table -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:copyright: Copyright 2021 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from typing import Iterable - -from .cache import Index, IndexID -from .utils import ellipsis - -COLUMNS = ['id', 'tags', 'excerpt', 'path', 'keywords'] -VISIABLE_COLUMNS = COLUMNS[1:4] -COLUMN_DELIMITER = ' ' - - -def tablify(indexes: Iterable[tuple[IndexID, Index]], width: int) -> Iterable[str]: - """Create a table from sequence of indices""" - - # Calcuate width - width = width - tags_width = len(VISIABLE_COLUMNS[0]) - width -= tags_width - excerpt_width = max(int(width * 6 / 10), len(VISIABLE_COLUMNS[1])) - path_width = max(int(width * 4 / 10), len(VISIABLE_COLUMNS[2])) - path_comp_width = path_width // 3 - - # Write header - header = COLUMN_DELIMITER.join( - [ - COLUMNS[0].upper(), - ellipsis.ellipsis(COLUMNS[1].upper(), tags_width, blank_sym=' '), - ellipsis.ellipsis(COLUMNS[2].upper(), excerpt_width, blank_sym=' '), - ellipsis.ellipsis(COLUMNS[3].upper(), path_width, blank_sym=' '), - COLUMNS[4].upper(), - ] - ) - yield header - - # Write rows - for index_id, index in indexes: - # TODO: assert index? - row = COLUMN_DELIMITER.join( - [ - index_id, # ID - ellipsis.ellipsis(f'[{index[0]}]', tags_width, blank_sym=' '), # Kind - ellipsis.ellipsis(index[1], excerpt_width, blank_sym=' '), # Excerpt - ellipsis.join( - index[2], path_width, path_comp_width, blank_sym=' ' - ), # Titleppath - ','.join(index[3]), - ] - ) # Keywords - yield row diff --git a/src/sphinxnotes/snippet/utils/__init__.py b/src/sphinxnotes/snippet/utils/__init__.py deleted file mode 100644 index 64770a8..0000000 --- a/src/sphinxnotes/snippet/utils/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- -""" -sphinxnotes.utils -~~~~~~~~~~~~~~~~~ - -:copyright: Copyright 2020 by the Shengyu Zhang. -""" diff --git a/src/sphinxnotes/snippet/utils/ellipsis.py b/src/sphinxnotes/snippet/utils/ellipsis.py deleted file mode 100644 index d46bda8..0000000 --- a/src/sphinxnotes/snippet/utils/ellipsis.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -sphinxnotes.utils.ellipsis -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Utils for ellipsis string. - -:copyright: Copyright 2020 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from wcwidth import wcswidth - - -def ellipsis( - text: str, width: int, ellipsis_sym: str = '..', blank_sym: str | None = None -) -> str: - text_width = wcswidth(text) - if text_width <= width: - if blank_sym: - # Padding with blank_sym - text += blank_sym * ((width - text_width) // wcswidth(blank_sym)) - return text - width -= wcswidth(ellipsis_sym) - if width > text_width: - width = text_width - i = 0 - new_text = '' - while wcswidth(new_text) < width: - new_text += text[i] - i += 1 - return new_text + ellipsis_sym - - -def join( - lst: list[str], - total_width: int, - title_width: int, - separate_sym: str = '/', - ellipsis_sym: str = '..', - blank_sym: str | None = None, -): - # TODO: position - total_width -= wcswidth(ellipsis_sym) - result = [] - for i, ln in enumerate(lst): - ln = ellipsis(ln, title_width, ellipsis_sym=ellipsis_sym, blank_sym=None) - l_width = wcswidth(ln) + (wcswidth(separate_sym) if i != 0 else 0) - if total_width - l_width < 0: - break - result.append(ln) - total_width -= l_width - s = separate_sym.join(result) - if blank_sym: - s += blank_sym * (total_width // wcswidth(blank_sym)) - return s diff --git a/src/sphinxnotes/snippet/utils/pdict.py b/src/sphinxnotes/snippet/utils/pdict.py deleted file mode 100644 index ef93a38..0000000 --- a/src/sphinxnotes/snippet/utils/pdict.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -sphinxnotes.utils.pdict -~~~~~~~~~~~~~~~~~~~~~~~ - -A customized persistent KV store for Sphinx project. - -:copyright: Copyright 2020 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -import os -from os import path -from typing import Iterator, TypeVar -import pickle -from collections.abc import MutableMapping -from hashlib import sha1 - -K = TypeVar('K') -V = TypeVar('V') - - -# FIXME: PDict is buggy -class PDict(MutableMapping[K, V]): - """A persistent dict with event handlers.""" - - dirname: str - # The real in memory store of values - _store: dict[K, V | None] - # Items that need write back to store - _dirty_items: dict[K, V] - # Items that need purge from store - _orphan_items: dict[K, V] - - def __init__(self, dirname: str) -> None: - self.dirname = dirname - self._store = {} - self._dirty_items = {} - self._orphan_items = {} - - def __getitem__(self, key: K) -> V: - if key not in self._store: - raise KeyError - value = self._store[key] - if value is not None: - return value - # V haven't loaded yet, load it from disk - with open(self.itemfile(key), 'rb') as f: - value = pickle.load(f) - self._store[key] = value - return value - - def __setitem__(self, key: K, value: V) -> None: - assert value is not None - if key in self._store: - self.__delitem__(key) - self._dirty_items[key] = value - self._store[key] = value - - def __delitem__(self, key: K) -> None: - value = self.__getitem__(key) - del self._store[key] - if key in self._dirty_items: - del self._dirty_items[key] - else: - self._orphan_items[key] = value - - def __iter__(self) -> Iterator: - return iter(self._store) - - def __len__(self) -> int: - return len(self._store) - - def _keytransform(self, key: K) -> K: - # No used - return key - - def load(self) -> None: - with open(self.dictfile(), 'rb') as f: - obj = pickle.load(f) - self.__dict__.update(obj.__dict__) - - def dump(self): - """Dump store to disk.""" - # sphinx.util.status_iterator alias has been deprecated since sphinx 6.1 - # and will be removed in sphinx 8.0 - try: - from sphinx.util.display import status_iterator - except ImportError: - from sphinx.util import status_iterator - - # Makesure dir exists - if not path.exists(self.dirname): - os.makedirs(self.dirname) - - # Purge orphan items - for key, value in status_iterator( - self._orphan_items.items(), - 'purging orphan document(s)... ', - 'brown', - len(self._orphan_items), - 0, - stringify_func=lambda i: self.stringify(i[0], i[1]), - ): - os.remove(self.itemfile(key)) - self.post_purge(key, value) - - # Dump dirty items - for key, value in status_iterator( - self._dirty_items.items(), - 'dumping dirty document(s)... ', - 'brown', - len(self._dirty_items), - 0, - stringify_func=lambda i: self.stringify(i[0], i[1]), - ): - with open(self.itemfile(key), 'wb') as f: - pickle.dump(value, f) - self.post_dump(key, value) - - # Clear all in-memory items - self._orphan_items = {} - self._dirty_items = {} - self._store = {key: None for key in self._store} - - # Dump store itself - with open(self.dictfile(), 'wb') as f: - pickle.dump(self, f) - - def dictfile(self) -> str: - return path.join(self.dirname, 'dict.pickle') - - def itemfile(self, key: K) -> str: - hasher = sha1() - hasher.update(pickle.dumps(key)) - return path.join(self.dirname, hasher.hexdigest()[:7] + '.pickle') - - def post_dump(self, key: K, value: V) -> None: - pass - - def post_purge(self, key: K, value: V) -> None: - pass - - def stringify(self, key: K, value: V) -> str: - return str(key) diff --git a/src/sphinxnotes/snippet/utils/titlepath.py b/src/sphinxnotes/snippet/utils/titlepath.py deleted file mode 100644 index eaa6bc3..0000000 --- a/src/sphinxnotes/snippet/utils/titlepath.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -sphinxnotes.utils.titlepath -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Utils for ellipsis string. - -:copyright: Copyright 2020 Shengyu Zhang -:license: BSD, see LICENSE for details. -""" - -from __future__ import annotations -from typing import TYPE_CHECKING - -from docutils import nodes - -if TYPE_CHECKING: - from sphinx.environment import BuildEnvironment - - -def resolve( - env: BuildEnvironment, docname: str, node: nodes.Element -) -> list[nodes.title]: - return resolve_section(node) + resolve_document(env, docname) - - -def resolve_section(node: nodes.Element) -> list[nodes.title]: - titlenodes = [] - while node: - if len(node) > 0 and isinstance(node[0], nodes.title): - titlenodes.append(node[0]) - node = node.parent - return titlenodes - - -def resolve_document(env: BuildEnvironment, docname: str) -> list[nodes.title]: - """NOTE: Title of document itself does not included in the returned list""" - titles = [] - master_doc = env.config.master_doc - v = docname.split('/') - - # Exclude self - if v.pop() == master_doc and v: - # If self is master_doc, like: "a/b/c/index", we only return titles - # of "a/b/", so pop again - v.pop() - - # Collect master doc title in docname - while v: - master_docname = '/'.join(v + [master_doc]) - if master_docname in env.titles: - title = env.titles[master_docname] - else: - title = nodes.title(text=v[-1].title()) # FIXME: Create mock title for now - titles.append(title) - v.pop() - - # Include title of top-level master doc - if master_doc in env.titles: - titles.append(env.titles[master_doc]) - - return titles From 5e3a82febebcaa81984a5c836a95bb0a9390fcfc Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Tue, 10 Feb 2026 11:37:30 +0800 Subject: [PATCH 6/6] chore: More update --- docs/conf.py | 2 +- docs/conf.py.rej | 10 ------- pyproject.toml | 10 +++---- pyproject.toml.rej | 15 ---------- src/sphinxnotes/picker/cli.py | 8 +++--- .../picker/integration/binding.nvim | 10 +++---- .../picker/integration/binding.vim | 28 +++++++++---------- src/sphinxnotes/picker/integration/plugin.sh | 2 +- src/sphinxnotes/picker/integration/plugin.vim | 24 ++++++++-------- 9 files changed, 42 insertions(+), 67 deletions(-) delete mode 100644 docs/conf.py.rej delete mode 100644 pyproject.toml.rej diff --git a/docs/conf.py b/docs/conf.py index 898a652..f6b325e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ # -- Project information ----------------------------------------------------- -project = 'sphinxnotes-snippet' +project = 'sphinxnotes-picker' author = 'Shengyu Zhang' copyright = "2023, " + author diff --git a/docs/conf.py.rej b/docs/conf.py.rej deleted file mode 100644 index a70162a..0000000 --- a/docs/conf.py.rej +++ /dev/null @@ -1,10 +0,0 @@ -diff a/docs/conf.py b/docs/conf.py (rejected hunks) -@@ -9,7 +9,7 @@ - - # -- Project information ----------------------------------------------------- - --project = 'sphinxnotes-snippet' -+project = 'sphinxnotes-picker' - author = 'Shengyu Zhang' - copyright = "2026, " + author - diff --git a/pyproject.toml b/pyproject.toml index e439000..273b737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ classifiers = [ # CUSTOM CLASSIFIERS END ] -# See ``make pyver`` for more details. requires-python = ">=3.12" dependencies = [ "Sphinx >= 7.0", @@ -85,14 +84,15 @@ docs = [ ] [project.urls] -homepage = "https://sphinx.silverrainz.me/snippet" -documentation = "https://sphinx.silverrainz.me/snippet" +homepage = "https://sphinx.silverrainz.me/picker" +documentation = "https://sphinx.silverrainz.me/picker" repository = "https://github.com/sphinx-notes/snippet" -changelog = "https://sphinx.silverrainz.me/snippet/changelog.html" +changelog = "https://sphinx.silverrainz.me/picker/changelog.html" tracker = "https://github.com/sphinx-notes/snippet/issues" [project.scripts] -picker = "sphinxnotes.picker.cli:main" +sphinx-picker = "sphinxnotes.picker.cli:main" +sphinxnotes-picker = "sphinxnotes.picker.cli:main" [build-system] requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] diff --git a/pyproject.toml.rej b/pyproject.toml.rej deleted file mode 100644 index c456ad1..0000000 --- a/pyproject.toml.rej +++ /dev/null @@ -1,15 +0,0 @@ -diff a/pyproject.toml b/pyproject.toml (rejected hunks) -@@ -77,10 +77,10 @@ docs = [ - ] - - [project.urls] --homepage = "https://sphinx.silverrainz.me/snippet" --documentation = "https://sphinx.silverrainz.me/snippet" -+homepage = "https://sphinx.silverrainz.me/picker" -+documentation = "https://sphinx.silverrainz.me/picker" - repository = "https://github.com/sphinx-notes/snippet" --changelog = "https://sphinx.silverrainz.me/snippet/changelog.html" -+changelog = "https://sphinx.silverrainz.me/picker/changelog.html" - tracker = "https://github.com/sphinx-notes/snippet/issues" - - [build-system] diff --git a/src/sphinxnotes/picker/cli.py b/src/sphinxnotes/picker/cli.py index 38c1a77..b0782b0 100644 --- a/src/sphinxnotes/picker/cli.py +++ b/src/sphinxnotes/picker/cli.py @@ -56,14 +56,14 @@ def main(argv: list[str] = sys.argv[1:]): parser = argparse.ArgumentParser( prog=__name__, - description='Sphinx documentation snippets manager', + description='Sphinx documentation pickers', formatter_class=HelpFormatter, epilog=dedent(""" - picker tags: + filter tags: d (document) a document s (section) a section c (code) a code block - * (any) wildcard for any picker"""), + * (any) wildcard for any target"""), ) parser.add_argument( '--version', @@ -228,7 +228,7 @@ def _on_command_stat(args: argparse.Namespace): num_projects = len(cache.num_snippets_by_project) num_docs = len(cache.num_snippets_by_docid) num_snippets = sum(cache.num_snippets_by_project.values()) - print(f'snippets are loaded from {cache.dirname}') + print(f'indexes are loaded from {cache.dirname}') print(f'configuration are loaded from {args.config}') print(f'integration files are located at {get_integration_file("")}') print('') diff --git a/src/sphinxnotes/picker/integration/binding.nvim b/src/sphinxnotes/picker/integration/binding.nvim index a5f7fd8..ead2218 100644 --- a/src/sphinxnotes/picker/integration/binding.nvim +++ b/src/sphinxnotes/picker/integration/binding.nvim @@ -7,15 +7,15 @@ " " TODO: Support vim? -function! g:SphinxNotesSnippetListAndView() +function! g:SphinxNotesPickerListAndView() function! ListAndView_CB(id) - call g:SphinxNotesSnippetView(a:id) + call g:SphinxNotesPickerView(a:id) endfunction - call g:SphinxNotesSnippetList('"*"', function('ListAndView_CB')) + call g:SphinxNotesPickerList('"*"', function('ListAndView_CB')) endfunction " https://github.com/anhmv/vim-float-window/blob/master/plugin/float-window.vim -function! g:SphinxNotesSnippetView(id) +function! g:SphinxNotesPickerView(id) let height = float2nr((&lines - 2) / 1.5) let row = float2nr((&lines - height) / 2) let width = float2nr(&columns / 1.5) @@ -48,7 +48,7 @@ function! g:SphinxNotesSnippetView(id) call append(line('$'), ['.. hint:: Press to return']) endfunction -nmap v :call g:SphinxNotesSnippetListAndView() +nmap v :call g:SphinxNotesPickerListAndView() " vim: set shiftwidth=2: " vim: set ft=vim: diff --git a/src/sphinxnotes/picker/integration/binding.vim b/src/sphinxnotes/picker/integration/binding.vim index b921aa5..ada7edc 100644 --- a/src/sphinxnotes/picker/integration/binding.vim +++ b/src/sphinxnotes/picker/integration/binding.vim @@ -6,9 +6,9 @@ " :Version: 20211114 " -function g:SphinxNotesSnippetEdit(id) - let file = g:SphinxNotesSnippetGet(a:id, 'file')[0] - let line = g:SphinxNotesSnippetGet(a:id, 'line-start')[0] +function g:SphinxNotesPickerEdit(id) + let file = g:SphinxNotesPickerGet(a:id, 'file')[0] + let line = g:SphinxNotesPickerGet(a:id, 'line-start')[0] if &modified execute 'vsplit ' . file else @@ -17,29 +17,29 @@ function g:SphinxNotesSnippetEdit(id) execute line endfunction -function g:SphinxNotesSnippetListAndEdit() +function g:SphinxNotesPickerListAndEdit() function! ListAndEdit_CB(id) - call g:SphinxNotesSnippetEdit(a:id) + call g:SphinxNotesPickerEdit(a:id) endfunction - call g:SphinxNotesSnippetList('ds', function('ListAndEdit_CB')) + call g:SphinxNotesPickerList('ds', function('ListAndEdit_CB')) endfunction -function g:SphinxNotesSnippetUrl(id) - let url_list = g:SphinxNotesSnippetGet(a:id, 'url') +function g:SphinxNotesPickerUrl(id) + let url_list = g:SphinxNotesPickerGet(a:id, 'url') for url in url_list echo system('xdg-open ' . shellescape(url)) endfor endfunction -function g:SphinxNotesSnippetListAndUrl() +function g:SphinxNotesPickerListAndUrl() function! ListAndUrl_CB(id) - call g:SphinxNotesSnippetUrl(a:id) + call g:SphinxNotesPickerUrl(a:id) endfunction - call g:SphinxNotesSnippetList('ds', function('ListAndUrl_CB')) + call g:SphinxNotesPickerList('ds', function('ListAndUrl_CB')) endfunction -nmap e :call g:SphinxNotesSnippetListAndEdit() -nmap u :call g:SphinxNotesSnippetListAndUrl() -nmap i :call g:SphinxNotesSnippetListAndInput() +nmap e :call g:SphinxNotesPickerListAndEdit() +nmap u :call g:SphinxNotesPickerListAndUrl() +nmap i :call g:SphinxNotesPickerListAndInput() " vim: set shiftwidth=2: diff --git a/src/sphinxnotes/picker/integration/plugin.sh b/src/sphinxnotes/picker/integration/plugin.sh index 621fa46..9777f37 100644 --- a/src/sphinxnotes/picker/integration/plugin.sh +++ b/src/sphinxnotes/picker/integration/plugin.sh @@ -6,7 +6,7 @@ # :Version: 20240828 # Make sure we have $PICKER -[ -z "$PICKER"] && PICKER='picker' +[ -z "$PICKER"] && PICKER='sphinxnotes-picker' # Arguments: $*: Extra opts of ``picker list`` # Returns: picker_id diff --git a/src/sphinxnotes/picker/integration/plugin.vim b/src/sphinxnotes/picker/integration/plugin.vim index b004f92..106aad8 100644 --- a/src/sphinxnotes/picker/integration/plugin.vim +++ b/src/sphinxnotes/picker/integration/plugin.vim @@ -7,12 +7,12 @@ " " NOTE: junegunn/fzf.vim is required -let s:picker = 'picker' +let s:picker = 'sphinxnotes-picker' let s:width = 0.9 let s:height = 0.6 " Use fzf to list all snippets, callback with argument id. -function g:SphinxNotesSnippetList(tags, callback) +function g:SphinxNotesPickerList(tags, callback) let cmd = [s:picker, 'list', \ '--tags', a:tags, \ '--width', float2nr(&columns * s:width) - 2, @@ -36,14 +36,14 @@ function g:SphinxNotesSnippetList(tags, callback) endfunction " Return the attribute value of specific snippet. -function g:SphinxNotesSnippetGet(id, attr) +function g:SphinxNotesPickerGet(id, attr) let cmd = [s:picker, 'get', a:id, '--' . a:attr] return systemlist(join(cmd, ' ')) endfunction " Use fzf to list all attr of specific snippet, " callback with arguments (attr_name, attr_value). -function g:SphinxNotesSnippetListSnippetAttrs(id, callback) +function g:SphinxNotesPickerListPickerAttrs(id, callback) " Display attr -> Identify attr (also used as CLI option) let attrs = { \ 'Source': 'src', @@ -59,9 +59,9 @@ function g:SphinxNotesSnippetListSnippetAttrs(id, callback) call add(table, attrs[name] . delim . name) endfor - function! ListSnippetAttrs_CB(selection) closure + function! ListPickerAttrs_CB(selection) closure let opt = split(a:selection, ' ')[0] - let val = g:SphinxNotesSnippetGet(a:id, opt) + let val = g:SphinxNotesPickerGet(a:id, opt) call a:callback(opt, val) " finally call user's cb endfunction @@ -69,7 +69,7 @@ function g:SphinxNotesSnippetListSnippetAttrs(id, callback) let info_cmd = ['echo', 'Index ID:', a:id] call fzf#run({ \ 'source': table, - \ 'sink': function('ListSnippetAttrs_CB'), + \ 'sink': function('ListPickerAttrs_CB'), \ 'options': [ \ '--header-lines', '1', \ '--with-nth', '2..', @@ -81,7 +81,7 @@ function g:SphinxNotesSnippetListSnippetAttrs(id, callback) \ }) endfunction -function g:SphinxNotesSnippetInput(id) +function g:SphinxNotesPickerInput(id) function! Input_CB(attr, val) " TODO: became g:func. if a:attr == 'docname' " Create doc reference. @@ -95,15 +95,15 @@ function g:SphinxNotesSnippetInput(id) execute 'normal! i' . content endfunction - call g:SphinxNotesSnippetListSnippetAttrs(a:id, function('Input_CB')) + call g:SphinxNotesPickerListPickerAttrs(a:id, function('Input_CB')) endfunction -function g:SphinxNotesSnippetListAndInput() +function g:SphinxNotesPickerListAndInput() function! ListAndInput_CB(id) - call g:SphinxNotesSnippetInput(a:id) + call g:SphinxNotesPickerInput(a:id) endfunction - call g:SphinxNotesSnippetList('"*"', function('ListAndInput_CB')) + call g:SphinxNotesPickerList('"*"', function('ListAndInput_CB')) endfunction " vim: set shiftwidth=2: