"""检索与阅读的子命令:forms / word / search / dist / get。 全部只读,不需要凭据。 """ import html as html_mod import json import re import sys from client import make_client, note from coords import fmt_coord, fmt_path, parse_coord, parse_coords, text_layer from errors import ApiError, WpError, explain_api_error # 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析 # 是同一个调用换 channel。 PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6' # 靠 channel 名字判断机器译文很脆弱:库里既有名字含 "AI" 的,也有直接用模型名命名的 # (deepseek / qwen-max / grok-简体中文 / gemini / 豆包 / ChatGPT),后者不含 "ai"。 # 这个清单只用来「提醒去核实」,不作为判定依据——权威判定看 get 返回的作者是不是模型。 MACHINE_HINTS = ('ai', 'gpt', 'chatgpt', 'claude', 'deepseek', 'gemini', 'qwen', 'grok', 'llama', 'mistral', 'kimi', 'norbu', '豆包', '文心', 'ernie', '通义') # 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。 READ_TIMEOUT = 60 def strip_markup(raw, hl='【】', bold='**'): """把服务端返回的 HTML 压成纯文本,保留命中高亮与黑体两种信息。 命中词用 包,黑体是原文的 ——后者是注释书 标出词条的地方,对判断「这段是不是定义」很关键,不能丢。 """ if not raw: return '' text = raw text = re.sub(r"(.*?)", hl[0] + r'\1' + hl[1], text, flags=re.S) text = re.sub(r'(.*?)', bold + r'\1' + bold, text, flags=re.S) text = re.sub(r"]*>", '', text) text = re.sub(r'<[^>]+>', '', text) text = html_mod.unescape(text) return re.sub(r'\s+', ' ', text).strip() def snippet(text, width, around=None): """截断文本;给了 around 就尽量把它所在的位置露出来。""" if len(text) <= width: return text if around: pos = text.find(around) if pos > width // 2: start = pos - width // 3 return '…' + text[start:start + width] + '…' return text[:width] + '…' def emit(args, payload, render): if getattr(args, 'json', False): print(json.dumps(payload, ensure_ascii=False, indent=2)) else: render() # --------------------------------------------------------------------------- # forms —— 词形展开,一切检索的前置 # --------------------------------------------------------------------------- def fetch_forms(client, word): try: data = client.call('GET', f'v2/case/{word}', timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'展开词形 {word}') return (data or {}).get('rows') or [] def cmd_forms(args): client = make_client(args) rows = fetch_forms(client, args.word) if not rows: raise WpError( f'「{args.word}」在语料里找不到任何词形。检查拼写(变音符号是否正确),' '或换一个可能的词根再试。' ) def render(): for idx, row in enumerate(rows[: args.limit], 1): forms = row.get('case') or [] total = sum(int(f.get('count') or 0) for f in forms) bold = sum(int(f.get('bold') or 0) for f in forms) mark = ' ← 可能性最高' if idx == 1 else '' print(f'[{idx}] {row.get("word")} {len(forms)} 形 / 共 {total} 次(黑体 {bold}){mark}') for f in sorted(forms, key=lambda x: -int(x.get('count') or 0)): print(f' {f.get("word"):<20} {f.get("count"):>5} 次 黑体 {f.get("bold")}') print() print('检索用(第一候选的全部词形):') print(' ' + forms_arg(rows[0])) if len(rows) > 1: print('注意:还有其他候选词根。若目标概念同时有名词与动词两条线,两条都要展开。') emit(args, rows, render) return 0 def forms_arg(row): """把一个候选的全部词形拼成 search 要的逗号串。""" return ','.join(f.get('word') for f in (row.get('case') or []) if f.get('word')) # --------------------------------------------------------------------------- # word —— 词典释义与形态分析,用来确认选对了词根 # --------------------------------------------------------------------------- def cmd_word(args): client = make_client(args) try: data = client.call('GET', 'v2/dict', query={'word': args.word, 'lang': args.lang}, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'查词典 {args.word}') groups = (data or {}).get('words') or [] if not groups: raise WpError(f'词典里没有「{args.word}」。') def render(): for grp in groups: for w in (grp.get('words') or [])[: args.limit]: print(f'■ {w.get("word")}') for g in (w.get('grammar') or [])[:6]: print(f' ← {g.get("parent")} {g.get("type")} {g.get("grammar")}' f' ({g.get("factors")})') for d in (w.get('dict') or [])[: args.dicts]: # 释义在 note;description 是词典本身的介绍,不是词条内容 meaning = strip_markup(d.get('note') or '') if not meaning: continue print(f' 〔{d.get("shortname")}·{d.get("lang")}〕{snippet(meaning, 220)}') print() emit(args, groups, render) return 0 # --------------------------------------------------------------------------- # search —— 按词形检索段落 # --------------------------------------------------------------------------- def resolve_key(client, args): """确定检索用的词形串。--lemma 会先跑一次 forms,并把展开结果打出来。""" if args.lemma: rows = fetch_forms(client, args.lemma) if not rows: raise WpError(f'「{args.lemma}」展不出任何词形。') key = forms_arg(rows[0]) note(f'⚠ 已把词根「{args.lemma}」展开为 {len(key.split(","))} 个词形:{key}') return key key = ','.join(part.strip() for item in args.forms for part in item.split(',') if part.strip()) if not key: raise WpError('没有给出词形。用 --lemma <词根> 自动展开,或直接给逗号分隔的词形。') return key def cmd_search(args): client = make_client(args) key = resolve_key(client, args) query = {'key': key, 'limit': args.limit, 'offset': args.offset} if args.bold: query['bold'] = 'on' if args.book: query['book'] = args.book if args.tags: query['tags'] = args.tags try: data = client.call('GET', 'v2/search-pali-wbw', query=query, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, '检索') rows = (data or {}).get('rows') or [] total = (data or {}).get('count', 0) def render(): scope = [] if args.bold: scope.append('仅黑体') if args.book: scope.append(f'book={args.book}') if args.tags: scope.append(f'tags={args.tags}') print(f'命中 {total} 段,本页 {len(rows)}(offset {args.offset})' + (f' [{" ".join(scope)}]' if scope else '')) if not rows: print('\n0 条。依次怀疑:词形没展开(用 --lemma)→ 词根选错 → 范围限太窄。') return print() for idx, r in enumerate(rows, 1 + args.offset): coord = fmt_coord(r.get('book'), r.get('paragraph')) print(f'[{idx}] {coord} {fmt_path(r.get("path"))} rank {r.get("rank")}') print(f' {snippet(strip_markup(r.get("highlight")), args.width, "【")}') print(f'\n引用时用坐标 book:paragraph,取原文用:wikipali get {rows[0].get("book")}:' f'{rows[0].get("paragraph")}') emit(args, {'count': total, 'rows': rows}, render) return 0 # --------------------------------------------------------------------------- # dist —— 出处分布 # --------------------------------------------------------------------------- def cmd_dist(args): client = make_client(args) key = resolve_key(client, args) query = {'key': key} if args.tags: query['tags'] = args.tags try: data = client.call('GET', 'v2/search-pali-wbw-books', query=query, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, '统计出处分布') rows = (data or {}).get('rows') or [] def render(): total = sum(int(r.get('count') or 0) for r in rows) print(f'{len(rows)} 部书,共 {total} 次词命中\n' '(注意:这里数的是词次,不是段落数。段落数用 search 的 count,' '两者不相等——同一段里出现多次只算一段。)\n') by_layer = {} for r in sorted(rows, key=lambda x: -int(x.get('count') or 0))[: args.limit]: layer = text_layer(r.get('tags')) by_layer[layer] = by_layer.get(layer, 0) + int(r.get('count') or 0) tags = ' '.join(t.get('name') for t in (r.get('tags') or []) if t.get('name')) print(f'{r.get("count"):>5} {str(r.get("paliTitle"))[:38]:<40} ' f'--book {r.get("pcdBookId")} [{tags}]') print('\n按文献层次:', end='') for layer in ('mūla', 'aṭṭhakathā', 'ṭīkā', ''): if layer in by_layer: print(f' {layer or "未标层次"} {by_layer[layer]}', end='') print('\n引用时必须标明层次——把义注的解释当成本文的说法是学术错误。') emit(args, {'rows': rows}, render) return 0 # --------------------------------------------------------------------------- # get —— 按坐标取原文/译文 # --------------------------------------------------------------------------- def cmd_get(args): client = make_client(args) grouped = parse_coords(args.coords) channels = ','.join(args.channel) if args.channel else PALI_CHANNEL collected = [] for book, paras in grouped.items(): # 服务端不带 channels 会 500,所以 channels 永远要给 query = {'view': 'paragraph', 'book': book, 'para': ','.join(str(p) for p in paras), 'channels': channels, 'limit': args.limit} try: data = client.call('GET', 'v2/sentence', query=query, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'取 {book} 的段落') collected.extend((data or {}).get('rows') or []) def render(): if not collected: print('这些坐标在指定 channel 下没有内容。') print('注意:这是「该 channel 在此处没有文本」,不是「查询失败」——' '如实报告,不要拿相邻段落或别的译本凑。') return current = None for r in collected: ch = (r.get('channel') or {}) head = (r.get('book'), r.get('paragraph'), ch.get('uid')) if head != current: current = head editor = (r.get('editor') or {}) who = editor.get('nickName') or editor.get('name') or '' print(f'\n=== {fmt_coord(r.get("book"), r.get("paragraph"))} ' f'{ch.get("name")}({ch.get("lang")})' + (f' 作者:{who}' if who else '') + ' ===') text = strip_markup(r.get('content')) print(f' [{r.get("word_start")}-{r.get("word_end")}] {text}') print(f'\n共 {len(collected)} 句。') emit(args, collected, render) return 0 # --------------------------------------------------------------------------- # toc —— 章节目录 # --------------------------------------------------------------------------- def cmd_toc(args): client = make_client(args) book, para = parse_coord(args.coord) try: data = client.call('GET', 'v2/palitext', query={'view': 'book-toc', 'book': book, 'para': para}, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'取 {book}:{para} 的章节目录') rows = (data or {}).get('rows') or [] # 服务端返回的是整套丛书的目录,默认只留当前这本,避免刷屏 shown = rows if args.all else [r for r in rows if r.get('book') == book] def render(): print(f'{len(rows)} 条目录条目' + ('' if args.all else f',其中 book {book} 有 {len(shown)} 条(--all 看整套丛书)')) for r in shown: level = int(r.get('level') or 1) if level > args.depth: continue print(f'{" " * (level - 1)}{r.get("book")}:{r.get("paragraph")} {r.get("toc")}') emit(args, shown, render) return 0 # --------------------------------------------------------------------------- # chapter —— 先报体量,再取整章 # --------------------------------------------------------------------------- def fetch_meta(client, book, para): try: return client.call('GET', f'v2/palitext/{book}-{para}', timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'取 {book}:{para} 的段落元信息') def parse_path(raw): """这个端点的 path 是 JSON 字符串,search 那边却是数组——两边都要能吃。""" if isinstance(raw, str): try: return json.loads(raw) except ValueError: return [] return raw or [] def resolve_chapter(client, book, para): """给任意段号,向上找到它所属的章节节点。返回 (章节 meta, 走了几层)。 注意:正文段自己也带 chapter_len(值为 1),所以不能用「有没有这个字段」判断, 要看它是不是 > 1。向上一层优先取 path 的末项(那就是直接所属的章节), 没有 path 才退回 parent。 """ meta = fetch_meta(client, book, para) hops = 0 while meta and int(meta.get('chapter_len') or 0) <= 1 and hops < 6: up = None path = parse_path(meta.get('path')) if path: last = path[-1] if int(last.get('paragraph', -1)) != int(meta.get('paragraph', -1)): up = int(last['paragraph']) if up is None and meta.get('parent'): up = int(meta['parent']) if up is None: break meta = fetch_meta(client, book, up) hops += 1 return meta, hops def cmd_chapter(args): client = make_client(args) book, para = parse_coord(args.coord) meta, hops = resolve_chapter(client, book, para) if not meta or not meta.get('chapter_len'): raise WpError(f'{book}:{para} 向上找不到章节节点,无法确定章节范围。') start = int(meta['paragraph']) length = int(meta['chapter_len']) strlen = int(meta.get('chapter_strlen') or 0) end = start + length - 1 path = parse_path(meta.get('path')) title = meta.get('toc') or meta.get('title') or (path[-1].get('title') if path else '') print(f'章节 : {title}') print(f'路径 : {fmt_path(path)}') print(f'范围 : {book}:{start} – {book}:{end}({length} 段)' + (f',约 {strlen} 字符' if strlen else '')) if hops: print(f'({book}:{para} 是正文段,向上 {hops} 层找到所属章节)') if meta.get('prev_chapter') or meta.get('next_chapter'): print(f'相邻 : 上一章 {book}:{meta.get("prev_chapter")} 下一章 {book}:{meta.get("next_chapter")}') if not args.fetch: print(f'\n只报体量,未取文。确认要读再加 --fetch;只要其中几段用:' f'wikipali get {book}:{start} {book}:{start + 1} …') return 0 if strlen > args.warn_at: note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。') args.coords = [f'{book}:{p}' for p in range(start, end + 1)] args.limit = max(args.limit, length * 20) return cmd_get(args) # --------------------------------------------------------------------------- # versions —— 某坐标有哪些译本,以及没有哪些 # --------------------------------------------------------------------------- def cmd_versions(args): client = make_client(args) book, para = parse_coord(args.coord) try: data = client.call('GET', 'v2/channel', query={'view': 'paragraphs', 'book_id': book, 'para': para}, timeout=READ_TIMEOUT) except ApiError as exc: if exc.status and exc.status >= 500: raise WpError( f'查 {book}:{para} 的可用译本失败(HTTP {exc.status})。\n' '稳定版站点上 channel?view=paragraphs 有已知缺陷,修复只在最新版代码上。\n' '请切到最新版再试:wikipali endpoint next,或本次调用加 --api next。' ) raise explain_api_error(exc, f'查 {book}:{para} 的可用译本') rows = (data or {}).get('rows') or [] def render(): if not rows: print(f'{book}:{para} 在任何 channel 下都没有内容。') return print(f'{book}:{para} 有 {len(rows)} 个 channel 存有内容:\n') by_type = {} for r in rows: by_type.setdefault(r.get('type') or '?', []).append(r) for typ in sorted(by_type): print(f' [{typ}]') for r in sorted(by_type[typ], key=lambda x: str(x.get('lang'))): name_l = (r.get('name') or '').lower() ai = ' ⚠疑似机器译' if any(h in name_l for h in MACHINE_HINTS) else '' print(f' {str(r.get("lang")):<8} {str(r.get("name"))[:36]:<38} {r.get("uid")}{ai}') langs = {str(r.get('lang')) for r in rows} missing = [l for l in ('pali', 'my', 'zh-Hans', 'zh', 'en', 'th') if l not in langs] if missing: print(f'\n该段**没有**这些语言的内容:{", ".join(missing)}') print('如实报告「无」,不要拿相邻段落或别的译本凑。') print('\n标 ⚠疑似机器译 的按机器译文标注引用。**没标的不等于是人译**——' '名字判断很脆弱,权威做法是 wikipali get 看作者是不是模型,见 conventions.md。') emit(args, rows, render) return 0 # --------------------------------------------------------------------------- # count —— 词频合计 # --------------------------------------------------------------------------- def cmd_count(args): client = make_client(args) out = [] for word in args.words: rows = fetch_forms(client, word) if not rows: out.append({'word': word, 'found': False}) continue top = rows[0] forms = top.get('case') or [] out.append({ 'word': word, 'found': True, 'lemma': top.get('word'), 'forms': len(forms), 'total': sum(int(f.get('count') or 0) for f in forms), 'bold': sum(int(f.get('bold') or 0) for f in forms), }) def render(): print(f'{"词":<28}{"词根":<24}{"词形":>5}{"词次":>8}{"黑体":>7}') for r in out: if not r['found']: print(f'{r["word"]:<28}{"(语料中未见)":<24}') continue print(f'{r["word"]:<28}{r["lemma"]:<24}{r["forms"]:>5}{r["total"]:>8}{r["bold"]:>7}') print('\n这里数的是**词次**,不是段落数。段落数用 search 的 count。') emit(args, out, render) return 0 # --------------------------------------------------------------------------- # terms —— 术语表(权威译名对照) # --------------------------------------------------------------------------- def terms_cache_path(lang, view): import os from creds import CREDS_DIR return os.path.join(CREDS_DIR, 'cache', f'terms-{view}-{lang}.json') def cmd_terms(args): import os client = make_client(args) path = terms_cache_path(args.lang, args.view) rows = None if os.path.exists(path) and not args.refresh: try: with open(path, encoding='utf-8') as fh: rows = json.load(fh) except (OSError, ValueError): rows = None if rows is None: note('正在拉取术语表全表(服务端不支持按词查询,只能整表拉后本地过滤)…') try: data = client.call('GET', 'v2/term-vocabulary', query={'view': args.view, 'lang': args.lang}, timeout=120) except ApiError as exc: raise explain_api_error(exc, '取术语表') rows = (data or {}).get('rows') or [] os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w', encoding='utf-8') as fh: json.dump(rows, fh, ensure_ascii=False) note(f'已缓存 {len(rows)} 条到 {path}(--refresh 可强制更新)') kw = (args.keyword or '').lower() hits = [r for r in rows if kw in (r.get('word') or '').lower()] if kw else rows def render(): if not hits: print(f'术语表({args.view} / {args.lang},共 {len(rows)} 条)里没有含「{args.keyword}」的词条。') print('注意:这只说明术语表没收录,不代表语料里没有这个词。') return print(f'{len(hits)} 条(全表 {len(rows)}):\n') for r in hits[: args.limit]: tag = f' [{r["tag"]}]' if r.get('tag') else '' other = f' / {r["other_meaning"]}' if r.get('other_meaning') else '' print(f' {r.get("word"):<32} {r.get("meaning")}{other}{tag}') if len(hits) > args.limit: print(f' …… 其余 {len(hits) - args.limit} 条(--limit 调整)') print('\n术语表是**权威译名对照**,写译文或论文时的用词应与它一致;' '与它不一致时要说明理由。') emit(args, hits, render) return 0 # --------------------------------------------------------------------------- # related —— 本文 ↔ 义注 ↔ 复注的段落对应 # --------------------------------------------------------------------------- def cmd_related(args): client = make_client(args) book, para = parse_coord(args.coord) try: data = client.call('GET', 'v2/related-paragraph', query={'book': book, 'para': para}, timeout=READ_TIMEOUT) except ApiError as exc: if exc.status and exc.status >= 500: # 服务端在「查无关联」时会抛异常(修复已合并,未部署到稳定版站点)。 # 对使用者来说这多半就是「没有关联段落」,但不能替服务端断言,如实说明两种可能。 raise WpError( f'查 {book}:{para} 的关联段落失败(HTTP {exc.status})。\n' '最可能的原因是**该段没有关联段落**——稳定版站点在这种情况下会报 500,\n' '服务端修复已合并但尚未部署。也可能是服务本身有问题。\n' '两者无法从这里区分,**不要据此断言「该段有/没有注释」**;\n' '可以换最新版试试:wikipali --api next related {0}:{1}'.format(book, para) ) raise explain_api_error(exc, f'查 {book}:{para} 的关联段落') rows = (data or {}).get('rows') or [] def render(): if not rows: print(f'{book}:{para} 没有关联段落。') print('约 2% 的段落没有 CST 锚点,这是正常结果,不是查询失败——' '如实报告,不要转而去注释书里搜关键词充数。') return print(f'{book}:{para} 关联到 {len(rows)} 部书:\n') order = {'mūla': 0, 'aṭṭhakathā': 1, 'ṭīkā': 2} rows.sort(key=lambda r: order.get(text_layer(r.get('tags')), 9)) for r in rows: layer = text_layer(r.get('tags')) or '未标层次' paras = r.get('para') or [] coords = ' '.join(f'{r.get("book")}:{p}' for p in paras[:8]) more = f' …共 {len(paras)} 段' if len(paras) > 8 else '' here = ' ← 当前' if int(r.get('book', -1)) == book and para in paras else '' print(f' [{layer:<11}] {str(r.get("book_title_pali"))[:26]:<28}{here}') print(f' {coords}{more}') first = rows[0] print(f'\n取文:wikipali get {first.get("book")}:{(first.get("para") or [0])[0]}') print('引用时必须标明层次——把义注的解释当成本文的说法是学术错误。') emit(args, rows, render) return 0 # --------------------------------------------------------------------------- # articles / article / anthology —— 文章与文集 # --------------------------------------------------------------------------- def cmd_articles(args): client = make_client(args) query = {'view': args.view, 'limit': args.limit, 'offset': args.offset} if args.keyword: query['search'] = args.keyword if args.lang: query['lang'] = args.lang try: data = client.call('GET', 'v2/article', query=query, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, '列出文章') rows = (data or {}).get('rows') or [] def render(): print(f'共 {(data or {}).get("count")} 篇,本页 {len(rows)}' + (f'(关键词「{args.keyword}」)' if args.keyword else '')) if not rows: return print() for r in rows: who = (r.get('editor') or {}).get('nickName') or '' sub = f' —— {r["subtitle"]}' if r.get('subtitle') else '' print(f' {str(r.get("lang")):<8} {str(r.get("title"))[:40]:<42}{sub}') print(f' {r.get("uid")} {who} {str(r.get("updated_at"))[:10]}') print(f'\n读全文:wikipali article {rows[0].get("uid")}') emit(args, rows, render) return 0 def cmd_article(args): client = make_client(args) try: art = client.call('GET', f'v2/article/{args.uid}', timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'读文章 {args.uid}') if not art: raise WpError(f'读不到文章 {args.uid}。') def render(): who = (art.get('editor') or {}).get('nickName') or '' studio = (art.get('studio') or {}).get('nickName') or '' print(f'# {art.get("title")}') if art.get('subtitle'): print(f' {art["subtitle"]}') print(f' {art.get("lang")} 作者 {who} studio {studio} 更新 {str(art.get("updated_at"))[:10]}') print(f' uid {art.get("uid")}\n') body = art.get('content') or '' if args.chars and len(body) > args.chars: print(body[: args.chars]) print(f'\n……全文 {len(body)} 字符,此处截断(--chars 0 取全文)') else: print(body) print('\n⚠ 文章是**二手研究**,不是原典。引用它的观点要标明作者,' '不要把它的说法当成经律本身的说法。') emit(args, art, render) return 0 def cmd_anthology(args): client = make_client(args) if args.uid: try: data = client.call('GET', f'v2/anthology/{args.uid}', timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, f'读文集 {args.uid}') arts = (data or {}).get('article_list') or [] def render(): print(f'# {data.get("title")} {data.get("lang")}') if data.get('summary'): print(f' {data["summary"]}') print(f' {len(arts)} 篇文章\n') for a in arts[: args.limit]: if isinstance(a, dict): print(f' {str(a.get("title"))[:44]:<46} {a.get("uid")}') else: print(f' {a}') emit(args, data, render) return 0 try: data = client.call('GET', 'v2/anthology', query={'view': args.view, 'limit': args.limit, 'offset': args.offset}, timeout=READ_TIMEOUT) except ApiError as exc: raise explain_api_error(exc, '列出文集') rows = (data or {}).get('rows') or [] def render(): print(f'共 {(data or {}).get("count")} 个文集,本页 {len(rows)}\n') for r in rows: print(f' {str(r.get("lang")):<8} {str(r.get("title"))[:40]:<42} ' f'{r.get("childrenNumber")} 篇') print(f' {r.get("uid")}') if rows: print(f'\n看目录:wikipali anthology {rows[0].get("uid")}') emit(args, rows, render) return 0