half-orm-gen 1.0.0a1__py3-none-any.whl
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.
- half_orm_gen/__init__.py +16 -0
- half_orm_gen/api_routes.py +224 -0
- half_orm_gen/cli_extension.py +170 -0
- half_orm_gen/crud_routes.py +505 -0
- half_orm_gen/gen_app/__init__.py +26 -0
- half_orm_gen/gen_app/angular.py +1727 -0
- half_orm_gen/gen_app/svelte.py +1336 -0
- half_orm_gen/gen_store/__init__.py +34 -0
- half_orm_gen/gen_store/base.py +88 -0
- half_orm_gen/gen_store/svelte.py +282 -0
- half_orm_gen/generate.py +120 -0
- half_orm_gen/scaffold.py +37 -0
- half_orm_gen/scaffolding/api_init.py +1 -0
- half_orm_gen/scaffolding/custom_authorization.py +36 -0
- half_orm_gen/scaffolding/custom_init.py +1 -0
- half_orm_gen/scaffolding/custom_middlewares_init.py +18 -0
- half_orm_gen/scaffolding/custom_routes.py +18 -0
- half_orm_gen/scaffolding/guards.py +40 -0
- half_orm_gen/scaffolding/roles_core.py +62 -0
- half_orm_gen/templates.py +454 -0
- half_orm_gen/templates_fastapi.py +264 -0
- half_orm_gen/tools.py +66 -0
- half_orm_gen/version.txt +1 -0
- half_orm_gen-1.0.0a1.dist-info/METADATA +73 -0
- half_orm_gen-1.0.0a1.dist-info/RECORD +29 -0
- half_orm_gen-1.0.0a1.dist-info/WHEEL +5 -0
- half_orm_gen-1.0.0a1.dist-info/licenses/AUTHORS +3 -0
- half_orm_gen-1.0.0a1.dist-info/licenses/LICENSE +674 -0
- half_orm_gen-1.0.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SvelteKit 5 backoffice generator.
|
|
3
|
+
|
|
4
|
+
Produces a SvelteKit app (Tailwind + TypeScript + Svelte 5 runes) with:
|
|
5
|
+
- src/lib/generated/stores/ — regenerable stores + API clients
|
|
6
|
+
- src/lib/generated/components/ — regenerable List/CreateForm/DetailView components
|
|
7
|
+
- src/routes/(admin)/ — thin page wrappers (auth-guarded)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from half_orm_gen.crud_routes import (
|
|
15
|
+
_gen_out_fields,
|
|
16
|
+
_gen_in_fields,
|
|
17
|
+
_pk_info,
|
|
18
|
+
_simple_pk,
|
|
19
|
+
_instance,
|
|
20
|
+
_py_type_str,
|
|
21
|
+
)
|
|
22
|
+
from half_orm_gen.gen_store.svelte import SvelteGenerator
|
|
23
|
+
from half_orm_gen.gen_store.base import StoreGenerator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Static file templates
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_PACKAGE_JSON = """\
|
|
31
|
+
{{
|
|
32
|
+
"name": "{project_name}",
|
|
33
|
+
"version": "0.0.1",
|
|
34
|
+
"private": true,
|
|
35
|
+
"type": "module",
|
|
36
|
+
"scripts": {{
|
|
37
|
+
"prepare": "svelte-kit sync || true",
|
|
38
|
+
"dev": "vite dev",
|
|
39
|
+
"build": "vite build",
|
|
40
|
+
"preview": "vite preview",
|
|
41
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
42
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
43
|
+
}},
|
|
44
|
+
"devDependencies": {{
|
|
45
|
+
"@sveltejs/adapter-auto": "^7.0.0",
|
|
46
|
+
"@sveltejs/kit": "^2.65.2",
|
|
47
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
48
|
+
"autoprefixer": "^10.4.0",
|
|
49
|
+
"postcss": "^8.4.0",
|
|
50
|
+
"svelte": "^5.46.4",
|
|
51
|
+
"svelte-check": "^4.0.0",
|
|
52
|
+
"tailwindcss": "^3.4.0",
|
|
53
|
+
"typescript": "^5.0.0",
|
|
54
|
+
"vite": "^8.0.0"
|
|
55
|
+
}}
|
|
56
|
+
}}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
_SVELTE_CONFIG = """\
|
|
60
|
+
import adapter from '@sveltejs/adapter-auto';
|
|
61
|
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
62
|
+
|
|
63
|
+
const config = {
|
|
64
|
+
preprocess: vitePreprocess(),
|
|
65
|
+
kit: { adapter: adapter() }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default config;
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_VITE_CONFIG = """\
|
|
72
|
+
import {{ sveltekit }} from '@sveltejs/kit/vite';
|
|
73
|
+
import {{ defineConfig }} from 'vite';
|
|
74
|
+
|
|
75
|
+
export default defineConfig({{
|
|
76
|
+
plugins: [sveltekit()],
|
|
77
|
+
server: {{
|
|
78
|
+
proxy: {{
|
|
79
|
+
'{version_prefix}': {{ target: 'http://localhost:8000', ws: true }},
|
|
80
|
+
}}
|
|
81
|
+
}}
|
|
82
|
+
}});
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
_TSCONFIG = """\
|
|
86
|
+
{
|
|
87
|
+
"extends": "./.svelte-kit/tsconfig.json",
|
|
88
|
+
"compilerOptions": {
|
|
89
|
+
"allowJs": true,
|
|
90
|
+
"checkJs": true,
|
|
91
|
+
"esModuleInterop": true,
|
|
92
|
+
"forceConsistentCasingInFileNames": true,
|
|
93
|
+
"resolveJsonModule": true,
|
|
94
|
+
"skipLibCheck": true,
|
|
95
|
+
"sourceMap": true,
|
|
96
|
+
"strict": true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_TAILWIND_CONFIG = """\
|
|
102
|
+
/** @type {import('tailwindcss').Config} */
|
|
103
|
+
export default {
|
|
104
|
+
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
105
|
+
theme: { extend: {} },
|
|
106
|
+
plugins: []
|
|
107
|
+
};
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_POSTCSS_CONFIG = """\
|
|
111
|
+
export default {
|
|
112
|
+
plugins: { tailwindcss: {}, autoprefixer: {} }
|
|
113
|
+
};
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
_APP_HTML = """\
|
|
117
|
+
<!doctype html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="utf-8" />
|
|
121
|
+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
122
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
123
|
+
%sveltekit.head%
|
|
124
|
+
</head>
|
|
125
|
+
<body data-sveltekit-preload-data="hover">
|
|
126
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
127
|
+
</body>
|
|
128
|
+
</html>
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
_APP_CSS = """\
|
|
132
|
+
@tailwind base;
|
|
133
|
+
@tailwind components;
|
|
134
|
+
@tailwind utilities;
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def _auth_store(version_prefix: str) -> str:
|
|
138
|
+
return f"""\
|
|
139
|
+
import {{ clearAllStates }} from '$lib/stateRegistry';
|
|
140
|
+
|
|
141
|
+
export type WsEvent = {{ event: 'create' | 'update' | 'delete'; resource: string; id: unknown }};
|
|
142
|
+
|
|
143
|
+
class AuthState {{
|
|
144
|
+
token = $state<string | null>(
|
|
145
|
+
typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ho_token') : null
|
|
146
|
+
);
|
|
147
|
+
access = $state<Record<string, any>>({{}});
|
|
148
|
+
lastEvent = $state<WsEvent | null>(null);
|
|
149
|
+
fetchedRoutes = new Set<string>();
|
|
150
|
+
|
|
151
|
+
login(t: string) {{
|
|
152
|
+
sessionStorage.setItem('ho_token', t);
|
|
153
|
+
this.token = t;
|
|
154
|
+
this.fetchedRoutes = new Set();
|
|
155
|
+
clearAllStates();
|
|
156
|
+
this._fetchAccess();
|
|
157
|
+
}}
|
|
158
|
+
|
|
159
|
+
logout() {{
|
|
160
|
+
sessionStorage.removeItem('ho_token');
|
|
161
|
+
this.token = null;
|
|
162
|
+
this.fetchedRoutes = new Set();
|
|
163
|
+
clearAllStates();
|
|
164
|
+
this._fetchAccess();
|
|
165
|
+
}}
|
|
166
|
+
|
|
167
|
+
async _fetchAccess() {{
|
|
168
|
+
const hdrs: Record<string, string> = this.token
|
|
169
|
+
? {{ Authorization: `Bearer ${{this.token}}` }}
|
|
170
|
+
: {{}};
|
|
171
|
+
try {{
|
|
172
|
+
const res = await fetch('{version_prefix}/ho_access', {{ headers: hdrs }});
|
|
173
|
+
this.access = res.ok ? await res.json() : {{}};
|
|
174
|
+
}} catch {{
|
|
175
|
+
this.access = {{}};
|
|
176
|
+
}}
|
|
177
|
+
}}
|
|
178
|
+
|
|
179
|
+
_connectWs() {{
|
|
180
|
+
const base = (import.meta.env.VITE_WS_BASE ?? '').replace(/^http/, 'ws')
|
|
181
|
+
|| `${{window.location.protocol === 'https:' ? 'wss' : 'ws'}}://${{window.location.host}}`;
|
|
182
|
+
const ws = new WebSocket(`${{base}}{version_prefix}/ws`);
|
|
183
|
+
ws.onmessage = (e) => {{
|
|
184
|
+
try {{ this.lastEvent = JSON.parse(e.data) as WsEvent; }} catch {{}}
|
|
185
|
+
}};
|
|
186
|
+
ws.onclose = () => {{ setTimeout(() => this._connectWs(), 2000); }};
|
|
187
|
+
ws.onerror = () => ws.close();
|
|
188
|
+
}}
|
|
189
|
+
}}
|
|
190
|
+
|
|
191
|
+
export const auth = new AuthState();
|
|
192
|
+
|
|
193
|
+
if (typeof window !== 'undefined') {{
|
|
194
|
+
auth._fetchAccess();
|
|
195
|
+
auth._connectWs();
|
|
196
|
+
}}
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def _login_page(version_prefix: str) -> str:
|
|
200
|
+
return f"""\
|
|
201
|
+
<script lang="ts">
|
|
202
|
+
import {{ auth }} from '$lib/auth.svelte.ts';
|
|
203
|
+
import {{ goto }} from '$app/navigation';
|
|
204
|
+
import {{ onMount }} from 'svelte';
|
|
205
|
+
|
|
206
|
+
let roles = $state<string[]>([]);
|
|
207
|
+
let loading = $state(true);
|
|
208
|
+
let error = $state('');
|
|
209
|
+
|
|
210
|
+
onMount(() => {{
|
|
211
|
+
fetch('{version_prefix}/ho_roles')
|
|
212
|
+
.then(r => {{ if (!r.ok) throw new Error(r.statusText); return r.json(); }})
|
|
213
|
+
.then(d => {{ roles = d; loading = false; }})
|
|
214
|
+
.catch(e => {{ error = e.message; loading = false; }});
|
|
215
|
+
}});
|
|
216
|
+
|
|
217
|
+
function selectRole(role: string) {{
|
|
218
|
+
auth.login(role);
|
|
219
|
+
goto('/');
|
|
220
|
+
}}
|
|
221
|
+
</script>
|
|
222
|
+
|
|
223
|
+
<div class="max-w-sm mx-auto mt-16 p-6 bg-white rounded-lg shadow">
|
|
224
|
+
<h1 class="text-xl font-bold mb-2">Select a role</h1>
|
|
225
|
+
<p class="text-xs text-gray-400 mb-6">Dev mode — the role name is used as bearer token.</p>
|
|
226
|
+
|
|
227
|
+
{{#if loading}}
|
|
228
|
+
<p class="text-gray-400 text-sm">Loading roles…</p>
|
|
229
|
+
{{:else if error}}
|
|
230
|
+
<p class="text-red-500 text-sm">{{error}}</p>
|
|
231
|
+
{{:else if roles.length === 0}}
|
|
232
|
+
<p class="text-gray-500 text-sm">No roles found.</p>
|
|
233
|
+
{{:else}}
|
|
234
|
+
<div class="space-y-2">
|
|
235
|
+
{{#each roles as role}}
|
|
236
|
+
<button onclick={{() => selectRole(role)}}
|
|
237
|
+
class="w-full text-left px-4 py-3 border rounded hover:bg-blue-50
|
|
238
|
+
hover:border-blue-300 transition-colors text-sm font-medium">
|
|
239
|
+
{{role}}
|
|
240
|
+
</button>
|
|
241
|
+
{{/each}}
|
|
242
|
+
</div>
|
|
243
|
+
{{/if}}
|
|
244
|
+
</div>
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def _home_page(first_route: str) -> str:
|
|
248
|
+
return f"""\
|
|
249
|
+
<div class="flex flex-col items-center justify-center h-screen bg-gray-50">
|
|
250
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107 128" width="80" height="96" class="mb-6">
|
|
251
|
+
<path d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157" fill="#FF3E00"/>
|
|
252
|
+
<path d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287" fill="#fff"/>
|
|
253
|
+
</svg>
|
|
254
|
+
<h1 class="text-3xl font-bold text-gray-800 mb-2">halfORM Backoffice</h1>
|
|
255
|
+
<p class="text-gray-500 mb-8">Powered by SvelteKit</p>
|
|
256
|
+
<a href="{first_route}"
|
|
257
|
+
class="bg-orange-500 text-white px-6 py-3 rounded-lg hover:bg-orange-600 font-medium transition-colors">
|
|
258
|
+
Open Backoffice →
|
|
259
|
+
</a>
|
|
260
|
+
</div>
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def _access_page(version_prefix: str) -> str:
|
|
264
|
+
return f"""\
|
|
265
|
+
<script lang="ts">
|
|
266
|
+
import {{ auth }} from '$lib/auth.svelte.ts';
|
|
267
|
+
import {{ onMount }} from 'svelte';
|
|
268
|
+
|
|
269
|
+
let roles = $state<string[]>([]);
|
|
270
|
+
let rolesLoading = $state(true);
|
|
271
|
+
|
|
272
|
+
const activeRole = $derived(auth.token ?? 'public');
|
|
273
|
+
|
|
274
|
+
onMount(() => {{
|
|
275
|
+
fetch('{version_prefix}/ho_roles')
|
|
276
|
+
.then(r => r.json())
|
|
277
|
+
.then(d => {{ roles = d; rolesLoading = false; }});
|
|
278
|
+
}});
|
|
279
|
+
|
|
280
|
+
function selectRole(role: string) {{
|
|
281
|
+
if (role === 'public') auth.logout();
|
|
282
|
+
else auth.login(role);
|
|
283
|
+
}}
|
|
284
|
+
|
|
285
|
+
const VERB_COLOR: Record<string, string> = {{
|
|
286
|
+
GET: 'bg-blue-100 text-blue-700',
|
|
287
|
+
POST: 'bg-green-100 text-green-700',
|
|
288
|
+
PUT: 'bg-yellow-100 text-yellow-700',
|
|
289
|
+
DELETE: 'bg-red-100 text-red-700',
|
|
290
|
+
}};
|
|
291
|
+
</script>
|
|
292
|
+
|
|
293
|
+
<div class="flex h-full gap-6">
|
|
294
|
+
<div class="w-44 shrink-0">
|
|
295
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Roles</h2>
|
|
296
|
+
{{#if rolesLoading}}
|
|
297
|
+
<p class="text-gray-400 text-sm">Loading…</p>
|
|
298
|
+
{{:else}}
|
|
299
|
+
<div class="space-y-1">
|
|
300
|
+
{{#each roles as role}}
|
|
301
|
+
<button
|
|
302
|
+
onclick={{() => selectRole(role)}}
|
|
303
|
+
class="w-full text-left px-3 py-2 rounded text-sm transition-colors
|
|
304
|
+
{{activeRole === role ? 'bg-blue-600 text-white font-semibold' : 'text-gray-700 hover:bg-gray-100'}}">
|
|
305
|
+
{{role}}
|
|
306
|
+
</button>
|
|
307
|
+
{{/each}}
|
|
308
|
+
</div>
|
|
309
|
+
{{/if}}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<div class="flex-1 min-w-0">
|
|
313
|
+
<h1 class="text-2xl font-bold mb-6">
|
|
314
|
+
Authorizations
|
|
315
|
+
<span class="text-base font-normal text-gray-500">— {{activeRole}}</span>
|
|
316
|
+
</h1>
|
|
317
|
+
|
|
318
|
+
{{#if Object.keys(auth.access).length === 0}}
|
|
319
|
+
<p class="text-gray-500 text-sm">No access granted for this role.</p>
|
|
320
|
+
{{:else}}
|
|
321
|
+
<div class="space-y-4">
|
|
322
|
+
{{#each Object.entries(auth.access) as [resource, verbs]}}
|
|
323
|
+
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
324
|
+
<div class="px-4 py-2 bg-gray-100 font-semibold text-gray-700 text-sm">{{resource}}</div>
|
|
325
|
+
<div class="divide-y">
|
|
326
|
+
{{#each Object.entries(verbs) as [verb, info]}}
|
|
327
|
+
<div class="px-4 py-3 flex gap-4 items-start text-sm">
|
|
328
|
+
<span class="inline-block px-2 py-0.5 rounded font-mono text-xs font-bold w-16 text-center {{VERB_COLOR[verb] ?? 'bg-gray-100 text-gray-600'}}">
|
|
329
|
+
{{verb}}
|
|
330
|
+
</span>
|
|
331
|
+
<div class="text-gray-700">
|
|
332
|
+
{{#if verb === 'DELETE'}}
|
|
333
|
+
<span class="text-green-600">allowed</span>
|
|
334
|
+
{{:else if verb === 'GET'}}
|
|
335
|
+
<span class="text-gray-400">out: </span>{{(info?.out ?? []).join(', ')}}
|
|
336
|
+
{{:else}}
|
|
337
|
+
<div><span class="text-gray-400">in: </span>{{(info?.in ?? []).join(', ')}}</div>
|
|
338
|
+
<div><span class="text-gray-400">out: </span>{{(info?.out ?? []).join(', ')}}</div>
|
|
339
|
+
{{/if}}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
{{/each}}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
{{/each}}
|
|
346
|
+
</div>
|
|
347
|
+
{{/if}}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Dynamic template helpers
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def _title(schema_name: str, table_name: str) -> str:
|
|
358
|
+
return f'{schema_name}.{table_name}'
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _layout(resources: list) -> str:
|
|
362
|
+
nav_items_js = ',\n '.join(
|
|
363
|
+
f'{{ href: "/ho_bo/{sn}/{tn}", label: "{_title(sn, tn)}" }}'
|
|
364
|
+
for sn, tn, *_ in resources
|
|
365
|
+
)
|
|
366
|
+
return f"""\
|
|
367
|
+
<script lang="ts">
|
|
368
|
+
import '../../app.css';
|
|
369
|
+
import {{ auth }} from '$lib/auth.svelte.ts';
|
|
370
|
+
|
|
371
|
+
let {{ children }} = $props();
|
|
372
|
+
let navFilter = $state('');
|
|
373
|
+
const navItems = [
|
|
374
|
+
{nav_items_js}
|
|
375
|
+
];
|
|
376
|
+
const filteredNav = $derived(
|
|
377
|
+
navFilter
|
|
378
|
+
? navItems.filter(i => i.label.toLowerCase().includes(navFilter.toLowerCase()))
|
|
379
|
+
: navItems
|
|
380
|
+
);
|
|
381
|
+
</script>
|
|
382
|
+
|
|
383
|
+
<div class="h-screen flex bg-gray-50 overflow-hidden">
|
|
384
|
+
<aside class="w-56 shrink-0 bg-white border-r flex flex-col">
|
|
385
|
+
<div class="px-4 py-4 border-b">
|
|
386
|
+
<span class="font-bold text-gray-800">API Browser</span>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="px-2 pt-2 pb-1">
|
|
389
|
+
<input bind:value={{navFilter}} placeholder="Filter…"
|
|
390
|
+
class="w-full text-xs border rounded px-2 py-1 text-gray-700"/>
|
|
391
|
+
</div>
|
|
392
|
+
<nav class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
|
393
|
+
{{#each filteredNav as item}}
|
|
394
|
+
<a href={{item.href}}
|
|
395
|
+
class="block px-3 py-2 rounded hover:bg-gray-100 text-sm text-gray-700">
|
|
396
|
+
{{item.label}}
|
|
397
|
+
</a>
|
|
398
|
+
{{/each}}
|
|
399
|
+
</nav>
|
|
400
|
+
<div class="px-2 py-3 border-t">
|
|
401
|
+
<a href="/access"
|
|
402
|
+
class="block px-3 py-2 rounded hover:bg-gray-100">
|
|
403
|
+
<div class="text-xs text-gray-400 mb-0.5">Role</div>
|
|
404
|
+
<div class="text-sm font-medium {{auth.token ? 'text-blue-700' : 'text-gray-400'}}">
|
|
405
|
+
{{auth.token ?? 'public'}}
|
|
406
|
+
</div>
|
|
407
|
+
</a>
|
|
408
|
+
</div>
|
|
409
|
+
</aside>
|
|
410
|
+
<main class="flex-1 overflow-y-auto p-6">
|
|
411
|
+
{{@render children()}}
|
|
412
|
+
</main>
|
|
413
|
+
</div>
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _cname(schema_name: str, table_name: str) -> str:
|
|
418
|
+
"""PascalCase component/interface name — e.g. BlogComment"""
|
|
419
|
+
return ''.join(p.capitalize() for p in f'{schema_name}_{table_name}'.split('_'))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _rname(schema_name: str, table_name: str) -> str:
|
|
423
|
+
"""camelCase resource name — e.g. blogComment"""
|
|
424
|
+
parts = schema_name.split('_') + table_name.split('_')
|
|
425
|
+
return parts[0].lower() + ''.join(p.capitalize() for p in parts[1:])
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _list_component(
|
|
429
|
+
schema_name: str, table_name: str,
|
|
430
|
+
stem: str, rname: str, iname: str,
|
|
431
|
+
out_names: list, pk_info: list,
|
|
432
|
+
has_post: bool, has_del: bool,
|
|
433
|
+
map_key: str,
|
|
434
|
+
fk_deps: list,
|
|
435
|
+
) -> str:
|
|
436
|
+
pk_field = pk_info[0][0] if pk_info else None
|
|
437
|
+
if len(pk_info) == 1:
|
|
438
|
+
pk_item_expr = f'item.{pk_field}'
|
|
439
|
+
elif len(pk_info) > 1:
|
|
440
|
+
pk_item_expr = '[' + ', '.join(f'item.{f}' for f, _, _ in pk_info) + '].map(String).join("::")'
|
|
441
|
+
else:
|
|
442
|
+
pk_item_expr = None
|
|
443
|
+
title = _title(schema_name, table_name)
|
|
444
|
+
fk_map = {local: (rs, rt) for local, rs, rt, _ in fk_deps}
|
|
445
|
+
|
|
446
|
+
def _sort_th(f: str) -> str:
|
|
447
|
+
toggle = (
|
|
448
|
+
f"() => {{ if (sortField === '{f}') sortAsc = !sortAsc;"
|
|
449
|
+
f" else {{ sortField = '{f}'; sortAsc = true; }} }}"
|
|
450
|
+
)
|
|
451
|
+
indicator = f"{{#if sortField === '{f}'}}{{sortAsc ? '↑' : '↓'}}{{/if}}"
|
|
452
|
+
return (
|
|
453
|
+
f'<th onclick={{{toggle}}}'
|
|
454
|
+
f' class="px-4 py-2 text-left text-sm font-semibold text-gray-600'
|
|
455
|
+
f' cursor-pointer select-none hover:bg-gray-200">'
|
|
456
|
+
f'{f} {indicator}</th>'
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
action_th = '<th class="px-2 py-2 w-16"></th>' if has_del and pk_field else ''
|
|
460
|
+
th_cols = (action_th + '\n ' if action_th else '') + '\n '.join(_sort_th(f) for f in out_names)
|
|
461
|
+
|
|
462
|
+
filter_inputs = '\n '.join(
|
|
463
|
+
f'<th class="px-2 py-1">'
|
|
464
|
+
f'<input bind:value={{localFilters[\'{f}\']}} placeholder="…"'
|
|
465
|
+
f' class="w-full text-xs border rounded px-2 py-1 font-normal" /></th>'
|
|
466
|
+
for f in out_names
|
|
467
|
+
)
|
|
468
|
+
action_filter_th = '<th></th>' if has_del and pk_field else ''
|
|
469
|
+
filter_row = (
|
|
470
|
+
f'\n {{#if !embedded}}\n'
|
|
471
|
+
f' <tr class="bg-white border-b">\n'
|
|
472
|
+
f' {action_filter_th}\n'
|
|
473
|
+
f' {filter_inputs}\n'
|
|
474
|
+
f' </tr>\n'
|
|
475
|
+
f' {{/if}}'
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _td(f: str) -> str:
|
|
479
|
+
if f in fk_map:
|
|
480
|
+
rs, rt = fk_map[f]
|
|
481
|
+
return (
|
|
482
|
+
f'<td class="px-4 py-2 text-sm">'
|
|
483
|
+
f'<a href="/ho_bo/{rs}/{rt}/{{item.{f}}}"'
|
|
484
|
+
f' onclick={{(e) => {{ e.preventDefault(); e.stopPropagation(); goto(`/ho_bo/{rs}/{rt}/${{item.{f}}}`); }}}}'
|
|
485
|
+
f' class="text-blue-500 hover:underline font-mono text-xs truncate block max-w-xs"'
|
|
486
|
+
f' title="{{cellTitle(item.{f})}}">{{fmtCell(item.{f})}}</a>'
|
|
487
|
+
f'</td>'
|
|
488
|
+
)
|
|
489
|
+
cell_click = (
|
|
490
|
+
f"(e) => {{ const _j = (item as any).{f}; "
|
|
491
|
+
f"if (_j != null && typeof _j === 'object') {{ e.stopPropagation(); showJson(_j); }} }}"
|
|
492
|
+
)
|
|
493
|
+
return (
|
|
494
|
+
f'<td class="px-4 py-2 text-sm" onclick={{{cell_click}}}>'
|
|
495
|
+
f'<div class="truncate max-w-xs" title="{{cellTitle(item.{f})}}"'
|
|
496
|
+
f' class:text-blue-600={{typeof (item as any).{f} === \'object\' && (item as any).{f} != null}}'
|
|
497
|
+
f' class:cursor-pointer={{typeof (item as any).{f} === \'object\' && (item as any).{f} != null}}>'
|
|
498
|
+
f'{{fmtCell(item.{f})}}</div></td>'
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
td_cols = '\n '.join(_td(f) for f in out_names)
|
|
502
|
+
|
|
503
|
+
if pk_field:
|
|
504
|
+
tr_open = (
|
|
505
|
+
f'<tr class="border-t hover:bg-gray-50 cursor-pointer"'
|
|
506
|
+
f' onclick={{() => goto(`/ho_bo/{schema_name}/{table_name}/${{{pk_item_expr}}}`)}}'
|
|
507
|
+
f'>'
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
tr_open = '<tr class="border-t hover:bg-gray-50">'
|
|
511
|
+
|
|
512
|
+
action_td = ''
|
|
513
|
+
if has_del and pk_field:
|
|
514
|
+
action_td = (
|
|
515
|
+
f'<td class="px-2 py-2">\n'
|
|
516
|
+
f' {{#if canDelete}}\n'
|
|
517
|
+
f' <button'
|
|
518
|
+
f' onclick={{(e) => {{ e.stopPropagation(); handleDelete({pk_item_expr}); }}}}'
|
|
519
|
+
f'\n class="text-red-600 hover:underline text-sm">Delete</button>\n'
|
|
520
|
+
f' {{/if}}\n'
|
|
521
|
+
f' </td>'
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
new_btn = (
|
|
525
|
+
f'\n {{#if canCreate}}\n'
|
|
526
|
+
f' <a href="/ho_bo/{schema_name}/{table_name}/new"\n'
|
|
527
|
+
f' class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm">\n'
|
|
528
|
+
f' New\n </a>\n {{/if}}'
|
|
529
|
+
if has_post else ''
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
can_create = f"\n const canCreate = $derived(!embedded && !!auth.access['{map_key}']?.POST);" if has_post else ''
|
|
533
|
+
can_delete = f"\n const canDelete = $derived(!!auth.access['{map_key}']?.DELETE);" if has_del else ''
|
|
534
|
+
delete_fn = (
|
|
535
|
+
f'\n async function handleDelete(id: string) {{\n'
|
|
536
|
+
f' if (confirm(\'Delete this item?\')) {{\n'
|
|
537
|
+
f' const res = await {rname}Api.remove(id);\n'
|
|
538
|
+
f' if (res.ok) {rname}State.removeItem(String(id));\n'
|
|
539
|
+
f' }}\n'
|
|
540
|
+
f' }}'
|
|
541
|
+
if has_del and pk_field else ''
|
|
542
|
+
)
|
|
543
|
+
goto_import = " import { goto } from '$app/navigation';\n" if pk_field else ''
|
|
544
|
+
|
|
545
|
+
return f"""\
|
|
546
|
+
<script lang="ts">
|
|
547
|
+
import {{ {rname}State, {rname}Api }} from '$lib/generated/stores/{stem}.svelte.ts';
|
|
548
|
+
import type {{ {iname}Out }} from '$lib/generated/stores/{stem}.svelte.ts';
|
|
549
|
+
import {{ auth }} from '$lib/auth.svelte.ts';
|
|
550
|
+
import {{ untrack }} from 'svelte';
|
|
551
|
+
{goto_import}
|
|
552
|
+
let {{ filters = {{}}, embedded = false }}: {{ filters?: Record<string, any>; embedded?: boolean }} = $props();
|
|
553
|
+
|
|
554
|
+
const hasFilters = $derived(Object.keys(filters).length > 0);
|
|
555
|
+
{"" if not pk_field else f"""
|
|
556
|
+
let localFilters = $state<Record<string, string>>({{}});
|
|
557
|
+
let sortField = $state<string | null>(null);
|
|
558
|
+
let sortAsc = $state(true);
|
|
559
|
+
|
|
560
|
+
const displayItems = $derived.by(() => {{
|
|
561
|
+
let items: {iname}Out[] = hasFilters
|
|
562
|
+
? Array.from({rname}State.byId.values()).filter(item =>
|
|
563
|
+
Object.entries(filters).every(([k, v]) => String((item as any)[k]) === String(v)))
|
|
564
|
+
: {rname}State.items;
|
|
565
|
+
const lf = localFilters;
|
|
566
|
+
if (Object.values(lf).some(v => v))
|
|
567
|
+
items = items.filter(item =>
|
|
568
|
+
Object.entries(lf).every(([k, v]) =>
|
|
569
|
+
!v || String((item as any)[k] ?? '').toLowerCase().includes(v.toLowerCase())));
|
|
570
|
+
const sf = sortField;
|
|
571
|
+
if (sf) {{
|
|
572
|
+
const asc = sortAsc;
|
|
573
|
+
items = [...items].sort((a, b) => {{
|
|
574
|
+
const av = String((a as any)[sf] ?? '');
|
|
575
|
+
const bv = String((b as any)[sf] ?? '');
|
|
576
|
+
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
577
|
+
}});
|
|
578
|
+
}}
|
|
579
|
+
return items;
|
|
580
|
+
}});
|
|
581
|
+
""".rstrip()}{"" if pk_field else f"""
|
|
582
|
+
let localFilters = $state<Record<string, string>>({{}});
|
|
583
|
+
let sortField = $state<string | null>(null);
|
|
584
|
+
let sortAsc = $state(true);
|
|
585
|
+
|
|
586
|
+
const displayItems = $derived.by(() => {{
|
|
587
|
+
let items: {iname}Out[] = {rname}State.items;
|
|
588
|
+
const lf = localFilters;
|
|
589
|
+
if (Object.values(lf).some(v => v))
|
|
590
|
+
items = items.filter(item =>
|
|
591
|
+
Object.entries(lf).every(([k, v]) =>
|
|
592
|
+
!v || String((item as any)[k] ?? '').toLowerCase().includes(v.toLowerCase())));
|
|
593
|
+
const sf = sortField;
|
|
594
|
+
if (sf) {{
|
|
595
|
+
const asc = sortAsc;
|
|
596
|
+
items = [...items].sort((a, b) => {{
|
|
597
|
+
const av = String((a as any)[sf] ?? '');
|
|
598
|
+
const bv = String((b as any)[sf] ?? '');
|
|
599
|
+
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
600
|
+
}});
|
|
601
|
+
}}
|
|
602
|
+
return items;
|
|
603
|
+
}});
|
|
604
|
+
""".rstrip()}
|
|
605
|
+
|
|
606
|
+
$effect(() => {{
|
|
607
|
+
const url = {rname}Api.listUrl(filters);
|
|
608
|
+
if (!auth.fetchedRoutes.has(url)) {{
|
|
609
|
+
const filtered = hasFilters;
|
|
610
|
+
{rname}Api.list(filters).then(r => r.ok ? r.json() : []).then(d => {{
|
|
611
|
+
if (filtered) {rname}State.mergeItems(d);
|
|
612
|
+
else {rname}State.setItems(d);
|
|
613
|
+
}});
|
|
614
|
+
}}
|
|
615
|
+
}});
|
|
616
|
+
{"" if not pk_field else f"""
|
|
617
|
+
$effect(() => {{
|
|
618
|
+
const ev = auth.lastEvent;
|
|
619
|
+
if (!ev || ev.resource !== '{map_key}') return;
|
|
620
|
+
if (ev.event === 'delete') {{
|
|
621
|
+
untrack(() => {rname}State.removeItem(String(ev.id)));
|
|
622
|
+
}} else {{
|
|
623
|
+
untrack(() => {rname}Api.get(ev.id)
|
|
624
|
+
.then(r => r.ok ? r.json() : null)
|
|
625
|
+
.then(d => {{ if (d) {rname}State.setItem(d); }}));
|
|
626
|
+
}}
|
|
627
|
+
}});
|
|
628
|
+
""".rstrip()}
|
|
629
|
+
{can_create}{can_delete}{delete_fn}
|
|
630
|
+
let jsonDialog = $state<string | null>(null);
|
|
631
|
+
function showJson(v: unknown): void {{ jsonDialog = JSON.stringify(v, null, 2); }}
|
|
632
|
+
function fmtCell(v: unknown): string {{
|
|
633
|
+
if (v == null) return '';
|
|
634
|
+
if (Array.isArray(v)) return `JSON [${{v.length}}]`;
|
|
635
|
+
if (typeof v === 'object') return 'JSON {{…}}';
|
|
636
|
+
const s = String(v);
|
|
637
|
+
return /^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i.test(s)
|
|
638
|
+
? s.slice(0, 8) + '…' : s;
|
|
639
|
+
}}
|
|
640
|
+
function cellTitle(v: unknown): string {{
|
|
641
|
+
if (v == null || typeof v === 'object') return '';
|
|
642
|
+
return String(v);
|
|
643
|
+
}}
|
|
644
|
+
</script>
|
|
645
|
+
|
|
646
|
+
{{#if !embedded}}
|
|
647
|
+
<div class="flex justify-between items-center mb-4">
|
|
648
|
+
<h1 class="text-2xl font-bold">{title}</h1>{new_btn}
|
|
649
|
+
</div>
|
|
650
|
+
{{/if}}
|
|
651
|
+
|
|
652
|
+
<div class="{{embedded ? '' : 'bg-white shadow-sm rounded-lg overflow-hidden'}}">
|
|
653
|
+
<table class="w-full border-collapse">
|
|
654
|
+
<thead class="bg-gray-100">
|
|
655
|
+
<tr>
|
|
656
|
+
{th_cols}
|
|
657
|
+
{action_th}
|
|
658
|
+
</tr>{filter_row}
|
|
659
|
+
</thead>
|
|
660
|
+
<tbody>
|
|
661
|
+
{{#each displayItems as item}}
|
|
662
|
+
{tr_open}
|
|
663
|
+
{action_td}
|
|
664
|
+
{td_cols}
|
|
665
|
+
</tr>
|
|
666
|
+
{{/each}}
|
|
667
|
+
</tbody>
|
|
668
|
+
</table>
|
|
669
|
+
</div>
|
|
670
|
+
|
|
671
|
+
{{#if jsonDialog !== null}}
|
|
672
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
|
673
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 cursor-default"
|
|
674
|
+
onclick={{() => jsonDialog = null}}
|
|
675
|
+
onkeydown={{(e) => e.key === 'Escape' && (jsonDialog = null)}}>
|
|
676
|
+
<div role="dialog" aria-modal="true" aria-label="JSON" tabindex="-1"
|
|
677
|
+
class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6"
|
|
678
|
+
onclick={{(e) => e.stopPropagation()}}
|
|
679
|
+
onkeydown={{(e) => e.stopPropagation()}}>
|
|
680
|
+
<div class="flex justify-between items-center mb-3">
|
|
681
|
+
<h3 class="font-semibold text-gray-800">JSON</h3>
|
|
682
|
+
<button onclick={{() => jsonDialog = null}}
|
|
683
|
+
class="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
|
684
|
+
</div>
|
|
685
|
+
<pre class="text-xs bg-gray-50 rounded p-4 overflow-auto max-h-[60vh] whitespace-pre-wrap">{{jsonDialog}}</pre>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
{{/if}}
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _list_page(stem: str) -> str:
|
|
693
|
+
return f"""\
|
|
694
|
+
<script lang="ts">
|
|
695
|
+
import List from '$lib/generated/components/{stem}/List.svelte';
|
|
696
|
+
</script>
|
|
697
|
+
|
|
698
|
+
<List />
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _admin_layout() -> str:
|
|
703
|
+
return """\
|
|
704
|
+
<script lang="ts">
|
|
705
|
+
import { auth } from '$lib/auth.svelte.ts';
|
|
706
|
+
import { goto } from '$app/navigation';
|
|
707
|
+
|
|
708
|
+
let { children } = $props();
|
|
709
|
+
|
|
710
|
+
$effect(() => {
|
|
711
|
+
if (!auth.token) goto('/login');
|
|
712
|
+
});
|
|
713
|
+
</script>
|
|
714
|
+
|
|
715
|
+
{#if auth.token}{@render children()}{/if}
|
|
716
|
+
"""
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _new_page_wrapper(stem: str) -> str:
|
|
720
|
+
return f"""\
|
|
721
|
+
<script lang="ts">
|
|
722
|
+
import CreateForm from '$lib/generated/components/{stem}/CreateForm.svelte';
|
|
723
|
+
</script>
|
|
724
|
+
|
|
725
|
+
<CreateForm />
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _detail_page_wrapper(stem: str) -> str:
|
|
730
|
+
return f"""\
|
|
731
|
+
<script lang="ts">
|
|
732
|
+
import {{ page }} from '$app/state';
|
|
733
|
+
import DetailView from '$lib/generated/components/{stem}/DetailView.svelte';
|
|
734
|
+
</script>
|
|
735
|
+
|
|
736
|
+
<DetailView id={{page.params.id}} />
|
|
737
|
+
"""
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _is_bool_field(f: str, all_fields: dict) -> bool:
|
|
741
|
+
return f in all_fields and _py_type_str(all_fields[f].py_type) == 'bool'
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _is_text_field(f: str, all_fields: dict) -> bool:
|
|
745
|
+
return f in all_fields and _py_type_str(all_fields[f].py_type) == 'str'
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _is_required(f: str, all_fields: dict) -> bool:
|
|
749
|
+
fo = all_fields.get(f)
|
|
750
|
+
return bool(fo and fo.is_not_null() and fo.has_default_value is None)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _is_server_generated(f: str, all_fields: dict) -> bool:
|
|
754
|
+
fo = all_fields.get(f)
|
|
755
|
+
if not fo or fo.has_default_value is None:
|
|
756
|
+
return False
|
|
757
|
+
dv = fo.has_default_value.lower().strip()
|
|
758
|
+
return dv.startswith('current') or dv in ('now()', 'clock_timestamp()')
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _input_type(f: str, all_fields: dict) -> str:
|
|
762
|
+
if f not in all_fields:
|
|
763
|
+
return 'text'
|
|
764
|
+
fo = all_fields[f]
|
|
765
|
+
t = _py_type_str(fo.py_type)
|
|
766
|
+
if t == 'datetime.datetime':
|
|
767
|
+
return 'datetime-local'
|
|
768
|
+
if t == 'datetime.date':
|
|
769
|
+
return 'date'
|
|
770
|
+
try:
|
|
771
|
+
sql = fo._Field__sql_type.lower()
|
|
772
|
+
if 'timestamp' in sql:
|
|
773
|
+
return 'datetime-local'
|
|
774
|
+
if sql == 'date':
|
|
775
|
+
return 'date'
|
|
776
|
+
except AttributeError:
|
|
777
|
+
pass
|
|
778
|
+
return 'text'
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _text_fields_js(field_names: list, all_fields: dict) -> str:
|
|
782
|
+
text = [f for f in field_names if _is_text_field(f, all_fields)]
|
|
783
|
+
return ', '.join(f"'{f}'" for f in text)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _null_map_js(text_fields_var: str = 'textFields') -> str:
|
|
787
|
+
return f'.map(([k, v]) => [k, !{text_fields_var}.has(k) && v === \'\' ? null : v] as [string, unknown])'
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _svelte_form_field(f: str, all_fields: dict, bind_prefix: str = 'form') -> str:
|
|
791
|
+
req = _is_required(f, all_fields)
|
|
792
|
+
req_attr = ' required' if req else ''
|
|
793
|
+
req_mark = ' <span class="text-red-500">*</span>' if req else ''
|
|
794
|
+
itype = _input_type(f, all_fields)
|
|
795
|
+
if _is_bool_field(f, all_fields):
|
|
796
|
+
return (
|
|
797
|
+
f'<div class="flex items-center gap-2">\n'
|
|
798
|
+
f' <input id="f_{f}" type="checkbox" bind:checked={{{bind_prefix}.{f}}}\n'
|
|
799
|
+
f' class="h-4 w-4 rounded border-gray-300" />\n'
|
|
800
|
+
f' <label for="f_{f}" class="text-sm font-medium text-gray-700">{f}</label>\n'
|
|
801
|
+
f' </div>'
|
|
802
|
+
)
|
|
803
|
+
return (
|
|
804
|
+
f'<div>\n'
|
|
805
|
+
f' <label for="f_{f}" class="block text-sm font-medium text-gray-700 mb-1">{f}{req_mark}</label>\n'
|
|
806
|
+
f' <input id="f_{f}" type="{itype}" bind:value={{{bind_prefix}.{f}}}{req_attr}\n'
|
|
807
|
+
f' class="w-full border rounded px-3 py-2 text-sm" />\n'
|
|
808
|
+
f' </div>'
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _new_page(
|
|
813
|
+
schema_name: str, table_name: str,
|
|
814
|
+
stem: str, rname: str, iname: str,
|
|
815
|
+
post_in_names: list, all_fields: dict,
|
|
816
|
+
optional_post_fields: frozenset = frozenset(),
|
|
817
|
+
) -> str:
|
|
818
|
+
title = _title(schema_name, table_name)
|
|
819
|
+
visible_post = [f for f in post_in_names if not _is_server_generated(f, all_fields)]
|
|
820
|
+
fields_init = ', '.join(
|
|
821
|
+
f'{f}: false' if _is_bool_field(f, all_fields) else f'{f}: ""'
|
|
822
|
+
for f in visible_post
|
|
823
|
+
)
|
|
824
|
+
optional_set_js = ', '.join(f"'{f}'" for f in optional_post_fields)
|
|
825
|
+
text_fields_js = _text_fields_js(visible_post, all_fields)
|
|
826
|
+
form_fields = '\n '.join(
|
|
827
|
+
_svelte_form_field(f, all_fields)
|
|
828
|
+
for f in visible_post
|
|
829
|
+
)
|
|
830
|
+
return f"""\
|
|
831
|
+
<script lang="ts">
|
|
832
|
+
import {{ {rname}Api, {rname}State }} from '$lib/generated/stores/{stem}.svelte.ts';
|
|
833
|
+
import type {{ {iname}PostIn }} from '$lib/generated/stores/{stem}.svelte.ts';
|
|
834
|
+
import {{ goto }} from '$app/navigation';
|
|
835
|
+
|
|
836
|
+
let form = $state<Partial<{iname}PostIn>>({{ {fields_init} }});
|
|
837
|
+
let error = $state('');
|
|
838
|
+
|
|
839
|
+
const optionalFields = new Set([{optional_set_js}]);
|
|
840
|
+
const textFields = new Set([{text_fields_js}]);
|
|
841
|
+
|
|
842
|
+
async function handleSubmit(e: Event) {{
|
|
843
|
+
e.preventDefault();
|
|
844
|
+
try {{
|
|
845
|
+
const payload = Object.fromEntries(
|
|
846
|
+
Object.entries(form as unknown as Record<string, unknown>)
|
|
847
|
+
.filter(([k, v]) => !optionalFields.has(k) || v !== '')
|
|
848
|
+
{_null_map_js()}
|
|
849
|
+
) as unknown as {iname}PostIn;
|
|
850
|
+
const res = await {rname}Api.create(payload);
|
|
851
|
+
if (!res.ok) throw new Error(await res.text());
|
|
852
|
+
const created = await res.json();
|
|
853
|
+
{rname}State.setItem(created);
|
|
854
|
+
goto('/ho_bo/{schema_name}/{table_name}');
|
|
855
|
+
}} catch (err: any) {{
|
|
856
|
+
error = err.message;
|
|
857
|
+
}}
|
|
858
|
+
}}
|
|
859
|
+
</script>
|
|
860
|
+
|
|
861
|
+
<div class="max-w-lg mx-auto p-6 bg-white rounded-lg shadow mt-6">
|
|
862
|
+
<h1 class="text-2xl font-bold mb-6">New {title}</h1>
|
|
863
|
+
{{#if error}}<p class="text-red-600 mb-4">{{error}}</p>{{/if}}
|
|
864
|
+
<form onsubmit={{handleSubmit}} class="space-y-4">
|
|
865
|
+
{form_fields}
|
|
866
|
+
<div class="flex gap-3 pt-2">
|
|
867
|
+
<button type="submit"
|
|
868
|
+
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm">
|
|
869
|
+
Create
|
|
870
|
+
</button>
|
|
871
|
+
<a href="/ho_bo/{schema_name}/{table_name}"
|
|
872
|
+
class="px-4 py-2 border rounded hover:bg-gray-50 text-sm">Cancel</a>
|
|
873
|
+
</div>
|
|
874
|
+
</form>
|
|
875
|
+
</div>
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _detail_page(
|
|
880
|
+
schema_name: str, table_name: str,
|
|
881
|
+
stem: str, rname: str, iname: str,
|
|
882
|
+
out_names: list, put_in_names: list,
|
|
883
|
+
pk_field: str, all_fields: dict,
|
|
884
|
+
has_put: bool,
|
|
885
|
+
fk_deps: list,
|
|
886
|
+
rev_fk_deps: list,
|
|
887
|
+
) -> str:
|
|
888
|
+
title = _title(schema_name, table_name)
|
|
889
|
+
fk_map = {local: (rs, rt) for local, rs, rt, _ in fk_deps}
|
|
890
|
+
read_only = [f for f in out_names if f != pk_field]
|
|
891
|
+
|
|
892
|
+
# Read-only fields — FK fields rendered as links
|
|
893
|
+
def _ro_row(f: str) -> str:
|
|
894
|
+
label = f'<span class="font-medium text-gray-600 w-36 shrink-0">{f}</span>'
|
|
895
|
+
if f in fk_map:
|
|
896
|
+
rs, rt = fk_map[f]
|
|
897
|
+
value = (
|
|
898
|
+
f'<a href="/ho_bo/{rs}/{rt}/{{item.{f}}}"'
|
|
899
|
+
f' class="text-blue-500 hover:underline font-mono text-xs">{{item.{f}}}</a>'
|
|
900
|
+
)
|
|
901
|
+
else:
|
|
902
|
+
value = f'<span class="text-sm break-all">{{item.{f}}}</span>'
|
|
903
|
+
return f'<div class="flex gap-2 items-baseline">{label}{value}</div>'
|
|
904
|
+
|
|
905
|
+
ro_fields = '\n '.join(_ro_row(f) for f in read_only) if read_only else ''
|
|
906
|
+
|
|
907
|
+
visible_put = [f for f in put_in_names if not _is_server_generated(f, all_fields)]
|
|
908
|
+
|
|
909
|
+
# Edit form fields
|
|
910
|
+
form_fields = '\n '.join(
|
|
911
|
+
_svelte_form_field(f, all_fields)
|
|
912
|
+
for f in visible_put
|
|
913
|
+
) if visible_put else ''
|
|
914
|
+
|
|
915
|
+
# Form state + edit toggle — populated reactively from item once loaded
|
|
916
|
+
extra_script = ''
|
|
917
|
+
edit_btn = ''
|
|
918
|
+
edit_section = ''
|
|
919
|
+
|
|
920
|
+
if has_put and visible_put:
|
|
921
|
+
empty_init = ', '.join(
|
|
922
|
+
f'{f}: false' if _is_bool_field(f, all_fields) else f'{f}: ""'
|
|
923
|
+
for f in visible_put
|
|
924
|
+
)
|
|
925
|
+
def _effect_assign(f: str) -> str:
|
|
926
|
+
if _is_bool_field(f, all_fields):
|
|
927
|
+
return f'form.{f} = Boolean(item.{f});'
|
|
928
|
+
if _input_type(f, all_fields) == 'datetime-local':
|
|
929
|
+
return f'form.{f} = item.{f} ? String(item.{f}).slice(0, 16) : "";'
|
|
930
|
+
return f'form.{f} = (item.{f} as string) ?? "";'
|
|
931
|
+
effect_body = '\n '.join(_effect_assign(f) for f in visible_put)
|
|
932
|
+
put_text_fields_js = _text_fields_js(visible_put, all_fields)
|
|
933
|
+
extra_script = (
|
|
934
|
+
f'\n let editing = $state(false);\n'
|
|
935
|
+
f' let form = $state({{ {empty_init} }});\n'
|
|
936
|
+
f' let error = $state(\'\');\n'
|
|
937
|
+
f' const putTextFields = new Set([{put_text_fields_js}]);\n'
|
|
938
|
+
f' $effect(() => {{\n'
|
|
939
|
+
f' if (item) {{\n'
|
|
940
|
+
f' {effect_body}\n'
|
|
941
|
+
f' }}\n'
|
|
942
|
+
f' }});\n'
|
|
943
|
+
f'\n async function handleUpdate(e: Event) {{\n'
|
|
944
|
+
f' e.preventDefault();\n'
|
|
945
|
+
f' try {{\n'
|
|
946
|
+
f' const putPayload = Object.fromEntries(\n'
|
|
947
|
+
f' Object.entries(form as unknown as Record<string, unknown>)\n'
|
|
948
|
+
f' {_null_map_js("putTextFields")}\n'
|
|
949
|
+
f' ) as unknown as {iname}PutIn;\n'
|
|
950
|
+
f' const res = await {rname}Api.update(id, putPayload);\n'
|
|
951
|
+
f' if (!res.ok) throw new Error(await res.text());\n'
|
|
952
|
+
f' const updated = await res.json();\n'
|
|
953
|
+
f' {rname}State.setItem(updated);\n'
|
|
954
|
+
f' editing = false;\n'
|
|
955
|
+
f' }} catch (err: any) {{\n'
|
|
956
|
+
f' error = err.message;\n'
|
|
957
|
+
f' }}\n'
|
|
958
|
+
f' }}'
|
|
959
|
+
)
|
|
960
|
+
edit_btn = (
|
|
961
|
+
'\n <button onclick={() => { editing = !editing; error = \'\'; }}'
|
|
962
|
+
'\n class="text-sm px-3 py-1 border rounded hover:bg-gray-50">'
|
|
963
|
+
'\n {editing ? \'Cancel\' : \'Edit\'}</button>'
|
|
964
|
+
)
|
|
965
|
+
edit_section = f"""
|
|
966
|
+
|
|
967
|
+
{{#if editing}}
|
|
968
|
+
<div class="mt-6 pt-6 border-t">
|
|
969
|
+
{{#if error}}<p class="text-red-600 mb-4">{{error}}</p>{{/if}}
|
|
970
|
+
<form onsubmit={{handleUpdate}} class="space-y-4">
|
|
971
|
+
{form_fields}
|
|
972
|
+
<div class="flex gap-3 pt-2">
|
|
973
|
+
<button type="submit"
|
|
974
|
+
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm">
|
|
975
|
+
Update
|
|
976
|
+
</button>
|
|
977
|
+
<button type="button" onclick={{() => {{ editing = false; }}}}
|
|
978
|
+
class="px-4 py-2 border rounded hover:bg-gray-50 text-sm">Cancel</button>
|
|
979
|
+
</div>
|
|
980
|
+
</form>
|
|
981
|
+
</div>
|
|
982
|
+
{{/if}}"""
|
|
983
|
+
|
|
984
|
+
map_key = f'{schema_name}/{table_name}'
|
|
985
|
+
put_in_import = f', {iname}PutIn' if has_put else ''
|
|
986
|
+
can_edit = f"\n const canEdit = $derived(!!auth.access['{map_key}']?.PUT);" if has_put else ''
|
|
987
|
+
edit_btn_wrap = (
|
|
988
|
+
f'\n {{#if canEdit}}{edit_btn}\n {{/if}}'
|
|
989
|
+
if has_put and visible_put else ''
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
# Forward FK reference imports, states, effects, sections
|
|
993
|
+
def _fk_ref_imports(deps: list) -> str:
|
|
994
|
+
lines = []
|
|
995
|
+
seen: set[str] = {stem} # skip self-referential FK and deduplicate
|
|
996
|
+
for _, rs, rt, _ in deps:
|
|
997
|
+
s = f'{rs}_{rt}'
|
|
998
|
+
if s in seen:
|
|
999
|
+
continue
|
|
1000
|
+
seen.add(s)
|
|
1001
|
+
rn = _rname(rs, rt)
|
|
1002
|
+
lines.append(f" import {{ {rn}State, {rn}Api }} from '$lib/generated/stores/{s}.svelte.ts';")
|
|
1003
|
+
return ('\n' + '\n'.join(lines)) if lines else ''
|
|
1004
|
+
|
|
1005
|
+
def _lf_ref_name(lf: str) -> str:
|
|
1006
|
+
"""user_fk → userFkRef (always unique — keyed on local field, not remote table)"""
|
|
1007
|
+
parts = lf.split('_')
|
|
1008
|
+
return parts[0] + ''.join(p.capitalize() for p in parts[1:]) + 'Ref'
|
|
1009
|
+
|
|
1010
|
+
def _fk_ref_states(deps: list) -> str:
|
|
1011
|
+
lines = [
|
|
1012
|
+
f" let {_lf_ref_name(lf)} = $state<{_cname(rs, rt)}Out | null>(null);"
|
|
1013
|
+
for lf, rs, rt, _ in deps
|
|
1014
|
+
]
|
|
1015
|
+
return ('\n' + '\n'.join(lines)) if lines else ''
|
|
1016
|
+
|
|
1017
|
+
def _fk_ref_effects(deps: list) -> str:
|
|
1018
|
+
blocks = []
|
|
1019
|
+
for lf, rs, rt, _ in deps:
|
|
1020
|
+
rn = _rname(rs, rt)
|
|
1021
|
+
lf_ref = _lf_ref_name(lf)
|
|
1022
|
+
blocks.append(
|
|
1023
|
+
f' $effect(() => {{\n'
|
|
1024
|
+
f' if (!item?.{lf}) return;\n'
|
|
1025
|
+
f' const _url = {rn}Api.getUrl(item.{lf});\n'
|
|
1026
|
+
f' if (auth.fetchedRoutes.has(_url)) {{\n'
|
|
1027
|
+
f' {lf_ref} = {rn}State.byId.get(String(item.{lf})) ?? null;\n'
|
|
1028
|
+
f' }} else {{\n'
|
|
1029
|
+
f' {rn}Api.get(item.{lf}).then(r => r.ok ? r.json() : null)\n'
|
|
1030
|
+
f' .then(d => {{ if (d) {{ {rn}State.setItem(d); {lf_ref} = d; }} }});\n'
|
|
1031
|
+
f' }}\n'
|
|
1032
|
+
f' }});'
|
|
1033
|
+
)
|
|
1034
|
+
return ('\n' + '\n'.join(blocks)) if blocks else ''
|
|
1035
|
+
|
|
1036
|
+
def _fk_ref_section(lf: str, rs: str, rt: str, remote_pk: str) -> str:
|
|
1037
|
+
rn = _rname(rs, rt)
|
|
1038
|
+
lf_ref = _lf_ref_name(lf)
|
|
1039
|
+
return (
|
|
1040
|
+
f'\n{{#if {lf_ref}}}\n'
|
|
1041
|
+
f'<div class="mt-4 p-6 bg-white rounded-lg shadow">\n'
|
|
1042
|
+
f' <div class="flex justify-between items-center mb-3">\n'
|
|
1043
|
+
f' <h2 class="text-lg font-semibold">{_title(rs, rt)}</h2>\n'
|
|
1044
|
+
f' <a href="/ho_bo/{rs}/{rt}/{{{lf_ref}.{remote_pk}}}"'
|
|
1045
|
+
f' class="text-sm text-blue-600 hover:underline">→</a>\n'
|
|
1046
|
+
f' </div>\n'
|
|
1047
|
+
f' <div class="space-y-1">\n'
|
|
1048
|
+
f' {{#each Object.entries({lf_ref}) as [k, v]}}\n'
|
|
1049
|
+
f' <div class="flex gap-2 items-baseline">\n'
|
|
1050
|
+
f' <span class="font-medium text-gray-600 w-36 shrink-0 text-sm">{{k}}</span>\n'
|
|
1051
|
+
f' <span class="text-sm break-all">{{String(v ?? \'\')}}</span>\n'
|
|
1052
|
+
f' </div>\n'
|
|
1053
|
+
f' {{/each}}\n'
|
|
1054
|
+
f' </div>\n'
|
|
1055
|
+
f'</div>\n'
|
|
1056
|
+
f'{{/if}}'
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
fk_imports = _fk_ref_imports(fk_deps)
|
|
1060
|
+
fk_states = _fk_ref_states(fk_deps)
|
|
1061
|
+
fk_effects = _fk_ref_effects(fk_deps)
|
|
1062
|
+
fk_sections = '\n'.join(_fk_ref_section(*d) for d in fk_deps)
|
|
1063
|
+
|
|
1064
|
+
# Reverse FK imports and sections
|
|
1065
|
+
rev_imports = '\n'.join(
|
|
1066
|
+
f" import {_cname(rs, rt)}List from '$lib/generated/components/{rs}_{rt}/List.svelte';"
|
|
1067
|
+
for rs, rt, _ in rev_fk_deps
|
|
1068
|
+
)
|
|
1069
|
+
if rev_imports:
|
|
1070
|
+
rev_imports = '\n' + rev_imports
|
|
1071
|
+
|
|
1072
|
+
def _rev_section(rs: str, rt: str, fk_field: str) -> str:
|
|
1073
|
+
cn = _cname(rs, rt)
|
|
1074
|
+
return (
|
|
1075
|
+
f'\n<div class="mt-4 bg-white rounded-lg shadow overflow-hidden">\n'
|
|
1076
|
+
f' <div class="px-6 pt-5 pb-3">\n'
|
|
1077
|
+
f' <h2 class="text-lg font-semibold">{_title(rs, rt)}</h2>\n'
|
|
1078
|
+
f' </div>\n'
|
|
1079
|
+
f' {{#if item}}\n'
|
|
1080
|
+
f' <{cn}List filters={{{{ {fk_field}: item.{pk_field} }}}} embedded />\n'
|
|
1081
|
+
f' {{/if}}\n'
|
|
1082
|
+
f'</div>'
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
rev_sections = '\n'.join(_rev_section(rs, rt, fk) for rs, rt, fk in rev_fk_deps)
|
|
1086
|
+
|
|
1087
|
+
right_col = ''
|
|
1088
|
+
if fk_deps:
|
|
1089
|
+
right_col += '\n<p class="mt-4 px-1 text-xs font-semibold uppercase tracking-wide text-gray-400">↗ Direct references</p>'
|
|
1090
|
+
right_col += fk_sections
|
|
1091
|
+
if rev_fk_deps:
|
|
1092
|
+
if fk_deps:
|
|
1093
|
+
right_col += '\n<hr class="my-6 border-gray-200">'
|
|
1094
|
+
right_col += '\n<p class="mt-4 px-1 text-xs font-semibold uppercase tracking-wide text-gray-400">↙ Related</p>'
|
|
1095
|
+
right_col += '\n' + rev_sections
|
|
1096
|
+
|
|
1097
|
+
return f"""\
|
|
1098
|
+
<script lang="ts">
|
|
1099
|
+
import {{ {rname}State, {rname}Api }} from '$lib/generated/stores/{stem}.svelte.ts';
|
|
1100
|
+
import type {{ {iname}Out{put_in_import} }} from '$lib/generated/stores/{stem}.svelte.ts';
|
|
1101
|
+
import {{ goto }} from '$app/navigation';
|
|
1102
|
+
import {{ auth }} from '$lib/auth.svelte.ts';
|
|
1103
|
+
import {{ untrack }} from 'svelte';{fk_imports}{rev_imports}
|
|
1104
|
+
|
|
1105
|
+
let {{ id }}: {{ id: string }} = $props();
|
|
1106
|
+
let item = $state<{iname}Out | null>({rname}State.byId.get(id) ?? null);
|
|
1107
|
+
let _lastToken = auth.token;
|
|
1108
|
+
{fk_states}
|
|
1109
|
+
$effect(() => {{
|
|
1110
|
+
const t = auth.token;
|
|
1111
|
+
if (t !== _lastToken) {{ _lastToken = t; item = null; }}
|
|
1112
|
+
if (!item)
|
|
1113
|
+
{rname}Api.get(id).then(r => r.ok ? r.json() : null)
|
|
1114
|
+
.then(d => {{ if (d) {{ item = d; {rname}State.setItem(d); }} }});
|
|
1115
|
+
}});
|
|
1116
|
+
|
|
1117
|
+
$effect(() => {{
|
|
1118
|
+
const ev = auth.lastEvent;
|
|
1119
|
+
if (ev?.resource === '{map_key}' && String(ev.id) === id) {{
|
|
1120
|
+
if (ev.event === 'delete') goto('/ho_bo/{schema_name}/{table_name}');
|
|
1121
|
+
else untrack(() => {rname}Api.get(id).then(r => r.ok ? r.json() : null)
|
|
1122
|
+
.then(d => {{ if (d) {{ item = d; {rname}State.setItem(d); }} }}));
|
|
1123
|
+
}}
|
|
1124
|
+
}});
|
|
1125
|
+
{fk_effects}{can_edit}{extra_script}
|
|
1126
|
+
</script>
|
|
1127
|
+
|
|
1128
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6 px-4 lg:h-[calc(100vh-4rem)] lg:overflow-hidden">
|
|
1129
|
+
<div class="min-w-0 lg:overflow-y-auto lg:pr-1">
|
|
1130
|
+
{{#if item}}
|
|
1131
|
+
<div class="p-6 bg-white rounded-lg shadow">
|
|
1132
|
+
<div class="flex justify-between items-start mb-6">
|
|
1133
|
+
<h1 class="text-2xl font-bold">{title}</h1>
|
|
1134
|
+
<div class="flex gap-3 items-center">
|
|
1135
|
+
{edit_btn_wrap}
|
|
1136
|
+
<a href="/ho_bo/{schema_name}/{table_name}" class="text-sm text-gray-500 hover:underline">← Back</a>
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
|
|
1140
|
+
<div class="space-y-2 mb-4">
|
|
1141
|
+
<div class="flex gap-2 items-baseline">
|
|
1142
|
+
<span class="font-medium text-gray-600 w-36 shrink-0">{pk_field}</span>
|
|
1143
|
+
<span class="font-mono text-xs text-gray-500 break-all">{{item.{pk_field}}}</span>
|
|
1144
|
+
</div>
|
|
1145
|
+
{ro_fields}
|
|
1146
|
+
</div>{edit_section}
|
|
1147
|
+
</div>
|
|
1148
|
+
{{/if}}
|
|
1149
|
+
</div>
|
|
1150
|
+
<div class="min-w-0 lg:overflow-y-auto lg:pr-1">
|
|
1151
|
+
{right_col}
|
|
1152
|
+
</div>
|
|
1153
|
+
</div>
|
|
1154
|
+
"""
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
# ---------------------------------------------------------------------------
|
|
1158
|
+
# Generator
|
|
1159
|
+
# ---------------------------------------------------------------------------
|
|
1160
|
+
|
|
1161
|
+
class SvelteAppGenerator(StoreGenerator):
|
|
1162
|
+
|
|
1163
|
+
def generate(self, classes, api_version, output_dir: Path) -> None:
|
|
1164
|
+
if output_dir.exists():
|
|
1165
|
+
shutil.rmtree(output_dir)
|
|
1166
|
+
output_dir.mkdir(parents=True)
|
|
1167
|
+
|
|
1168
|
+
version_prefix = f'/v{api_version}' if api_version is not None else ''
|
|
1169
|
+
project_name = output_dir.name
|
|
1170
|
+
|
|
1171
|
+
# --- static files ---
|
|
1172
|
+
self._write(output_dir / 'package.json',
|
|
1173
|
+
_PACKAGE_JSON.format(project_name=project_name))
|
|
1174
|
+
self._write(output_dir / 'svelte.config.js', _SVELTE_CONFIG)
|
|
1175
|
+
self._write(output_dir / 'vite.config.ts',
|
|
1176
|
+
_VITE_CONFIG.format(version_prefix=version_prefix or '/api'))
|
|
1177
|
+
self._write(output_dir / 'tsconfig.json', _TSCONFIG)
|
|
1178
|
+
self._write(output_dir / 'tailwind.config.js', _TAILWIND_CONFIG)
|
|
1179
|
+
self._write(output_dir / 'postcss.config.js', _POSTCSS_CONFIG)
|
|
1180
|
+
self._write(output_dir / 'src' / 'app.html', _APP_HTML)
|
|
1181
|
+
self._write(output_dir / 'src' / 'app.css', _APP_CSS)
|
|
1182
|
+
|
|
1183
|
+
# --- stores (reuse SvelteGenerator) ---
|
|
1184
|
+
stores_dir = output_dir / 'src' / 'lib' / 'generated' / 'stores'
|
|
1185
|
+
SvelteGenerator().generate(classes, api_version, stores_dir)
|
|
1186
|
+
|
|
1187
|
+
# --- stateRegistry + auth store + WS env var ---
|
|
1188
|
+
self._write(output_dir / 'src' / 'lib' / 'stateRegistry.ts',
|
|
1189
|
+
"const _fns: Array<() => void> = [];\n"
|
|
1190
|
+
"export const registerClear = (fn: () => void): void => { _fns.push(fn); };\n"
|
|
1191
|
+
"export const clearAllStates = (): void => { _fns.forEach(fn => fn()); };\n"
|
|
1192
|
+
)
|
|
1193
|
+
self._write(output_dir / 'src' / 'lib' / 'auth.svelte.ts', _auth_store(version_prefix))
|
|
1194
|
+
env_local = output_dir / '.env.local'
|
|
1195
|
+
if not env_local.exists():
|
|
1196
|
+
self._write(env_local, 'VITE_WS_BASE=http://localhost:8000\n')
|
|
1197
|
+
|
|
1198
|
+
# Pass 1: identify all resources that expose CRUD_ACCESS
|
|
1199
|
+
crud_resources: set[tuple[str, str]] = set()
|
|
1200
|
+
raw = []
|
|
1201
|
+
for relation, _relation_type in classes:
|
|
1202
|
+
module_str = relation.__module__
|
|
1203
|
+
try:
|
|
1204
|
+
mod = importlib.import_module(module_str)
|
|
1205
|
+
except ImportError:
|
|
1206
|
+
continue
|
|
1207
|
+
schema_name = relation._t_fqrn[1]
|
|
1208
|
+
table_name = relation._t_fqrn[2]
|
|
1209
|
+
crud_resources.add((schema_name, table_name))
|
|
1210
|
+
raw.append((relation, mod))
|
|
1211
|
+
|
|
1212
|
+
# Pass 2: build per-resource metadata (needs complete crud_resources for fk_deps)
|
|
1213
|
+
resources = []
|
|
1214
|
+
for relation, mod in raw:
|
|
1215
|
+
crud_access = getattr(mod, 'CRUD_ACCESS', None) or {'GET': {}, 'POST': {}, 'PUT': {}, 'DELETE': {}}
|
|
1216
|
+
api_excluded = getattr(mod, 'API_EXCLUDED_FIELDS', [])
|
|
1217
|
+
schema_name = relation._t_fqrn[1]
|
|
1218
|
+
table_name = relation._t_fqrn[2]
|
|
1219
|
+
inst = _instance(relation)
|
|
1220
|
+
all_fields = getattr(inst, '_ho_fields', {})
|
|
1221
|
+
all_names = list(all_fields.keys())
|
|
1222
|
+
pk_cols = _pk_info(relation)
|
|
1223
|
+
pk_info = pk_cols
|
|
1224
|
+
pk_field = pk_cols[0][0] if pk_cols else None
|
|
1225
|
+
iname = self.interface_name(schema_name, table_name)
|
|
1226
|
+
rname = self.resource_name(schema_name, table_name)
|
|
1227
|
+
stem = f'{schema_name}_{table_name}'
|
|
1228
|
+
map_key = f'{schema_name}/{table_name}'
|
|
1229
|
+
|
|
1230
|
+
out_names = _gen_out_fields(crud_access, 'GET', api_excluded, all_names)
|
|
1231
|
+
if not out_names:
|
|
1232
|
+
out_names = [f for f in all_names if f not in api_excluded]
|
|
1233
|
+
|
|
1234
|
+
has_post = 'POST' in crud_access and bool(pk_info)
|
|
1235
|
+
has_put = 'PUT' in crud_access and bool(pk_info)
|
|
1236
|
+
has_del = 'DELETE' in crud_access and bool(pk_info)
|
|
1237
|
+
|
|
1238
|
+
pk_has_default = bool(
|
|
1239
|
+
pk_field and all_fields.get(pk_field) and
|
|
1240
|
+
all_fields[pk_field].has_default_value is not None
|
|
1241
|
+
)
|
|
1242
|
+
fields_with_defaults = {
|
|
1243
|
+
f for f in all_names
|
|
1244
|
+
if all_fields.get(f) and all_fields[f].has_default_value is not None
|
|
1245
|
+
}
|
|
1246
|
+
_non_pk = [f for f in all_names
|
|
1247
|
+
if (f != pk_field or not pk_has_default) and f not in api_excluded]
|
|
1248
|
+
post_in_names = _gen_in_fields(
|
|
1249
|
+
crud_access, 'POST', pk_field, api_excluded, all_names, pk_has_default
|
|
1250
|
+
) if has_post else []
|
|
1251
|
+
if has_post and not post_in_names:
|
|
1252
|
+
post_in_names = _non_pk
|
|
1253
|
+
put_in_names = _gen_in_fields(
|
|
1254
|
+
crud_access, 'PUT', pk_field, api_excluded, all_names
|
|
1255
|
+
) if has_put else []
|
|
1256
|
+
if has_put and not put_in_names:
|
|
1257
|
+
put_in_names = _non_pk
|
|
1258
|
+
optional_post_fields = frozenset(f for f in post_in_names if f in fields_with_defaults)
|
|
1259
|
+
|
|
1260
|
+
fk_deps = self._fk_deps(inst, out_names, crud_resources)
|
|
1261
|
+
rev_fk_deps = self._reverse_fk_deps(inst, pk_field, crud_resources)
|
|
1262
|
+
|
|
1263
|
+
resources.append((
|
|
1264
|
+
schema_name, table_name, stem, rname, iname,
|
|
1265
|
+
out_names, pk_info, pk_field, all_fields,
|
|
1266
|
+
has_post, has_put, has_del,
|
|
1267
|
+
post_in_names, put_in_names, map_key, crud_access, fk_deps, rev_fk_deps,
|
|
1268
|
+
optional_post_fields,
|
|
1269
|
+
))
|
|
1270
|
+
|
|
1271
|
+
# --- layout + home ---
|
|
1272
|
+
routes_dir = output_dir / 'src' / 'routes'
|
|
1273
|
+
components_dir = output_dir / 'src' / 'lib' / 'generated' / 'components'
|
|
1274
|
+
# Root layout is minimal — home page renders without nav
|
|
1275
|
+
self._write(routes_dir / '+layout.svelte',
|
|
1276
|
+
'<script>\n import \'../app.css\';\n let { children } = $props();\n</script>\n{@render children()}\n')
|
|
1277
|
+
# (nav) group provides the sidebar layout for all other pages
|
|
1278
|
+
self._write(routes_dir / '(nav)' / '+layout.svelte', _layout(resources))
|
|
1279
|
+
self._write(routes_dir / '(nav)' / 'ho_bo' / '+layout.svelte', _admin_layout())
|
|
1280
|
+
first_route = (
|
|
1281
|
+
f'/ho_bo/{resources[0][0]}/{resources[0][1]}' if resources else '/ho_bo'
|
|
1282
|
+
)
|
|
1283
|
+
self._write(routes_dir / '+page.svelte', _home_page(first_route), once=True)
|
|
1284
|
+
self._write(routes_dir / '(nav)' / 'login' / '+page.svelte', _login_page(version_prefix))
|
|
1285
|
+
self._write(routes_dir / '(nav)' / 'access' / '+page.svelte', _access_page(version_prefix))
|
|
1286
|
+
|
|
1287
|
+
# --- per-resource components + routes ---
|
|
1288
|
+
for (schema_name, table_name, stem, rname, iname,
|
|
1289
|
+
out_names, pk_info, pk_field, all_fields,
|
|
1290
|
+
has_post, has_put, has_del,
|
|
1291
|
+
post_in_names, put_in_names, map_key, crud_access, fk_deps, rev_fk_deps,
|
|
1292
|
+
optional_post_fields) in resources:
|
|
1293
|
+
|
|
1294
|
+
comp_dir = components_dir / stem
|
|
1295
|
+
res_dir = routes_dir / '(nav)' / 'ho_bo' / schema_name / table_name
|
|
1296
|
+
|
|
1297
|
+
# List component + thin page wrapper
|
|
1298
|
+
self._write(
|
|
1299
|
+
comp_dir / 'List.svelte',
|
|
1300
|
+
_list_component(schema_name, table_name, stem, rname, iname,
|
|
1301
|
+
out_names, pk_info, has_post, has_del, map_key, fk_deps),
|
|
1302
|
+
)
|
|
1303
|
+
self._write(res_dir / '+page.svelte', _list_page(stem))
|
|
1304
|
+
|
|
1305
|
+
# CreateForm component + thin page wrapper
|
|
1306
|
+
if has_post:
|
|
1307
|
+
self._write(
|
|
1308
|
+
comp_dir / 'CreateForm.svelte',
|
|
1309
|
+
_new_page(schema_name, table_name, stem, rname, iname,
|
|
1310
|
+
post_in_names, all_fields, optional_post_fields),
|
|
1311
|
+
)
|
|
1312
|
+
self._write(res_dir / 'new' / '+page.svelte', _new_page_wrapper(stem))
|
|
1313
|
+
|
|
1314
|
+
# DetailView component + thin page wrapper
|
|
1315
|
+
if pk_info and 'GET' in crud_access:
|
|
1316
|
+
self._write(
|
|
1317
|
+
comp_dir / 'DetailView.svelte',
|
|
1318
|
+
_detail_page(schema_name, table_name, stem, rname, iname,
|
|
1319
|
+
out_names, put_in_names, pk_field, all_fields,
|
|
1320
|
+
has_put, fk_deps, rev_fk_deps),
|
|
1321
|
+
)
|
|
1322
|
+
self._write(res_dir / '[id]' / '+page.svelte', _detail_page_wrapper(stem))
|
|
1323
|
+
|
|
1324
|
+
print(f'\nSvelteKit app generated in {output_dir}')
|
|
1325
|
+
print('Next steps:')
|
|
1326
|
+
print(f' cd {output_dir}')
|
|
1327
|
+
print(' npm install')
|
|
1328
|
+
print(' npm run dev')
|
|
1329
|
+
|
|
1330
|
+
def _write(self, path: Path, content: str, *, once: bool = False) -> None:
|
|
1331
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1332
|
+
if once and path.exists():
|
|
1333
|
+
print(f' {path} (skipped — developer-owned)')
|
|
1334
|
+
return
|
|
1335
|
+
path.write_text(content, encoding='utf-8')
|
|
1336
|
+
print(f' {path}')
|