@dickpy/dsh-imagegen 1.5.1 → 1.5.3

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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Favorites store for the prompt-template library.
3
+ *
4
+ * The user's starred templates persist host-side as full case snapshots under
5
+ * ~/.dsh/dsh-imagegen/templates/favorites.json, keyed by
6
+ * `${sourceId}:${caseId}` — the snapshot means a favorite stays usable even
7
+ * after the upstream list drops or renumbers the case. Framework-free
8
+ * (node:fs only) so the route layer and tests can drive it directly.
9
+ */
10
+
11
+ import { promises as fs } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import path from 'node:path'
14
+ import { isTemplateSourceId, type TemplateCase, type TemplateFavorite } from './protocol.ts'
15
+
16
+ const DATA_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
17
+ const FAVORITES_PATH = path.join(DATA_DIR, 'templates', 'favorites.json')
18
+
19
+ /** Refuse to grow the file without bound; the user curates this list. */
20
+ const MAX_FAVORITES = 1000
21
+
22
+ /** In-memory memo of the persisted list. */
23
+ let memo: TemplateFavorite[] | undefined
24
+
25
+ /** Build the stable key of one case within a source. */
26
+ export function templateFavoriteKey(sourceId: string, caseId: number): string {
27
+ return `${sourceId}:${caseId}`
28
+ }
29
+
30
+ /** Validate + normalize one raw stored favorite; undefined when unusable. */
31
+ function normalizeFavorite(raw: unknown): TemplateFavorite | undefined {
32
+ if (raw === null || typeof raw !== 'object') return undefined
33
+ const record = raw as Record<string, unknown>
34
+ if (typeof record.key !== 'string' || typeof record.savedAt !== 'string') return undefined
35
+ const sourceId = typeof record.sourceId === 'string' ? record.sourceId : ''
36
+ if (!isTemplateSourceId(sourceId)) return undefined
37
+ if (record.key !== templateFavoriteKey(sourceId, Number(record.case && (record.case as TemplateCase).id))) return undefined
38
+ const rawCase = record.case
39
+ if (rawCase === null || typeof rawCase !== 'object') return undefined
40
+ const item = rawCase as Record<string, unknown>
41
+ const id = Number(item.id)
42
+ const title = typeof item.title === 'string' ? item.title : ''
43
+ const prompt = typeof item.prompt === 'string' ? item.prompt : ''
44
+ if (!Number.isInteger(id) || title === '' || prompt === '') return undefined
45
+ // Keep only the wire fields so hand-edited files cannot smuggle extras.
46
+ const snapshot: TemplateCase = {
47
+ id,
48
+ title,
49
+ prompt,
50
+ category: typeof item.category === 'string' ? item.category : '',
51
+ categoryZh: typeof item.categoryZh === 'string' ? item.categoryZh : '',
52
+ styles: Array.isArray(item.styles) ? item.styles.map(String) : [],
53
+ scenes: Array.isArray(item.scenes) ? item.scenes.map(String) : [],
54
+ sourceLabel: typeof item.sourceLabel === 'string' ? item.sourceLabel : '',
55
+ sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
56
+ githubUrl: typeof item.githubUrl === 'string' ? item.githubUrl : '',
57
+ image: typeof item.image === 'string' ? item.image : '',
58
+ featured: item.featured === true,
59
+ }
60
+ return { key: record.key, sourceId, savedAt: record.savedAt, case: snapshot }
61
+ }
62
+
63
+ /** Read + parse the favorites file (memoized). */
64
+ export async function listTemplateFavorites(): Promise<TemplateFavorite[]> {
65
+ if (memo !== undefined) return memo
66
+ try {
67
+ const parsed: unknown = JSON.parse(await fs.readFile(FAVORITES_PATH, 'utf8'))
68
+ memo = Array.isArray(parsed)
69
+ ? parsed.map(normalizeFavorite).filter((entry): entry is TemplateFavorite => entry !== undefined)
70
+ : []
71
+ } catch {
72
+ memo = []
73
+ }
74
+ return memo
75
+ }
76
+
77
+ /** Persist the list atomically and update the memo. */
78
+ async function writeFavorites(entries: TemplateFavorite[]): Promise<void> {
79
+ memo = entries
80
+ await fs.mkdir(path.dirname(FAVORITES_PATH), { recursive: true })
81
+ const tmp = `${FAVORITES_PATH}.tmp-${process.pid}`
82
+ await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf8')
83
+ await fs.rename(tmp, FAVORITES_PATH)
84
+ }
85
+
86
+ /** Star one template. Re-starring refreshes the snapshot and is idempotent. */
87
+ export async function addTemplateFavorite(sourceId: string, item: TemplateCase): Promise<TemplateFavorite[]> {
88
+ if (!isTemplateSourceId(sourceId)) throw new Error(`未知的模板库来源:${sourceId}`)
89
+ const key = templateFavoriteKey(sourceId, item.id)
90
+ const rest = (await listTemplateFavorites()).filter(entry => entry.key !== key)
91
+ const entry: TemplateFavorite = { key, sourceId, savedAt: new Date().toISOString(), case: item }
92
+ const next = [entry, ...rest].slice(0, MAX_FAVORITES)
93
+ await writeFavorites(next)
94
+ return next
95
+ }
96
+
97
+ /** Unstar one template by key; unknown keys are a no-op. */
98
+ export async function removeTemplateFavorite(key: string): Promise<TemplateFavorite[]> {
99
+ const next = (await listTemplateFavorites()).filter(entry => entry.key !== key)
100
+ if (next.length === memo?.length) return next
101
+ await writeFavorites(next)
102
+ return next
103
+ }
104
+
105
+ /** Drop the in-memory memo (tests). */
106
+ export function clearTemplateFavoritesMemo(): void {
107
+ memo = undefined
108
+ }