@waterwx/dsh-novel-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +180 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +3924 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3120 -0
- package/lib/index.js.map +1 -0
- package/lib/types/assets.d.ts +35 -0
- package/lib/types/assistant.d.ts +43 -0
- package/lib/types/bookshelf.d.ts +35 -0
- package/lib/types/client/api.d.ts +68 -0
- package/lib/types/client/docx.d.ts +15 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +139 -0
- package/lib/types/client/mount.d.ts +9 -0
- package/lib/types/client/panel/AssetsTab.d.ts +7 -0
- package/lib/types/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/client/panel/controller.d.ts +19 -0
- package/lib/types/client/panel/helpers.d.ts +8 -0
- package/lib/types/client/sidebar-entry.d.ts +13 -0
- package/lib/types/docx.d.ts +19 -0
- package/lib/types/engine.d.ts +95 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/protocol.d.ts +521 -0
- package/lib/types/routes.d.ts +29 -0
- package/package.json +105 -0
- package/src/assets.ts +518 -0
- package/src/assistant.ts +547 -0
- package/src/bookshelf.ts +137 -0
- package/src/client/api.ts +254 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/docx.ts +69 -0
- package/src/client/index.ts +34 -0
- package/src/client/locales.ts +271 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AssetsTab.tsx +341 -0
- package/src/client/panel/AssistantTab.tsx +188 -0
- package/src/client/panel/BookshelfBar.tsx +116 -0
- package/src/client/panel/NovelPanel.tsx +990 -0
- package/src/client/panel/controller.ts +45 -0
- package/src/client/panel/helpers.ts +17 -0
- package/src/client/panel/panel.module.css +894 -0
- package/src/client/sidebar-entry.ts +122 -0
- package/src/docx.ts +83 -0
- package/src/engine.ts +1019 -0
- package/src/index.ts +184 -0
- package/src/protocol.ts +539 -0
- package/src/routes.ts +955 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar entry injection — mirrors the family plugins' DOM-level pattern:
|
|
3
|
+
* the shell exposes no external slot, so the entry row is injected after the
|
|
4
|
+
* sibling plugin entries and self-heals via MutationObserver.
|
|
5
|
+
*/
|
|
6
|
+
import type { PanelController } from './panel/controller.ts'
|
|
7
|
+
import { tt } from './panel/helpers.ts'
|
|
8
|
+
import css from './panel/panel.module.css'
|
|
9
|
+
|
|
10
|
+
/** Stable data attribute identifying the injected entry row. */
|
|
11
|
+
export const ENTRY_SELECTOR = '[data-dsh-novelforge-entry]'
|
|
12
|
+
|
|
13
|
+
/** Inline icon: an open book / writing glyph. */
|
|
14
|
+
const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 3.5h4a2 2 0 0 1 2 2v7a2 2 0 0 0-2-2h-4z"/><path d="M13.5 3.5h-4a2 2 0 0 0-2 2v7a2 2 0 0 1 2-2h4z"/><path d="M8 5.5v7"/></svg>'
|
|
15
|
+
|
|
16
|
+
/** Find the sidebar shell root element. */
|
|
17
|
+
function sidebarRoot(): HTMLElement | undefined {
|
|
18
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
19
|
+
if (column === null) return undefined
|
|
20
|
+
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
21
|
+
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The New Session button (nested in the logo row on current shells). */
|
|
25
|
+
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
26
|
+
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
27
|
+
if (nested !== null) return nested
|
|
28
|
+
for (const child of root.children) {
|
|
29
|
+
if (child.tagName === 'BUTTON') return child as HTMLButtonElement
|
|
30
|
+
}
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
35
|
+
function createEntry(controller: PanelController): HTMLButtonElement {
|
|
36
|
+
const entry = document.createElement('button')
|
|
37
|
+
entry.type = 'button'
|
|
38
|
+
entry.dataset.dshNovelforgeEntry = 'true'
|
|
39
|
+
entry.className = css.entry
|
|
40
|
+
entry.setAttribute('aria-label', tt('entry.label'))
|
|
41
|
+
entry.setAttribute('title', tt('entry.tooltip'))
|
|
42
|
+
entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + tt('entry.label') + '</span>'
|
|
43
|
+
entry.addEventListener('click', () => { controller.toggle() })
|
|
44
|
+
return entry
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Re-insert the entry after the sibling plugin entry block. */
|
|
48
|
+
function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
49
|
+
const button = newSessionButton(root)
|
|
50
|
+
if (button === undefined) return false
|
|
51
|
+
if (entry.parentElement !== root) {
|
|
52
|
+
const row = button.closest('[class*="logoRow"]')
|
|
53
|
+
const base = (row !== null && row.parentElement === root) ? row : button
|
|
54
|
+
const family = Array.from(root.children).filter(
|
|
55
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-novelforge-entry]'),
|
|
56
|
+
)
|
|
57
|
+
const last = family.length > 0 ? family[family.length - 1] : undefined
|
|
58
|
+
const anchor = last !== undefined ? last.nextElementSibling : base.nextElementSibling
|
|
59
|
+
root.insertBefore(entry, anchor)
|
|
60
|
+
}
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Mount the sidebar entry, waiting for the shell and self-healing on
|
|
66
|
+
* re-renders.
|
|
67
|
+
*/
|
|
68
|
+
export function mountSidebarEntry(controller: PanelController): () => void {
|
|
69
|
+
const entry = createEntry(controller)
|
|
70
|
+
let root: HTMLElement | undefined
|
|
71
|
+
let placed = false
|
|
72
|
+
|
|
73
|
+
const tryPlace = (): void => {
|
|
74
|
+
if (root !== undefined && !root.isConnected) {
|
|
75
|
+
rootObserver.disconnect()
|
|
76
|
+
root = undefined
|
|
77
|
+
placed = false
|
|
78
|
+
}
|
|
79
|
+
if (placed) {
|
|
80
|
+
if (document.body.contains(entry)) return
|
|
81
|
+
rootObserver.disconnect()
|
|
82
|
+
root = undefined
|
|
83
|
+
placed = false
|
|
84
|
+
}
|
|
85
|
+
root ??= sidebarRoot()
|
|
86
|
+
if (root === undefined) return
|
|
87
|
+
placed = placeEntry(root, entry)
|
|
88
|
+
if (placed) {
|
|
89
|
+
rootObserver.observe(root, { childList: true, subtree: true })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
94
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
95
|
+
|
|
96
|
+
const rootObserver = new MutationObserver(() => {
|
|
97
|
+
if (root === undefined || !root.isConnected) {
|
|
98
|
+
placed = false
|
|
99
|
+
tryPlace()
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (!root.contains(entry)) {
|
|
103
|
+
placed = placeEntry(root, entry)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const syncActive = () => {
|
|
108
|
+
if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
|
|
109
|
+
else delete entry.dataset.active
|
|
110
|
+
}
|
|
111
|
+
const unsubscribe = controller.subscribe(syncActive)
|
|
112
|
+
syncActive()
|
|
113
|
+
|
|
114
|
+
tryPlace()
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
waitObserver.disconnect()
|
|
118
|
+
rootObserver.disconnect()
|
|
119
|
+
unsubscribe()
|
|
120
|
+
entry.remove()
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/docx.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docx outline extraction: a .docx is a zip whose word/document.xml holds the
|
|
3
|
+
* body text in <w:t> runs inside <w:p> paragraphs. We unzip with fflate and
|
|
4
|
+
* walk the XML with a tiny tokenizer — no heavyweight XML/DOM dependency.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { unzipSync, strFromU8 } from 'fflate'
|
|
9
|
+
|
|
10
|
+
/** Decode the handful of XML entities docx bodies actually use. */
|
|
11
|
+
function decodeEntities(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, "'")
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/ /g, ' ')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract plain text from a docx buffer: one line per <w:p> paragraph, with
|
|
23
|
+
* <w:tab>/<w:br> preserved as whitespace. Tables and nested structures are
|
|
24
|
+
* flattened in document order (their paragraphs are just <w:p> too).
|
|
25
|
+
* @param buffer - the raw .docx bytes.
|
|
26
|
+
* @returns the body text.
|
|
27
|
+
*/
|
|
28
|
+
export function extractDocxText(buffer: Uint8Array): string {
|
|
29
|
+
let files: ReturnType<typeof unzipSync>
|
|
30
|
+
try {
|
|
31
|
+
files = unzipSync(buffer)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`not a valid docx (zip open failed): ${(error as Error).message}`)
|
|
34
|
+
}
|
|
35
|
+
const document = files['word/document.xml']
|
|
36
|
+
if (document === undefined) {
|
|
37
|
+
throw new Error('not a valid docx (word/document.xml missing)')
|
|
38
|
+
}
|
|
39
|
+
const xml = strFromU8(document)
|
|
40
|
+
|
|
41
|
+
const paragraphs: string[] = []
|
|
42
|
+
// Split on paragraph boundaries; keep the segment text of each.
|
|
43
|
+
const parts = xml.split(/<w:p\b[^>]*>/)
|
|
44
|
+
for (let i = 1; i < parts.length; i++) {
|
|
45
|
+
const segment = parts[i]!
|
|
46
|
+
// Runs <w:t ...>…</w:t>; also honor <w:tab/> and <w:br/> as spaces.
|
|
47
|
+
const runs: string[] = []
|
|
48
|
+
const runRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>/g
|
|
49
|
+
for (const match of segment.matchAll(runRe)) {
|
|
50
|
+
if (match[0].startsWith('<w:tab')) {
|
|
51
|
+
runs.push('\t')
|
|
52
|
+
} else if (match[0].startsWith('<w:br')) {
|
|
53
|
+
runs.push('\n')
|
|
54
|
+
} else {
|
|
55
|
+
runs.push(decodeEntities(match[1] ?? ''))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const line = runs.join('').replace(/\u00a0/g, ' ').trimEnd()
|
|
59
|
+
paragraphs.push(line)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Collapse 3+ blank lines and trim.
|
|
63
|
+
const text = paragraphs.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
64
|
+
if (text.length === 0) {
|
|
65
|
+
throw new Error('docx contains no extractable text')
|
|
66
|
+
}
|
|
67
|
+
return text
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read and extract a docx outline from disk.
|
|
72
|
+
* @param path - absolute path to the .docx file.
|
|
73
|
+
* @returns the extracted outline text.
|
|
74
|
+
*/
|
|
75
|
+
export function readOutlineFromDocx(path: string): string {
|
|
76
|
+
let buffer: Buffer
|
|
77
|
+
try {
|
|
78
|
+
buffer = readFileSync(path)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`cannot read outline file "${path}": ${(error as Error).message}`)
|
|
81
|
+
}
|
|
82
|
+
return extractDocxText(new Uint8Array(buffer))
|
|
83
|
+
}
|