feat: 階段 5 wiki 知識庫(讀寫、10 分鐘快取、動態註冊指令)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-anima",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
|
||||
"license": "MIT",
|
||||
"userConfig": {
|
||||
@@ -103,9 +103,21 @@
|
||||
"title": "情緒外顯 (0-10,-1 沿用角色包)",
|
||||
"description": "覆蓋目前角色的數值軸,只存本機",
|
||||
"default": -1
|
||||
},
|
||||
"wikiUrl": {
|
||||
"type": "string",
|
||||
"title": "wiki 位址",
|
||||
"description": "知識庫的 Gitea wiki git 位址;留空則由目前專案的 origin 推得(認證優先讀 tea 登入,其次 GITEA_TOKEN)",
|
||||
"default": ""
|
||||
},
|
||||
"wikiCacheDir": {
|
||||
"type": "string",
|
||||
"title": "wiki 本機快取目錄",
|
||||
"description": "10 分鐘 git 快取的位置;留空用 ~/.cache/jsc-anima/wiki",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"author": {
|
||||
"name": "jsc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
|
||||
|
||||
## 知識庫(wiki)
|
||||
|
||||
啟動角色後可用:
|
||||
|
||||
- `/jsc-anima:anima-wiki <領域> <主題> [出處]`(下一行起為內容):寫入 Gitea wiki,每次一個中文 commit,並更新 `Home` 目錄與 `_Sidebar`。領域與主題只能用英數與底線。
|
||||
- `/jsc-anima:<領域>-<主題>`:讀取知識,指令在啟動時依 wiki 目錄註冊。
|
||||
|
||||
讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。
|
||||
|
||||
## 安裝
|
||||
|
||||
```
|
||||
|
||||
+116
-1
@@ -4,9 +4,14 @@ import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './cha
|
||||
import type { Axes } from './characters'
|
||||
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
|
||||
import type { Segment } from './statusline'
|
||||
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
|
||||
import type { Entry } from './wiki'
|
||||
|
||||
const ACTIVE_KEY = 'active'
|
||||
const OFF_COMMAND = 'anima-off'
|
||||
const WIKI_COMMAND = 'anima-wiki'
|
||||
const SYNCED_KEY = 'wikiSyncedAt'
|
||||
const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
|
||||
|
||||
const AXIS_OPTION: Record<keyof Axes, string> = {
|
||||
gentle: 'axisGentle',
|
||||
@@ -54,6 +59,106 @@ async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Pro
|
||||
return holder.sys
|
||||
}
|
||||
|
||||
type WikiCfg = { url: string; cacheDir: string }
|
||||
type Wiki = { dir: string; url: string; auth: string[] }
|
||||
|
||||
async function wikiCtx($: Engine, cfg: WikiCfg): Promise<Wiki | undefined> {
|
||||
let url = cfg.url
|
||||
if (!url) {
|
||||
const cwd = await $.session.cwd().catch(() => '')
|
||||
const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: cwd || undefined, timeoutMs: 3000 }).catch(() => undefined)
|
||||
url = (r && r.exitCode === 0 && wikiUrlFromRemote(String(r.stdout))) || ''
|
||||
}
|
||||
if (!url) return undefined
|
||||
const home = (await $.env.get('HOME')) ?? ''
|
||||
const dir = cfg.cacheDir || `${home}/.cache/jsc-anima/wiki`
|
||||
// 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數,不寫入任何檔案。
|
||||
let token: string | undefined
|
||||
try {
|
||||
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(url).host)
|
||||
} catch {}
|
||||
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
|
||||
return { dir, url, auth: token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] }
|
||||
}
|
||||
|
||||
async function git($: Engine, w: Wiki, args: string[], inDir = true) {
|
||||
const r = await $.process.run(['git', ...w.auth, ...(inDir ? ['-C', w.dir] : []), ...args], { timeoutMs: 60000 }).catch(() => undefined)
|
||||
return r && r.exitCode === 0 ? r : undefined
|
||||
}
|
||||
|
||||
// 本機只做 10 分鐘快取;離線時沿用已有的快取。
|
||||
async function sync($: Engine, w: Wiki, force: boolean): Promise<'fresh' | 'cached' | 'none'> {
|
||||
const cloned = await $.fs.exists(`${w.dir}/.git`).catch(() => false)
|
||||
const now = await $.clock.now()
|
||||
const last = Number((await $.store.get(SYNCED_KEY)) ?? 0)
|
||||
if (cloned && !force && now - last < CACHE_MS) return 'fresh'
|
||||
const ok = cloned ? await git($, w, ['pull', '--ff-only']) : await git($, w, ['clone', w.url, w.dir], false)
|
||||
if (ok) {
|
||||
await $.store.set(SYNCED_KEY, now)
|
||||
return 'fresh'
|
||||
}
|
||||
return cloned ? 'cached' : 'none'
|
||||
}
|
||||
|
||||
async function readCatalog($: Engine, w: Wiki): Promise<Entry[]> {
|
||||
return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => ''))
|
||||
}
|
||||
|
||||
async function registerEntry($: Engine, known: Set<string>, e: Entry) {
|
||||
const name = commandName(e)
|
||||
if (known.has(name)) return
|
||||
known.add(name)
|
||||
await $.command.register({ name, description: `讀取知識庫:${e.domain}/${e.topic}` })
|
||||
}
|
||||
|
||||
// 工具三的指令只在啟動角色後註冊,依 wiki 目錄動態註冊。
|
||||
async function registerWikiTools($: Engine, cfg: WikiCfg, known: Set<string>) {
|
||||
if (!known.has(WIKI_COMMAND)) {
|
||||
known.add(WIKI_COMMAND)
|
||||
await $.command.register({ name: WIKI_COMMAND, description: '整理資料寫入 wiki 知識庫', argumentHint: '<領域> <主題> [出處](下一行起為內容)' })
|
||||
}
|
||||
try {
|
||||
const w = await wikiCtx($, cfg)
|
||||
if (!w) return
|
||||
await sync($, w, false)
|
||||
for (const e of await readCatalog($, w)) await registerEntry($, known, e)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function writeWiki($: Engine, cfg: WikiCfg, known: Set<string>, args: string): Promise<string> {
|
||||
const parsed = parseWriteArgs(args)
|
||||
if (!parsed) return '用法:/jsc-anima:anima-wiki <領域> <主題> [出處],下一行起寫內容。領域與主題只能用英數與底線(會成為指令名稱)。'
|
||||
const w = await wikiCtx($, cfg)
|
||||
if (!w) return '找不到 wiki 位址:請在 /config 設定 wikiUrl,或在 Gitea 專案目錄中使用。'
|
||||
if ((await sync($, w, true)) === 'none') return '無法連線到 wiki,且本機沒有快取,未寫入。'
|
||||
const { entry, source, body } = parsed
|
||||
const file = `${w.dir}/${pageName(entry)}.md`
|
||||
const existed = await $.fs.exists(file).catch(() => false)
|
||||
const catalog = addEntry(await readCatalog($, w), entry)
|
||||
await $.fs.write(file, renderPage(entry, body, source))
|
||||
await $.fs.write(`${w.dir}/Home.md`, renderHome(catalog))
|
||||
await $.fs.write(`${w.dir}/_Sidebar.md`, renderSidebar(catalog))
|
||||
if (!(await git($, w, ['add', '-A'])) || !(await git($, w, ['commit', '-m', commitMessage(entry, existed)]))) {
|
||||
return '寫入失敗:無法建立 commit。'
|
||||
}
|
||||
if (!(await git($, w, ['push']))) {
|
||||
await git($, w, ['reset', '--hard', 'HEAD~1'])
|
||||
return '推送 wiki 失敗,已還原本機變更,請稍後再試。'
|
||||
}
|
||||
await $.store.set(SYNCED_KEY, await $.clock.now())
|
||||
await registerEntry($, known, entry)
|
||||
return `已${existed ? '更新' : '新增'}知識「${entry.domain}/${entry.topic}」,指令:/jsc-anima:${commandName(entry)}`
|
||||
}
|
||||
|
||||
async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string | undefined> {
|
||||
const w = await wikiCtx($, cfg)
|
||||
if (!w) return undefined
|
||||
await sync($, w, false)
|
||||
const e = (await readCatalog($, w)).find(x => commandName(x) === name)
|
||||
if (!e) return undefined
|
||||
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
|
||||
}
|
||||
|
||||
export const register: Register = (on, options) => {
|
||||
const opt = options as Record<string, unknown>
|
||||
const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
|
||||
@@ -63,6 +168,8 @@ export const register: Register = (on, options) => {
|
||||
const isEnabled = flag('enabled', true)
|
||||
let active: string | undefined
|
||||
const holder: { sys?: Sys } = {}
|
||||
const wikiCommands = new Set<string>()
|
||||
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
|
||||
|
||||
const overrides = (): Partial<Axes> => {
|
||||
const out: Partial<Axes> = {}
|
||||
@@ -81,6 +188,7 @@ export const register: Register = (on, options) => {
|
||||
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
|
||||
const saved = await $.store.get(ACTIVE_KEY)
|
||||
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
|
||||
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
|
||||
}
|
||||
return next(e)
|
||||
})
|
||||
@@ -93,9 +201,16 @@ export const register: Register = (on, options) => {
|
||||
return { text: '已離開角色,回到一般模式。' }
|
||||
}
|
||||
const c = findCharacter(e.command)
|
||||
if (!c) return next(e)
|
||||
if (!c) {
|
||||
if (!wikiCommands.has(e.command)) return next(e)
|
||||
if (!findCharacter(active)) return { text: NEED_ACTIVE }
|
||||
if (e.command === WIKI_COMMAND) return { text: await writeWiki($, wikiCfg(), wikiCommands, String(e.args ?? '')) }
|
||||
const page = await readWiki($, wikiCfg(), e.command)
|
||||
return { text: page ?? `找不到知識「${e.command}」。` }
|
||||
}
|
||||
active = c.slug
|
||||
await $.store.set(ACTIVE_KEY, c.slug)
|
||||
await registerWikiTools($, wikiCfg(), wikiCommands)
|
||||
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
const OPTS = { options: { wikiCacheDir: '/cache/wiki' } }
|
||||
|
||||
// 記憶體內的假 wiki:以 process.run/fs.* 事件邊界替代 git 與檔案系統。
|
||||
const setup = (on: any, opts: { registered?: string[]; online?: boolean; seed?: Record<string, string>; pushOk?: boolean } = {}) => {
|
||||
const files = new Map<string, string>(Object.entries(opts.seed ?? {}))
|
||||
const calls: string[][] = []
|
||||
const state = { online: opts.online ?? true, pushOk: opts.pushOk ?? true, commits: [] as string[] }
|
||||
const D = '/cache/wiki'
|
||||
mock.store(on)
|
||||
mock.env(on, { HOME: '/home/u', GITEA_TOKEN: 'secret-token' })
|
||||
const clock = mock.clock(on, { now: 1_000_000 })
|
||||
on('command.register', (_$: any, e: any) => {
|
||||
opts.registered?.push(e.name)
|
||||
return { value: { name: e.name } } as never
|
||||
})
|
||||
on('session.start', () => ({ cwd: '/proj' }) as never)
|
||||
on('command.run', () => ({ text: '' }))
|
||||
on('fs.exists', (_$: any, e: any) => ({ value: files.has(e.path.replace(`${D}/`, '')) || e.path === `${D}/.git` && files.has('.git') }) as never)
|
||||
on('fs.read', (_$: any, e: any) => {
|
||||
const k = e.path.replace(`${D}/`, '')
|
||||
if (!files.has(k)) return { deny: 'ENOENT' } as never
|
||||
return { value: files.get(k)! } as never
|
||||
})
|
||||
on('fs.write', (_$: any, e: any) => {
|
||||
files.set(e.path.replace(`${D}/`, ''), e.text)
|
||||
return { value: undefined } as never
|
||||
})
|
||||
on('process.run', (_$: any, e: any) => {
|
||||
const argv = [...e.argv]
|
||||
calls.push(argv)
|
||||
const sub = argv.find(a => ['clone', 'pull', 'add', 'commit', 'push', 'reset', 'get-url'].includes(a))
|
||||
const out = (exitCode = 0, stdout = '') => ({ value: { exitCode, stdout, stderr: '' } }) as never
|
||||
if (sub === 'get-url') return out(0, 'https://gitea.example/o/r.git\n')
|
||||
if (sub === 'clone') {
|
||||
if (!state.online) return out(128)
|
||||
files.set('.git', '1')
|
||||
return out()
|
||||
}
|
||||
if (sub === 'pull') return state.online ? out() : out(128)
|
||||
if (sub === 'commit') {
|
||||
state.commits.push(argv[argv.indexOf('-m') + 1]!)
|
||||
return out()
|
||||
}
|
||||
if (sub === 'push') return state.pushOk ? out() : out(1)
|
||||
return out()
|
||||
})
|
||||
return { files, calls, state, clock }
|
||||
}
|
||||
|
||||
const start = async ($: any) => {
|
||||
await $.session.start({ source: 'startup', cwd: '/proj' } as never)
|
||||
}
|
||||
const run = ($: any, command: string, args = '') => $.command.run({ command, args } as never)
|
||||
|
||||
test('未啟動角色時拒絕寫入與讀取', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '內容' } })
|
||||
await start($)
|
||||
// 工具指令尚未註冊,也未啟動:直接當作一般指令放行(由 harness 底層回空字串)。
|
||||
const res = await run($, 'anima-wiki', 'tools git\n內容')
|
||||
expect(res.text).toBe('')
|
||||
expect(w.state.commits.length).toBe(0)
|
||||
})
|
||||
|
||||
test('啟動後才註冊工具,並依 wiki 目錄動態註冊', OPTS, async ($, on) => {
|
||||
const registered: string[] = []
|
||||
setup(on, { registered, seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n' } })
|
||||
await start($)
|
||||
expect(registered).toEqual([...registered.filter(n => n === 'anima-off' || n.startsWith('sao-'))])
|
||||
await run($, 'sao-asuna')
|
||||
expect(registered).toContain('anima-wiki')
|
||||
expect(registered).toContain('tools-git')
|
||||
})
|
||||
|
||||
test('寫入:一個中文 commit、更新 Home 與 _Sidebar、推送,並註冊新指令', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1' } })
|
||||
await start($)
|
||||
await run($, 'sao-asuna')
|
||||
const res = await run($, 'anima-wiki', 'tools git https://git-scm.com\n用 rebase 整理歷史。')
|
||||
expect(res.text).toContain('已新增')
|
||||
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」'])
|
||||
expect(w.files.get('Home.md')).toContain('## tools')
|
||||
expect(w.files.get('Home.md')).toContain('- [git](tools-git)')
|
||||
expect(w.files.get('_Sidebar.md')).toContain('[git](tools-git)')
|
||||
expect(w.files.get('tools-git.md')).toContain('出處:https://git-scm.com')
|
||||
expect(w.calls.some(c => c.includes('push'))).toBe(true)
|
||||
// 讀回
|
||||
const read = await run($, 'tools-git')
|
||||
expect(read.text).toContain('用 rebase 整理歷史。')
|
||||
})
|
||||
|
||||
test('覆蓋既有主題用「更新」訊息;來源預設為使用者提供', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1' } })
|
||||
await start($)
|
||||
await run($, 'sao-asuna')
|
||||
await run($, 'anima-wiki', 'tools git\n第一版')
|
||||
await run($, 'anima-wiki', 'tools git\n第二版')
|
||||
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」', 'feat: 更新知識「tools/git」'])
|
||||
expect(w.files.get('tools-git.md')).toContain('出處:使用者提供')
|
||||
expect(w.files.get('Home.md')!.match(/\[git\]/g)!.length).toBe(1)
|
||||
})
|
||||
|
||||
test('10 分鐘內讀取用快取,逾時才重新 pull;離線沿用快取', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '舊內容' } })
|
||||
await start($)
|
||||
await run($, 'sao-asuna')
|
||||
const pulls = () => w.calls.filter(c => c.includes('pull')).length
|
||||
await run($, 'tools-git')
|
||||
const base = pulls()
|
||||
await w.clock.advance(5 * 60 * 1000)
|
||||
await run($, 'tools-git')
|
||||
expect(pulls()).toBe(base)
|
||||
await w.clock.advance(6 * 60 * 1000)
|
||||
w.state.online = false
|
||||
const res = await run($, 'tools-git')
|
||||
expect(pulls()).toBe(base + 1)
|
||||
expect(res.text).toContain('舊內容')
|
||||
})
|
||||
|
||||
test('推送失敗時還原並回報,不註冊指令', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1' }, pushOk: false })
|
||||
await start($)
|
||||
await run($, 'sao-asuna')
|
||||
const res = await run($, 'anima-wiki', 'tools git\n內容')
|
||||
expect(res.text).toContain('失敗')
|
||||
expect(w.calls.some(c => c.includes('reset'))).toBe(true)
|
||||
})
|
||||
|
||||
test('token 只放在指令參數,不寫入任何檔案', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1' } })
|
||||
await start($)
|
||||
await run($, 'sao-asuna')
|
||||
await run($, 'anima-wiki', 'tools git\n內容')
|
||||
const pushCall = w.calls.find(c => c.includes('push'))!
|
||||
expect(pushCall.join(' ')).toContain('Authorization: token secret-token')
|
||||
expect([...w.files.values()].some(v => v.includes('secret-token'))).toBe(false)
|
||||
})
|
||||
|
||||
test('離開角色後工具被拒絕並提示先啟動', OPTS, async ($, on) => {
|
||||
const w = setup(on, { seed: { '.git': '1' } })
|
||||
await start($)
|
||||
await run($, 'sao-asuna')
|
||||
await run($, 'anima-off')
|
||||
const res = await run($, 'anima-wiki', 'tools git\n內容')
|
||||
expect(res.text).toContain('請先啟動角色')
|
||||
expect(w.state.commits.length).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// wiki 知識庫的純邏輯:目錄解析與渲染、頁名、驗證。讀寫與 git 在 register.tsx。
|
||||
|
||||
export type Entry = { domain: string; topic: string }
|
||||
|
||||
export const CACHE_MS = 10 * 60 * 1000
|
||||
|
||||
// 指令名稱只能用英數與底線,領域與主題也沿用。
|
||||
const NAME_RE = /^[A-Za-z0-9_]+$/
|
||||
|
||||
export const validName = (s: string): boolean => NAME_RE.test(s)
|
||||
|
||||
export const commandName = (e: Entry): string => `${e.domain}-${e.topic}`
|
||||
|
||||
export const pageName = (e: Entry): string => `${e.domain}-${e.topic}`
|
||||
|
||||
// Home 的格式:「## 領域」章、「- [主題](頁名)」節。
|
||||
export const parseCatalog = (home: string): Entry[] => {
|
||||
const out: Entry[] = []
|
||||
let domain = ''
|
||||
for (const line of home.split('\n')) {
|
||||
const h = /^##\s+(.+?)\s*$/.exec(line)
|
||||
if (h) {
|
||||
domain = h[1]!
|
||||
continue
|
||||
}
|
||||
const m = /^\s*[-*]\s+\[(.+?)\]\((.+?)\)\s*$/.exec(line)
|
||||
if (m && domain) out.push({ domain, topic: m[1]! })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const group = (entries: Entry[]): Map<string, string[]> => {
|
||||
const g = new Map<string, string[]>()
|
||||
for (const e of entries) g.set(e.domain, [...(g.get(e.domain) ?? []), e.topic])
|
||||
return g
|
||||
}
|
||||
|
||||
export const addEntry = (entries: Entry[], e: Entry): Entry[] =>
|
||||
entries.some(x => x.domain === e.domain && x.topic === e.topic) ? entries : [...entries, e]
|
||||
|
||||
export const renderHome = (entries: Entry[]): string => {
|
||||
const lines = ['# 知識庫', '']
|
||||
for (const [domain, topics] of group(entries)) {
|
||||
lines.push(`## ${domain}`, '')
|
||||
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export const renderSidebar = (entries: Entry[]): string => {
|
||||
const lines = ['**[知識庫](Home)**', '']
|
||||
for (const [domain, topics] of group(entries)) {
|
||||
lines.push(`**${domain}**`, '')
|
||||
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export const renderPage = (e: Entry, body: string, source: string): string =>
|
||||
`# ${e.topic}\n\n> 領域:${e.domain}\n\n${body.trim()}\n\n---\n\n出處:${source}\n`
|
||||
|
||||
export const commitMessage = (e: Entry, existed: boolean): string =>
|
||||
`feat: ${existed ? '更新' : '新增'}知識「${e.domain}/${e.topic}」`
|
||||
|
||||
// tea 設定檔(YAML)中,找出網址主機相符的登入的 token。
|
||||
export const teaToken = (yaml: string, host: string): string | undefined => {
|
||||
for (const block of yaml.split(/^\s*-\s+name:/m).slice(1)) {
|
||||
const url = /^\s*url:\s*(\S+)/m.exec(block)?.[1]
|
||||
const token = /^\s*token:\s*(\S+)/m.exec(block)?.[1]
|
||||
if (!url || !token) continue
|
||||
try {
|
||||
if (new URL(url.replace(/^['"]|['"]$/g, '')).host === host) return token.replace(/^['"]|['"]$/g, '')
|
||||
} catch {}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const wikiUrlFromRemote = (remote: string): string | undefined => {
|
||||
const base = remote.trim().replace(/\.git$/, '')
|
||||
return base ? `${base}.wiki.git` : undefined
|
||||
}
|
||||
|
||||
// 指令參數:第一行「領域 主題 [出處]」,其後為內容。
|
||||
export const parseWriteArgs = (args: string): { entry: Entry; source: string; body: string } | undefined => {
|
||||
const [first = '', ...rest] = args.trim().split('\n')
|
||||
const [domain, topic, ...src] = first.trim().split(/\s+/)
|
||||
const body = rest.join('\n').trim()
|
||||
if (!domain || !topic || !body || !validName(domain) || !validName(topic)) return undefined
|
||||
return { entry: { domain, topic }, source: src.join(' ') || '使用者提供', body }
|
||||
}
|
||||
Reference in New Issue
Block a user