@lazycatcloud/lzc-cli 1.3.13 → 2.0.0-pre.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/README.md +30 -5
- package/changelog.md +16 -0
- package/lib/app/index.js +174 -58
- package/lib/app/lpk_build.js +197 -18
- package/lib/app/lpk_build_images.js +728 -0
- package/lib/app/lpk_create.js +96 -23
- package/lib/app/lpk_create_generator.js +150 -12
- package/lib/app/lpk_devshell.js +35 -21
- package/lib/app/lpk_embed_images.js +257 -0
- package/lib/app/lpk_installer.js +15 -7
- package/lib/app/project_cp.js +64 -0
- package/lib/app/project_deploy.js +33 -0
- package/lib/app/project_exec.js +45 -0
- package/lib/app/project_info.js +106 -0
- package/lib/app/project_log.js +67 -0
- package/lib/app/project_runtime.js +261 -0
- package/lib/app/project_start.js +100 -0
- package/lib/appstore/index.js +56 -16
- package/lib/appstore/publish.js +16 -13
- package/lib/box/index.js +103 -6
- package/lib/box/ssh_remote.js +259 -0
- package/lib/build_remote.js +22 -0
- package/lib/config/index.js +4 -3
- package/lib/debug_bridge.js +837 -44
- package/lib/docker/index.js +30 -10
- package/lib/i18n/index.js +1 -0
- package/lib/i18n/locales/en/translation.json +263 -250
- package/lib/i18n/locales/zh/translation.json +57 -44
- package/lib/lpk/core.js +487 -0
- package/lib/lpk/index.js +210 -0
- package/lib/shellapi.js +5 -5
- package/lib/sig/core.js +254 -0
- package/lib/sig/index.js +88 -0
- package/lib/utils.js +17 -12
- package/package.json +4 -3
- package/scripts/cli.js +4 -0
- package/template/_lpk/README.md +11 -3
- package/template/_lpk/gui-vnc.manifest.yml.in +27 -0
- package/template/_lpk/manifest.yml.in +4 -2
- package/template/_lpk/todolist-golang.manifest.yml.in +16 -0
- package/template/_lpk/todolist-java.manifest.yml.in +15 -0
- package/template/_lpk/todolist-python.manifest.yml.in +15 -0
- package/template/_lpk/vue.lzc-build.yml.in +0 -44
- package/template/blank/_gitignore +1 -0
- package/template/blank/lzc-build.yml +25 -40
- package/template/blank/lzc-manifest.yml +14 -7
- package/template/golang/Dockerfile +19 -0
- package/template/golang/README.md +33 -0
- package/template/golang/_gitignore +3 -0
- package/template/golang/go.mod +3 -0
- package/template/golang/lzc-build.yml +21 -0
- package/template/golang/lzc-icon.png +0 -0
- package/template/golang/main.go +252 -0
- package/template/golang/run.sh +3 -0
- package/template/golang/web/index.html +238 -0
- package/template/gui-vnc/README.md +19 -0
- package/template/gui-vnc/_gitignore +2 -0
- package/template/gui-vnc/images/Dockerfile +30 -0
- package/template/gui-vnc/images/kasmvnc.yaml +33 -0
- package/template/gui-vnc/images/startup-script.desktop +9 -0
- package/template/gui-vnc/images/startup-script.sh +6 -0
- package/template/gui-vnc/lzc-build.yml +23 -0
- package/template/gui-vnc/lzc-icon.png +0 -0
- package/template/python/Dockerfile +15 -0
- package/template/python/README.md +33 -0
- package/template/python/_gitignore +3 -0
- package/template/python/app.py +110 -0
- package/template/python/lzc-build.yml +21 -0
- package/template/python/lzc-icon.png +0 -0
- package/template/python/requirements.txt +1 -0
- package/template/python/run.sh +3 -0
- package/template/python/web/index.html +238 -0
- package/template/springboot/Dockerfile +20 -0
- package/template/springboot/README.md +33 -0
- package/template/springboot/_gitignore +3 -0
- package/template/springboot/lzc-build.yml +21 -0
- package/template/springboot/lzc-icon.png +0 -0
- package/template/springboot/pom.xml +38 -0
- package/template/springboot/run.sh +3 -0
- package/template/springboot/src/main/java/cloud/lazycat/app/Application.java +132 -0
- package/template/springboot/src/main/resources/application.properties +1 -0
- package/template/springboot/src/main/resources/static/index.html +238 -0
- package/template/vue/README.md +17 -7
- package/template/vue/_gitignore +1 -0
- package/template/vue/lzc-build.yml +31 -42
- package/template/vue/src/App.vue +36 -25
- package/template/vue/src/style.css +106 -49
- package/template/vue-minidb/README.md +34 -0
- package/template/vue-minidb/_gitignore +26 -0
- package/template/vue-minidb/index.html +13 -0
- package/template/vue-minidb/lzc-build.yml +48 -0
- package/template/vue-minidb/lzc-icon.png +0 -0
- package/template/vue-minidb/package.json +21 -0
- package/template/vue-minidb/public/vite.svg +1 -0
- package/template/vue-minidb/src/App.vue +206 -0
- package/template/vue-minidb/src/assets/vue.svg +1 -0
- package/template/vue-minidb/src/main.ts +5 -0
- package/template/vue-minidb/src/style.css +136 -0
- package/template/vue-minidb/src/vite-env.d.ts +1 -0
- package/template/vue-minidb/tsconfig.app.json +24 -0
- package/template/vue-minidb/tsconfig.json +7 -0
- package/template/vue-minidb/tsconfig.node.json +22 -0
- package/template/vue-minidb/vite.config.ts +10 -0
- /package/template/{vue → vue-minidb}/src/components/HelloWorld.vue +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Lazycat Python Todo Template</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg-1: #f8fbff;
|
|
10
|
+
--bg-2: #ecf6ff;
|
|
11
|
+
--card: #ffffff;
|
|
12
|
+
--text: #142033;
|
|
13
|
+
--muted: #4e617b;
|
|
14
|
+
--line: #d8e4f2;
|
|
15
|
+
--brand: #0076d6;
|
|
16
|
+
--brand-2: #00a5a5;
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
min-height: 100vh;
|
|
22
|
+
display: grid;
|
|
23
|
+
place-items: center;
|
|
24
|
+
padding: 24px;
|
|
25
|
+
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
26
|
+
background: radial-gradient(circle at top right, var(--bg-2), var(--bg-1) 42%);
|
|
27
|
+
color: var(--text);
|
|
28
|
+
}
|
|
29
|
+
.card {
|
|
30
|
+
width: min(920px, 100%);
|
|
31
|
+
background: var(--card);
|
|
32
|
+
border: 1px solid var(--line);
|
|
33
|
+
border-radius: 22px;
|
|
34
|
+
padding: 26px;
|
|
35
|
+
box-shadow: 0 18px 48px rgba(20, 32, 51, 0.08);
|
|
36
|
+
}
|
|
37
|
+
.tag {
|
|
38
|
+
margin: 0;
|
|
39
|
+
color: var(--brand);
|
|
40
|
+
font-weight: 700;
|
|
41
|
+
letter-spacing: .08em;
|
|
42
|
+
text-transform: uppercase;
|
|
43
|
+
font-size: .85rem;
|
|
44
|
+
}
|
|
45
|
+
h1 { margin: 10px 0 12px; font-size: clamp(1.8rem, 3.2vw, 2.7rem); }
|
|
46
|
+
p { margin: 0; color: var(--muted); }
|
|
47
|
+
.btns {
|
|
48
|
+
margin-top: 18px;
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: 10px;
|
|
51
|
+
flex-wrap: wrap;
|
|
52
|
+
}
|
|
53
|
+
a {
|
|
54
|
+
display: inline-flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
text-decoration: none;
|
|
58
|
+
border-radius: 10px;
|
|
59
|
+
padding: 10px 14px;
|
|
60
|
+
border: 1px solid var(--line);
|
|
61
|
+
color: var(--text);
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
}
|
|
64
|
+
a.primary {
|
|
65
|
+
color: #fff;
|
|
66
|
+
border-color: transparent;
|
|
67
|
+
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
|
68
|
+
}
|
|
69
|
+
.panel {
|
|
70
|
+
margin-top: 22px;
|
|
71
|
+
border: 1px solid var(--line);
|
|
72
|
+
border-radius: 14px;
|
|
73
|
+
padding: 14px;
|
|
74
|
+
background: #fbfdff;
|
|
75
|
+
}
|
|
76
|
+
.sync-note {
|
|
77
|
+
margin-top: 8px;
|
|
78
|
+
font-size: .92rem;
|
|
79
|
+
}
|
|
80
|
+
.form {
|
|
81
|
+
margin-top: 12px;
|
|
82
|
+
display: flex;
|
|
83
|
+
gap: 10px;
|
|
84
|
+
}
|
|
85
|
+
.form input {
|
|
86
|
+
flex: 1;
|
|
87
|
+
border: 1px solid var(--line);
|
|
88
|
+
border-radius: 10px;
|
|
89
|
+
padding: 10px 12px;
|
|
90
|
+
min-width: 0;
|
|
91
|
+
}
|
|
92
|
+
button {
|
|
93
|
+
border: 1px solid var(--line);
|
|
94
|
+
border-radius: 10px;
|
|
95
|
+
padding: 9px 12px;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
background: #fff;
|
|
98
|
+
}
|
|
99
|
+
ul {
|
|
100
|
+
margin: 12px 0 0;
|
|
101
|
+
padding: 0;
|
|
102
|
+
list-style: none;
|
|
103
|
+
display: grid;
|
|
104
|
+
gap: 10px;
|
|
105
|
+
}
|
|
106
|
+
li {
|
|
107
|
+
border: 1px solid var(--line);
|
|
108
|
+
border-radius: 10px;
|
|
109
|
+
padding: 10px 12px;
|
|
110
|
+
display: flex;
|
|
111
|
+
justify-content: space-between;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 8px;
|
|
114
|
+
background: #fff;
|
|
115
|
+
}
|
|
116
|
+
label {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: 8px;
|
|
120
|
+
}
|
|
121
|
+
.done {
|
|
122
|
+
text-decoration: line-through;
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
}
|
|
125
|
+
.danger {
|
|
126
|
+
color: #a63e37;
|
|
127
|
+
}
|
|
128
|
+
@media (max-width: 640px) {
|
|
129
|
+
.form {
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
}
|
|
132
|
+
li {
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
align-items: flex-start;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<main class="card">
|
|
141
|
+
<p class="tag">Python Version</p>
|
|
142
|
+
<h1>Todo demo powered by Flask API</h1>
|
|
143
|
+
<p>
|
|
144
|
+
This template includes a Python backend and a simple Todo app to help you start fast.
|
|
145
|
+
</p>
|
|
146
|
+
|
|
147
|
+
<div class="btns">
|
|
148
|
+
<a class="primary" href="https://lazycat.cloud/" target="_blank" rel="noreferrer">Visit lazycat.cloud</a>
|
|
149
|
+
<a href="https://developer.lazycat.cloud/" target="_blank" rel="noreferrer">Open Developer Docs</a>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<section class="panel">
|
|
153
|
+
<strong>Todo quick demo</strong>
|
|
154
|
+
<p class="sync-note">Data syncs automatically across devices when opened with the same app account.</p>
|
|
155
|
+
<div class="form">
|
|
156
|
+
<input id="todoInput" placeholder="Add a task for your app kickoff" />
|
|
157
|
+
<button id="addBtn" type="button">Add</button>
|
|
158
|
+
</div>
|
|
159
|
+
<ul id="todoList"></ul>
|
|
160
|
+
</section>
|
|
161
|
+
</main>
|
|
162
|
+
<script>
|
|
163
|
+
const todoInput = document.getElementById('todoInput');
|
|
164
|
+
const addBtn = document.getElementById('addBtn');
|
|
165
|
+
const todoList = document.getElementById('todoList');
|
|
166
|
+
|
|
167
|
+
async function fetchTodos() {
|
|
168
|
+
const resp = await fetch('/api/todos');
|
|
169
|
+
const data = await resp.json();
|
|
170
|
+
return Array.isArray(data.items) ? data.items : [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderTodos(items) {
|
|
174
|
+
todoList.innerHTML = '';
|
|
175
|
+
for (const todo of items) {
|
|
176
|
+
const li = document.createElement('li');
|
|
177
|
+
|
|
178
|
+
const label = document.createElement('label');
|
|
179
|
+
const checkbox = document.createElement('input');
|
|
180
|
+
checkbox.type = 'checkbox';
|
|
181
|
+
checkbox.checked = !!todo.done;
|
|
182
|
+
checkbox.addEventListener('change', async () => {
|
|
183
|
+
await fetch(`/api/todos/${todo.id}/toggle`, { method: 'PUT' });
|
|
184
|
+
await refreshTodos();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const span = document.createElement('span');
|
|
188
|
+
span.textContent = todo.title;
|
|
189
|
+
if (todo.done) {
|
|
190
|
+
span.className = 'done';
|
|
191
|
+
}
|
|
192
|
+
label.append(checkbox, span);
|
|
193
|
+
|
|
194
|
+
const delBtn = document.createElement('button');
|
|
195
|
+
delBtn.type = 'button';
|
|
196
|
+
delBtn.className = 'danger';
|
|
197
|
+
delBtn.textContent = 'Delete';
|
|
198
|
+
delBtn.addEventListener('click', async () => {
|
|
199
|
+
await fetch(`/api/todos/${todo.id}`, { method: 'DELETE' });
|
|
200
|
+
await refreshTodos();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
li.append(label, delBtn);
|
|
204
|
+
todoList.appendChild(li);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function refreshTodos() {
|
|
209
|
+
const items = await fetchTodos();
|
|
210
|
+
renderTodos(items);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function addTodo() {
|
|
214
|
+
const title = todoInput.value.trim();
|
|
215
|
+
if (!title) return;
|
|
216
|
+
await fetch('/api/todos', {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'Content-Type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ title }),
|
|
220
|
+
});
|
|
221
|
+
todoInput.value = '';
|
|
222
|
+
await refreshTodos();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
addBtn.addEventListener('click', addTodo);
|
|
226
|
+
todoInput.addEventListener('keyup', async (event) => {
|
|
227
|
+
if (event.key === 'Enter') {
|
|
228
|
+
await addTodo();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
refreshTodos();
|
|
233
|
+
setInterval(refreshTodos, 2500);
|
|
234
|
+
document.addEventListener('visibilitychange', refreshTodos);
|
|
235
|
+
window.addEventListener('focus', refreshTodos);
|
|
236
|
+
</script>
|
|
237
|
+
</body>
|
|
238
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
FROM registry.lazycat.cloud/lzc/lzcapp:3.20.3 AS builder
|
|
2
|
+
|
|
3
|
+
RUN apk add --no-cache openjdk17 maven
|
|
4
|
+
WORKDIR /workspace
|
|
5
|
+
|
|
6
|
+
COPY pom.xml ./
|
|
7
|
+
COPY src ./src
|
|
8
|
+
RUN mvn -DskipTests package
|
|
9
|
+
|
|
10
|
+
FROM registry.lazycat.cloud/lzc/lzcapp:3.20.3
|
|
11
|
+
|
|
12
|
+
RUN apk add --no-cache openjdk17-jre-headless
|
|
13
|
+
WORKDIR /app
|
|
14
|
+
|
|
15
|
+
COPY --from=builder /workspace/target/*.jar /app/app.jar
|
|
16
|
+
COPY run.sh /app/run.sh
|
|
17
|
+
RUN chmod +x /app/run.sh
|
|
18
|
+
|
|
19
|
+
EXPOSE 8080
|
|
20
|
+
CMD ["java", "-jar", "/app/app.jar"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Lazycat Java Spring Boot Todo App
|
|
2
|
+
|
|
3
|
+
## First Deploy
|
|
4
|
+
```bash
|
|
5
|
+
lzc-cli project deploy
|
|
6
|
+
lzc-cli project info
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Then open the app from launcher or browser.
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
```text
|
|
13
|
+
GET /api/health
|
|
14
|
+
GET /api/todos
|
|
15
|
+
POST /api/todos
|
|
16
|
+
PUT /api/todos/{id}/toggle
|
|
17
|
+
DELETE /api/todos/{id}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Data Path
|
|
21
|
+
```text
|
|
22
|
+
/lzcapp/var/todos.json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Build LPK
|
|
26
|
+
```bash
|
|
27
|
+
lzc-cli project build -o springboot-app.lpk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
```bash
|
|
32
|
+
lzc-cli lpk install springboot-app.lpk
|
|
33
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 可选基础文件: lzc-build.base.yml(与当前文件同目录,先加载再与当前文件合并)
|
|
2
|
+
|
|
3
|
+
# manifest: 指定 lpk 的 manifest 文件
|
|
4
|
+
manifest: ./lzc-manifest.yml
|
|
5
|
+
|
|
6
|
+
# contentdir: 需要打包进 lpk 的目录
|
|
7
|
+
contentdir: ./
|
|
8
|
+
|
|
9
|
+
# pkgout: lpk 输出目录
|
|
10
|
+
pkgout: ./
|
|
11
|
+
|
|
12
|
+
# icon: 应用图标(png)
|
|
13
|
+
icon: ./lzc-icon.png
|
|
14
|
+
|
|
15
|
+
# images: 构建容器镜像并写入 lpk v2 的 images 目录
|
|
16
|
+
images:
|
|
17
|
+
app-runtime:
|
|
18
|
+
# context: Docker 构建上下文目录
|
|
19
|
+
context: ./
|
|
20
|
+
# dockerfile: 镜像构建文件
|
|
21
|
+
dockerfile: ./Dockerfile
|
|
Binary file
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
2
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
3
|
+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
4
|
+
<modelVersion>4.0.0</modelVersion>
|
|
5
|
+
|
|
6
|
+
<parent>
|
|
7
|
+
<groupId>org.springframework.boot</groupId>
|
|
8
|
+
<artifactId>spring-boot-starter-parent</artifactId>
|
|
9
|
+
<version>3.3.2</version>
|
|
10
|
+
<relativePath/>
|
|
11
|
+
</parent>
|
|
12
|
+
|
|
13
|
+
<groupId>cloud.lazycat.app</groupId>
|
|
14
|
+
<artifactId>springboot-app</artifactId>
|
|
15
|
+
<version>0.0.1</version>
|
|
16
|
+
<name>springboot-app</name>
|
|
17
|
+
<description>Spring Boot template for lzc-cli</description>
|
|
18
|
+
|
|
19
|
+
<properties>
|
|
20
|
+
<java.version>17</java.version>
|
|
21
|
+
</properties>
|
|
22
|
+
|
|
23
|
+
<dependencies>
|
|
24
|
+
<dependency>
|
|
25
|
+
<groupId>org.springframework.boot</groupId>
|
|
26
|
+
<artifactId>spring-boot-starter-web</artifactId>
|
|
27
|
+
</dependency>
|
|
28
|
+
</dependencies>
|
|
29
|
+
|
|
30
|
+
<build>
|
|
31
|
+
<plugins>
|
|
32
|
+
<plugin>
|
|
33
|
+
<groupId>org.springframework.boot</groupId>
|
|
34
|
+
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
35
|
+
</plugin>
|
|
36
|
+
</plugins>
|
|
37
|
+
</build>
|
|
38
|
+
</project>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
package cloud.lazycat.app;
|
|
2
|
+
|
|
3
|
+
import java.io.IOException;
|
|
4
|
+
import java.nio.file.Files;
|
|
5
|
+
import java.nio.file.Path;
|
|
6
|
+
import java.nio.file.StandardCopyOption;
|
|
7
|
+
import java.util.ArrayList;
|
|
8
|
+
import java.util.Comparator;
|
|
9
|
+
import java.util.List;
|
|
10
|
+
import java.util.Map;
|
|
11
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
12
|
+
import java.util.concurrent.atomic.AtomicLong;
|
|
13
|
+
|
|
14
|
+
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
15
|
+
|
|
16
|
+
import org.springframework.boot.SpringApplication;
|
|
17
|
+
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
18
|
+
import org.springframework.http.HttpStatus;
|
|
19
|
+
import org.springframework.web.bind.annotation.GetMapping;
|
|
20
|
+
import org.springframework.web.bind.annotation.PathVariable;
|
|
21
|
+
import org.springframework.web.bind.annotation.PostMapping;
|
|
22
|
+
import org.springframework.web.bind.annotation.PutMapping;
|
|
23
|
+
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
24
|
+
import org.springframework.web.bind.annotation.RequestBody;
|
|
25
|
+
import org.springframework.web.bind.annotation.RestController;
|
|
26
|
+
import org.springframework.web.server.ResponseStatusException;
|
|
27
|
+
|
|
28
|
+
@SpringBootApplication
|
|
29
|
+
@RestController
|
|
30
|
+
public class Application {
|
|
31
|
+
private final Map<Long, TodoItem> todos = new ConcurrentHashMap<>();
|
|
32
|
+
private final AtomicLong nextId = new AtomicLong(1);
|
|
33
|
+
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
34
|
+
private final Path todosFile = Path.of("/lzcapp/var/todos.json");
|
|
35
|
+
|
|
36
|
+
public Application() {
|
|
37
|
+
try {
|
|
38
|
+
loadTodos();
|
|
39
|
+
} catch (IOException e) {
|
|
40
|
+
throw new IllegalStateException("failed to load todos", e);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public static void main(String[] args) {
|
|
45
|
+
SpringApplication.run(Application.class, args);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@GetMapping("/api/health")
|
|
49
|
+
public Map<String, String> health() {
|
|
50
|
+
return Map.of("status", "ok");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@GetMapping("/api/todos")
|
|
54
|
+
public Map<String, List<TodoItem>> listTodos() {
|
|
55
|
+
List<TodoItem> items = new ArrayList<>(todos.values());
|
|
56
|
+
items.sort(Comparator.comparingLong(TodoItem::updatedAt).reversed());
|
|
57
|
+
return Map.of("items", items);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@PostMapping("/api/todos")
|
|
61
|
+
public TodoItem addTodo(@RequestBody TodoCreateRequest request) {
|
|
62
|
+
String title = request.title() == null ? "" : request.title().trim();
|
|
63
|
+
if (title.isEmpty()) {
|
|
64
|
+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
|
|
65
|
+
}
|
|
66
|
+
TodoItem todo = new TodoItem(nextId.getAndIncrement(), title, false, System.currentTimeMillis());
|
|
67
|
+
todos.put(todo.id(), todo);
|
|
68
|
+
try {
|
|
69
|
+
persistTodos();
|
|
70
|
+
} catch (IOException e) {
|
|
71
|
+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "failed to persist todos");
|
|
72
|
+
}
|
|
73
|
+
return todo;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@PutMapping("/api/todos/{id}/toggle")
|
|
77
|
+
public TodoItem toggleTodo(@PathVariable("id") long id) {
|
|
78
|
+
TodoItem found = todos.get(id);
|
|
79
|
+
if (found == null) {
|
|
80
|
+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "todo not found");
|
|
81
|
+
}
|
|
82
|
+
TodoItem updated = new TodoItem(found.id(), found.title(), !found.done(), System.currentTimeMillis());
|
|
83
|
+
todos.put(id, updated);
|
|
84
|
+
try {
|
|
85
|
+
persistTodos();
|
|
86
|
+
} catch (IOException e) {
|
|
87
|
+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "failed to persist todos");
|
|
88
|
+
}
|
|
89
|
+
return updated;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@DeleteMapping("/api/todos/{id}")
|
|
93
|
+
public Map<String, Boolean> deleteTodo(@PathVariable("id") long id) {
|
|
94
|
+
TodoItem removed = todos.remove(id);
|
|
95
|
+
if (removed == null) {
|
|
96
|
+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "todo not found");
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
persistTodos();
|
|
100
|
+
} catch (IOException e) {
|
|
101
|
+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "failed to persist todos");
|
|
102
|
+
}
|
|
103
|
+
return Map.of("ok", true);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private synchronized void loadTodos() throws IOException {
|
|
107
|
+
Files.createDirectories(todosFile.getParent());
|
|
108
|
+
if (!Files.exists(todosFile)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
TodoItem[] loaded = objectMapper.readValue(todosFile.toFile(), TodoItem[].class);
|
|
112
|
+
long maxId = 0;
|
|
113
|
+
for (TodoItem item : loaded) {
|
|
114
|
+
todos.put(item.id(), item);
|
|
115
|
+
maxId = Math.max(maxId, item.id());
|
|
116
|
+
}
|
|
117
|
+
nextId.set(maxId + 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private synchronized void persistTodos() throws IOException {
|
|
121
|
+
Files.createDirectories(todosFile.getParent());
|
|
122
|
+
List<TodoItem> items = new ArrayList<>(todos.values());
|
|
123
|
+
items.sort(Comparator.comparingLong(TodoItem::id));
|
|
124
|
+
Path tmpFile = Path.of(todosFile.toString() + ".tmp");
|
|
125
|
+
objectMapper.writerWithDefaultPrettyPrinter().writeValue(tmpFile.toFile(), items);
|
|
126
|
+
Files.move(tmpFile, todosFile, StandardCopyOption.REPLACE_EXISTING);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
record TodoCreateRequest(String title) {}
|
|
130
|
+
|
|
131
|
+
record TodoItem(long id, String title, boolean done, long updatedAt) {}
|
|
132
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
server.port=8080
|