iobroker.kanban 0.3.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 +21 -0
- package/README.md +224 -0
- package/admin/i18n/de.json +100 -0
- package/admin/i18n/en.json +100 -0
- package/admin/i18n/es.json +100 -0
- package/admin/i18n/fr.json +100 -0
- package/admin/i18n/it.json +100 -0
- package/admin/i18n/nl.json +100 -0
- package/admin/i18n/pl.json +100 -0
- package/admin/i18n/pt.json +100 -0
- package/admin/i18n/ru.json +100 -0
- package/admin/i18n/uk.json +100 -0
- package/admin/i18n/zh-cn.json +100 -0
- package/admin/jsonConfig.json +668 -0
- package/admin/kanban.png +0 -0
- package/io-package.json +299 -0
- package/lib/cron.js +203 -0
- package/lib/dueat.js +77 -0
- package/lib/events.js +24 -0
- package/lib/holidays.js +167 -0
- package/lib/i18n-server.js +301 -0
- package/lib/notify.js +627 -0
- package/lib/scheduler.js +198 -0
- package/lib/server.js +799 -0
- package/lib/store.js +1500 -0
- package/main.js +382 -0
- package/package.json +58 -0
- package/www/css/app.css +1022 -0
- package/www/i18n/de.json +238 -0
- package/www/i18n/en.json +238 -0
- package/www/i18n/es.json +238 -0
- package/www/i18n/fr.json +238 -0
- package/www/i18n/it.json +238 -0
- package/www/i18n/nl.json +238 -0
- package/www/i18n/pl.json +238 -0
- package/www/i18n/pt.json +238 -0
- package/www/i18n/ru.json +238 -0
- package/www/i18n/uk.json +238 -0
- package/www/i18n/zh-cn.json +238 -0
- package/www/index.html +171 -0
- package/www/js/api.js +98 -0
- package/www/js/app.js +411 -0
- package/www/js/board.js +983 -0
- package/www/js/colorpicker.js +165 -0
- package/www/js/dialogs.js +1419 -0
- package/www/js/i18n.js +43 -0
- package/www/vendor/marked.min.js +69 -0
- package/www/vendor/purify.min.js +3 -0
- package/www/vendor/sortable.min.js +2 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
{
|
|
2
|
+
"topbar.selectBoard": "选择看板",
|
|
3
|
+
"topbar.activeUser": "当前用户",
|
|
4
|
+
"topbar.filterMine": "仅显示我的卡片",
|
|
5
|
+
"topbar.addCard": "+ 卡片",
|
|
6
|
+
"topbar.theme": "切换主题",
|
|
7
|
+
"topbar.share": "视图",
|
|
8
|
+
"topbar.settings": "设置",
|
|
9
|
+
"board.loading": "加载中…",
|
|
10
|
+
"board.empty": "还没有看板——请通过 ⚙ 创建。",
|
|
11
|
+
"board.addCard": "+ 添加卡片",
|
|
12
|
+
"col.hideDone": "隐藏已完成",
|
|
13
|
+
"col.showDone": "显示已完成",
|
|
14
|
+
"badge.recurring": "重复任务",
|
|
15
|
+
"badge.description": "含描述",
|
|
16
|
+
"card.titleNew": "新建卡片",
|
|
17
|
+
"card.titleEdit": "编辑卡片",
|
|
18
|
+
"card.title": "标题",
|
|
19
|
+
"card.titleRequired": "请输入标题",
|
|
20
|
+
"card.description": "描述",
|
|
21
|
+
"card.markdown": "(Markdown)",
|
|
22
|
+
"card.dueDate": "到期日期",
|
|
23
|
+
"card.timeToggleTitle": "同时设置时间",
|
|
24
|
+
"card.time": "时间",
|
|
25
|
+
"card.priority": "优先级",
|
|
26
|
+
"card.column": "列",
|
|
27
|
+
"card.assignees": "负责人",
|
|
28
|
+
"card.requiredLegend": "必填项",
|
|
29
|
+
"card.assigneeRequired": "请至少选择一位负责人",
|
|
30
|
+
"card.labels": "标签",
|
|
31
|
+
"card.color": "卡片颜色",
|
|
32
|
+
"card.link": "链接",
|
|
33
|
+
"card.location": "地点",
|
|
34
|
+
"card.locationPlaceholder": "例如:市政厅,主街 1 号",
|
|
35
|
+
"card.calendarInviteTitle": "仅在设置了到期日期时生效",
|
|
36
|
+
"card.calendarDuration": "时长",
|
|
37
|
+
"card.calendarDurationTitle": "日历邀请中日程的时长(时:分)",
|
|
38
|
+
"card.calendarInvite": "在通知中附带日历邀请(.ics)",
|
|
39
|
+
"card.recurrence": "重复",
|
|
40
|
+
"card.checklist": "清单",
|
|
41
|
+
"card.addCheckItem": "+ 添加条目",
|
|
42
|
+
"card.delete": "删除",
|
|
43
|
+
"card.manage": "管理",
|
|
44
|
+
"card.manageTitle": "转移或克隆卡片",
|
|
45
|
+
"card.cancel": "取消",
|
|
46
|
+
"card.save": "保存",
|
|
47
|
+
"card.checkPlaceholder": "条目…",
|
|
48
|
+
"card.checkDrag": "调整条目顺序",
|
|
49
|
+
"prio.normal": "普通",
|
|
50
|
+
"prio.high": "高",
|
|
51
|
+
"prio.urgent": "紧急",
|
|
52
|
+
"rec.none": "无",
|
|
53
|
+
"rec.daily": "每天",
|
|
54
|
+
"rec.weekly": "每周",
|
|
55
|
+
"rec.monthly": "每月(按日期)",
|
|
56
|
+
"rec.monthlyWeekday": "每月(按星期)",
|
|
57
|
+
"rec.workday": "每月工作日",
|
|
58
|
+
"rec.yearly": "每年",
|
|
59
|
+
"rec.everyNDays": "每 X 天",
|
|
60
|
+
"rec.interval": "间隔(天)",
|
|
61
|
+
"rec.dayOfMonth": "每月第几日",
|
|
62
|
+
"rec.month": "月份",
|
|
63
|
+
"rec.which": "哪一个",
|
|
64
|
+
"rec.position": "位置",
|
|
65
|
+
"rec.n": "n",
|
|
66
|
+
"rec.ordinalLast": "最后一个",
|
|
67
|
+
"rec.hint.daily": "完成后于次日再次出现。",
|
|
68
|
+
"rec.hint.weekly": "完成 → 在下一个所选星期几生成新卡片。",
|
|
69
|
+
"rec.hint.monthly": "完成 → 在下个月所选日期生成新卡片。",
|
|
70
|
+
"rec.hint.monthly_weekday": "完成 → 在下个月所选星期几(例如第 2 个星期二)生成新卡片。",
|
|
71
|
+
"rec.hint.workday": "完成 → 在下个月所选工作日生成新卡片(跳过周末和法定节假日)。",
|
|
72
|
+
"rec.hint.yearly": "完成 → 在明年所选月份和日期生成新卡片。",
|
|
73
|
+
"rec.hint.every_n_days": "完成 → 经过设定的天数后生成新卡片。",
|
|
74
|
+
"wd.first": "第一个",
|
|
75
|
+
"wd.last": "最后一个",
|
|
76
|
+
"wd.nth": "第 n 个(从前往后)",
|
|
77
|
+
"wd.nthLast": "第 n 个(从后往前)",
|
|
78
|
+
"month.1": "一月",
|
|
79
|
+
"month.2": "二月",
|
|
80
|
+
"month.3": "三月",
|
|
81
|
+
"month.4": "四月",
|
|
82
|
+
"month.5": "五月",
|
|
83
|
+
"month.6": "六月",
|
|
84
|
+
"month.7": "七月",
|
|
85
|
+
"month.8": "八月",
|
|
86
|
+
"month.9": "九月",
|
|
87
|
+
"month.10": "十月",
|
|
88
|
+
"month.11": "十一月",
|
|
89
|
+
"month.12": "十二月",
|
|
90
|
+
"weekday.1": "周一",
|
|
91
|
+
"weekday.2": "周二",
|
|
92
|
+
"weekday.3": "周三",
|
|
93
|
+
"weekday.4": "周四",
|
|
94
|
+
"weekday.5": "周五",
|
|
95
|
+
"weekday.6": "周六",
|
|
96
|
+
"weekday.7": "周日",
|
|
97
|
+
"label.new": "+ 新建",
|
|
98
|
+
"label.namePlaceholder": "标签名称",
|
|
99
|
+
"color.own": "自定义颜色",
|
|
100
|
+
"color.none": "无颜色",
|
|
101
|
+
"boards.boardTitle": "看板标题",
|
|
102
|
+
"boards.columns": "列(拖动可调整顺序)",
|
|
103
|
+
"boards.columnName": "列名称",
|
|
104
|
+
"boards.wipTitle": "WIP 上限",
|
|
105
|
+
"boards.done": "完成",
|
|
106
|
+
"boards.allowAdd": "新建",
|
|
107
|
+
"boards.deleteColumnTitle": "删除该列(卡片将移到第一列)",
|
|
108
|
+
"boards.addColumn": "+ 添加列",
|
|
109
|
+
"boards.labels": "标签",
|
|
110
|
+
"boards.deleteLabelTitle": "删除标签",
|
|
111
|
+
"boards.addLabel": "+ 添加标签",
|
|
112
|
+
"boards.linkTarget": "通知中的链接",
|
|
113
|
+
"boards.linkBoard": "看板视图(高亮该卡片)",
|
|
114
|
+
"boards.linkEdit": "打开卡片编辑器",
|
|
115
|
+
"boards.linkUrl": "自定义 URL",
|
|
116
|
+
"boards.linkUrlField": "自定义 URL(固定地址)",
|
|
117
|
+
"boards.members": "成员(可被指派的用户)",
|
|
118
|
+
"boards.membersHint": "每个看板至少需要选择一位用户。",
|
|
119
|
+
"boards.membersRequiredShort": "每个看板至少需要一位用户。",
|
|
120
|
+
"boards.memberRemoveTitle": "移除成员",
|
|
121
|
+
"boards.memberRemoveMsg": "有 {count} 张卡片仅以 {names} 为负责人。这些卡片会保留指派,但不再出现在人员筛选中。仍要移除吗?",
|
|
122
|
+
"boards.deleteBoard": "删除看板",
|
|
123
|
+
"boards.close": "关闭",
|
|
124
|
+
"boards.save": "保存",
|
|
125
|
+
"boards.newBoard": "新建看板…",
|
|
126
|
+
"boards.noneYet": "还没有看板——请在上方输入名称并点击“创建”。",
|
|
127
|
+
"boards.create": "创建",
|
|
128
|
+
"share.title": "视图",
|
|
129
|
+
"share.hint": "生成带有预设筛选和显示选项的链接,例如用于嵌入(Lovelace 的 webpage 卡片)或分享。",
|
|
130
|
+
"share.board": "看板",
|
|
131
|
+
"share.users": "用户(留空 = 全部)",
|
|
132
|
+
"share.labels": "隐藏标签(留空 = 全部显示)",
|
|
133
|
+
"share.labelsHide": "隐藏这些标签",
|
|
134
|
+
"share.labelsOnly": "仅显示这些标签",
|
|
135
|
+
"share.visibleColumns": "可见的列",
|
|
136
|
+
"share.doneLimit": "最多显示的已完成任务(留空 = 全部,0 = 不显示)",
|
|
137
|
+
"share.doneLimitAll": "全部",
|
|
138
|
+
"share.hideSettings": "隐藏设置齿轮",
|
|
139
|
+
"share.embed": "嵌入模式(紧凑,适用于 iframe)",
|
|
140
|
+
"share.generatedUrl": "生成的链接",
|
|
141
|
+
"share.copy": "复制",
|
|
142
|
+
"share.copied": "已复制 ✓",
|
|
143
|
+
"confirm.deleteCard": "将该卡片移入回收站?之后 30 天内仍可恢复。",
|
|
144
|
+
"confirm.deleteBoard": "删除看板“{title}”及其所有卡片?",
|
|
145
|
+
"error.moveFailed": "移动失败:{msg}",
|
|
146
|
+
"error.saveFailed": "保存失败:{msg}",
|
|
147
|
+
"boards.avatars": "用户头像(以图片代替首字母)",
|
|
148
|
+
"avatar.choose": "选择图片",
|
|
149
|
+
"user.color": "颜色",
|
|
150
|
+
"avatar.remove": "移除头像",
|
|
151
|
+
"avatar.failed": "上传失败:{msg}",
|
|
152
|
+
"error.loadFailed": "加载失败",
|
|
153
|
+
"settings.title": "设置",
|
|
154
|
+
"settings.tabBoard": "看板",
|
|
155
|
+
"settings.tabUsers": "用户",
|
|
156
|
+
"card.checklistToggle": "展开/收起清单",
|
|
157
|
+
"boards.colTitle": "标题",
|
|
158
|
+
"boards.colMax": "上限",
|
|
159
|
+
"boards.colWip": "WIP",
|
|
160
|
+
"boards.legendTitle": "列名称",
|
|
161
|
+
"boards.legendMax": "最多显示的卡片数(0 = 全部)",
|
|
162
|
+
"boards.legendWip": "WIP 上限——仅作视觉提示(0 = 不限制)",
|
|
163
|
+
"boards.legendAdd": "此列可以添加卡片",
|
|
164
|
+
"boards.legendDone": "将该列标记为“已完成”",
|
|
165
|
+
"boards.maxTitle": "每列最多显示的卡片数(0 = 全部)",
|
|
166
|
+
"boards.dragTitle": "拖动可调整顺序",
|
|
167
|
+
"board.moreCards": "还有 {n} 张",
|
|
168
|
+
"board.moreCardsTitle": "更多卡片因显示上限而被隐藏",
|
|
169
|
+
"col.trash": "回收站",
|
|
170
|
+
"sort.mode": "排序",
|
|
171
|
+
"sort.manual": "拖放",
|
|
172
|
+
"sort.grid": "拖动手柄",
|
|
173
|
+
"sort.gripTitle": "拖动可调整顺序",
|
|
174
|
+
"sort.due": "到期日期",
|
|
175
|
+
"sort.priority": "优先级",
|
|
176
|
+
"sort.age": "在列中的时长",
|
|
177
|
+
"sort.reverse": "反向排序",
|
|
178
|
+
"card.doneLabel": "已完成",
|
|
179
|
+
"card.copy": "复制卡片",
|
|
180
|
+
"card.edit": "编辑卡片",
|
|
181
|
+
"trash.daysLeft": "还剩 {n} 天",
|
|
182
|
+
"trash.restore": "恢复",
|
|
183
|
+
"trash.purge": "永久删除",
|
|
184
|
+
"trash.empty": "清空回收站",
|
|
185
|
+
"trash.showToggle": "显示回收站",
|
|
186
|
+
"confirm.title": "确认",
|
|
187
|
+
"confirm.ok": "确定",
|
|
188
|
+
"confirm.cancel": "取消",
|
|
189
|
+
"confirm.purgeCard": "永久且不可撤销地删除这张卡片?",
|
|
190
|
+
"confirm.emptyTrash": "永久删除回收站中的所有卡片?",
|
|
191
|
+
"cleanup.title": "将已完成的卡片移入回收站",
|
|
192
|
+
"cleanup.off": "关闭",
|
|
193
|
+
"cleanup.age": "按时长",
|
|
194
|
+
"cleanup.count": "按数量",
|
|
195
|
+
"cleanup.days": "天数",
|
|
196
|
+
"cleanup.countLabel": "数量",
|
|
197
|
+
"cleanup.hint": "自动把较旧的已完成卡片移入回收站(每天以及启动时)。在回收站中,它们将在 30 天后被永久删除。",
|
|
198
|
+
"boards.lastBoard": "最后一个看板无法删除。",
|
|
199
|
+
"boards.editingBoard": "正在编辑哪个看板",
|
|
200
|
+
"boards.showBoard": "显示该看板",
|
|
201
|
+
"boards.showConfirm": "切换到看板“{title}”?设置对话框将保持打开。",
|
|
202
|
+
"boards.unsavedTitle": "未保存的更改",
|
|
203
|
+
"boards.unsavedMsg": "该看板有未保存的更改。保存还是放弃?",
|
|
204
|
+
"boards.discard": "放弃",
|
|
205
|
+
"transfer.title": "管理",
|
|
206
|
+
"transfer.targetBoard": "目标看板",
|
|
207
|
+
"transfer.targetColumn": "目标列",
|
|
208
|
+
"transfer.move": "移动",
|
|
209
|
+
"transfer.noOtherBoards": "没有其他看板可供转移。",
|
|
210
|
+
"transfer.copy": "复制",
|
|
211
|
+
"transfer.clone": "克隆",
|
|
212
|
+
"transfer.cloneHint": "卡片将连同全部内容一起复制——克隆件会放在原件正下方。",
|
|
213
|
+
"transfer.dropLabels": "将移除 {n} 个没有对应项的标签。",
|
|
214
|
+
"transfer.dropAssignees": "有 {n} 位负责人不是目标看板的成员,将被移除。",
|
|
215
|
+
"transfer.pickAssignee": "当前负责人都不是该看板的成员。请至少选择一位负责人。",
|
|
216
|
+
"desc.close": "关闭",
|
|
217
|
+
"rec.cron": "Cron 表达式",
|
|
218
|
+
"rec.cronExpr": "模式",
|
|
219
|
+
"rec.hint.cron": "完成 → 在下一个符合该模式的日期生成新卡片。分钟和小时决定卡片的时间。",
|
|
220
|
+
"cron.empty": "请输入模式,例如 0 8 * * 1-5",
|
|
221
|
+
"cron.next": "接下来:{dates}",
|
|
222
|
+
"cron.everyDayAt": "每天 {time}",
|
|
223
|
+
"cron.monthlyAt": "每月 {day} 日 {time}",
|
|
224
|
+
"cron.rangeAt": "{from} 至 {to} {time}",
|
|
225
|
+
"cron.daysAt": "{days} {time}",
|
|
226
|
+
"cron.minute": "分钟",
|
|
227
|
+
"cron.hour": "小时",
|
|
228
|
+
"cron.day": "日",
|
|
229
|
+
"cron.month": "月",
|
|
230
|
+
"cron.weekday": "星期",
|
|
231
|
+
"cron.sun": "星期日",
|
|
232
|
+
"cron.mon": "星期一",
|
|
233
|
+
"cron.tue": "星期二",
|
|
234
|
+
"cron.wed": "星期三",
|
|
235
|
+
"cron.thu": "星期四",
|
|
236
|
+
"cron.fri": "星期五",
|
|
237
|
+
"cron.sat": "星期六"
|
|
238
|
+
}
|
package/www/index.html
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="de">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<!-- Content-Security-Policy: unterbindet Inline-/Fremd-Skripte (Defense-in-Depth gegen XSS).
|
|
7
|
+
Als <meta> gesetzt → 'frame-ancestors' entfällt bewusst, damit die iframe-Einbindung (Lovelace) frei bleibt. -->
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http: https:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'">
|
|
9
|
+
<title>Kanban</title>
|
|
10
|
+
<link rel="stylesheet" href="css/app.css">
|
|
11
|
+
<link rel="stylesheet" href="api/custom.css">
|
|
12
|
+
<script src="vendor/sortable.min.js"></script>
|
|
13
|
+
<script src="vendor/marked.min.js"></script>
|
|
14
|
+
<script src="vendor/purify.min.js"></script>
|
|
15
|
+
<script type="module" src="js/app.js"></script>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<header id="topbar">
|
|
19
|
+
<select id="boardSelect" data-i18n-title="topbar.selectBoard"></select>
|
|
20
|
+
<div id="userChips" data-i18n-title="topbar.activeUser"></div>
|
|
21
|
+
<button id="filterBtn" class="iconbtn" data-i18n-title="topbar.filterMine">☰</button>
|
|
22
|
+
<span class="spacer"></span>
|
|
23
|
+
<button id="addCardBtn" class="primary" data-i18n="topbar.addCard">+ Karte</button>
|
|
24
|
+
<button id="themeBtn" class="iconbtn" data-i18n-title="topbar.theme">◑</button>
|
|
25
|
+
<button id="shareBtn" class="iconbtn" data-i18n-title="topbar.share">🔗</button>
|
|
26
|
+
<button id="settingsBtn" class="iconbtn" data-i18n-title="topbar.settings">⚙</button>
|
|
27
|
+
</header>
|
|
28
|
+
<main id="board"><div class="empty" data-i18n="board.loading">Lade…</div></main>
|
|
29
|
+
|
|
30
|
+
<dialog id="cardDialog">
|
|
31
|
+
<form method="dialog" id="cardForm">
|
|
32
|
+
<h3 id="cardDialogTitle">Karte</h3>
|
|
33
|
+
<label>
|
|
34
|
+
<span class="req-cap"><span data-i18n="card.title">Titel</span><span class="req-star" aria-hidden="true">*</span></span>
|
|
35
|
+
<input type="text" name="title" required autocomplete="off">
|
|
36
|
+
</label>
|
|
37
|
+
<label><span data-i18n="card.description">Beschreibung</span> <span class="hint" data-i18n="card.markdown">(Markdown)</span>
|
|
38
|
+
<textarea name="description" rows="4"></textarea>
|
|
39
|
+
</label>
|
|
40
|
+
<div id="descPreview" class="md-preview" hidden></div>
|
|
41
|
+
<div class="row">
|
|
42
|
+
<label data-i18n="card.dueDate">Fällig am
|
|
43
|
+
<input type="date" name="due">
|
|
44
|
+
</label>
|
|
45
|
+
<label class="inline" id="dueTimeToggleWrap" data-i18n-title="card.timeToggleTitle">
|
|
46
|
+
<input type="checkbox" name="dueTimeEnabled"> <span data-i18n="card.time">Uhrzeit</span>
|
|
47
|
+
</label>
|
|
48
|
+
<label id="dueTimeWrap" hidden data-i18n="card.time">Uhrzeit
|
|
49
|
+
<input type="time" name="dueTime">
|
|
50
|
+
</label>
|
|
51
|
+
<label data-i18n="card.priority">Priorität
|
|
52
|
+
<select name="priority">
|
|
53
|
+
<option value="0" data-i18n="prio.normal">Normal</option>
|
|
54
|
+
<option value="1" data-i18n="prio.high">Hoch</option>
|
|
55
|
+
<option value="2" data-i18n="prio.urgent">Dringend</option>
|
|
56
|
+
</select>
|
|
57
|
+
</label>
|
|
58
|
+
<label data-i18n="card.column">Spalte
|
|
59
|
+
<select name="columnId"></select>
|
|
60
|
+
</label>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="row">
|
|
63
|
+
<label class="inline" data-i18n-title="card.calendarInviteTitle">
|
|
64
|
+
<input type="checkbox" name="calendarInvite"> <span data-i18n="card.calendarInvite">Kalender-Einladung (.ics) an Benachrichtigung anhängen</span>
|
|
65
|
+
</label>
|
|
66
|
+
<label id="calDurWrap" hidden data-i18n="card.calendarDuration" data-i18n-title="card.calendarDurationTitle">Dauer
|
|
67
|
+
<input type="time" name="calendarDuration" step="60" value="01:00">
|
|
68
|
+
</label>
|
|
69
|
+
</div>
|
|
70
|
+
<label data-i18n="card.location">Ort
|
|
71
|
+
<input type="text" name="location" data-i18n-ph="card.locationPlaceholder" placeholder="z.B. Rathaus, Musterstr. 1">
|
|
72
|
+
</label>
|
|
73
|
+
<div class="row">
|
|
74
|
+
<div class="field">
|
|
75
|
+
<label><span class="req-cap"><span data-i18n="card.assignees">Zuständig</span><span class="req-star" aria-hidden="true">*</span></span></label>
|
|
76
|
+
<div id="assigneePick" class="chip-pick"></div>
|
|
77
|
+
<input id="assigneeValidity" class="req-proxy" tabindex="-1" aria-hidden="true">
|
|
78
|
+
</div>
|
|
79
|
+
<div class="field">
|
|
80
|
+
<label data-i18n="card.labels">Labels</label>
|
|
81
|
+
<div id="labelPick" class="chip-pick"></div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="row">
|
|
85
|
+
<label data-i18n="card.color">Kartenfarbe
|
|
86
|
+
<div id="colorPick" class="color-pick"></div>
|
|
87
|
+
</label>
|
|
88
|
+
<label data-i18n="card.link">Link
|
|
89
|
+
<input type="url" name="link" placeholder="https://…">
|
|
90
|
+
</label>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="row">
|
|
93
|
+
<label data-i18n="card.recurrence">Wiederholung
|
|
94
|
+
<select name="recType" id="recType">
|
|
95
|
+
<option value="none" data-i18n="rec.none">Keine</option>
|
|
96
|
+
<option value="daily" data-i18n="rec.daily">Täglich</option>
|
|
97
|
+
<option value="weekly" data-i18n="rec.weekly">Wöchentlich</option>
|
|
98
|
+
<option value="monthly" data-i18n="rec.monthly">Monatlich (Tag im Monat)</option>
|
|
99
|
+
<option value="monthly_weekday" data-i18n="rec.monthlyWeekday">Monatlich (Wochentag)</option>
|
|
100
|
+
<option value="workday" data-i18n="rec.workday">Arbeitstag im Monat</option>
|
|
101
|
+
<option value="yearly" data-i18n="rec.yearly">Jährlich</option>
|
|
102
|
+
<option value="every_n_days" data-i18n="rec.everyNDays">Alle X Tage</option>
|
|
103
|
+
<option value="cron" data-i18n="rec.cron">Cron-Ausdruck</option>
|
|
104
|
+
</select>
|
|
105
|
+
</label>
|
|
106
|
+
<label id="recCronWrap" hidden data-i18n="rec.cronExpr">Muster
|
|
107
|
+
<input type="text" name="recCron" placeholder="0 8 * * 1-5" spellcheck="false" autocapitalize="off" autocomplete="off">
|
|
108
|
+
</label>
|
|
109
|
+
<label id="recIntervalWrap" hidden data-i18n="rec.interval">Intervall (Tage)
|
|
110
|
+
<input type="number" name="recInterval" min="1" value="2">
|
|
111
|
+
</label>
|
|
112
|
+
<label id="recDomWrap" hidden data-i18n="rec.dayOfMonth">Tag im Monat
|
|
113
|
+
<input type="number" name="recDom" min="1" max="31" value="1">
|
|
114
|
+
</label>
|
|
115
|
+
<label id="recMonthWrap" hidden data-i18n="rec.month">Monat
|
|
116
|
+
<select name="recMonth"></select>
|
|
117
|
+
</label>
|
|
118
|
+
<label id="recOrdinalWrap" hidden data-i18n="rec.which">Welcher
|
|
119
|
+
<select name="recOrdinal">
|
|
120
|
+
<option value="1">1.</option><option value="2">2.</option>
|
|
121
|
+
<option value="3">3.</option><option value="4">4.</option>
|
|
122
|
+
<option value="-1" data-i18n="rec.ordinalLast">letzter</option>
|
|
123
|
+
</select>
|
|
124
|
+
</label>
|
|
125
|
+
<label id="recWorkdayPosWrap" hidden data-i18n="rec.position">Position
|
|
126
|
+
<select name="recWorkdayPos">
|
|
127
|
+
<option value="first" data-i18n="wd.first">erster</option>
|
|
128
|
+
<option value="last" data-i18n="wd.last">letzter</option>
|
|
129
|
+
<option value="nth" data-i18n="wd.nth">n-ter (von vorn)</option>
|
|
130
|
+
<option value="nth_last" data-i18n="wd.nthLast">n-ter (von hinten)</option>
|
|
131
|
+
</select>
|
|
132
|
+
</label>
|
|
133
|
+
<label id="recWorkdayNWrap" hidden data-i18n="rec.n">n
|
|
134
|
+
<input type="number" name="recWorkdayN" min="1" max="23" value="1">
|
|
135
|
+
</label>
|
|
136
|
+
</div>
|
|
137
|
+
<div id="recWeekdayWrap" class="chip-pick" hidden></div>
|
|
138
|
+
<div id="recCronInfo" class="rec-cron-info" hidden></div>
|
|
139
|
+
<div id="recHint" class="hint" hidden></div>
|
|
140
|
+
|
|
141
|
+
<label data-i18n="card.checklist">Checkliste</label>
|
|
142
|
+
<div id="checklistEdit"></div>
|
|
143
|
+
<button type="button" id="addCheckItem" class="linkbtn" data-i18n="card.addCheckItem">+ Punkt hinzufügen</button>
|
|
144
|
+
<p class="req-legend"><span class="req-star" aria-hidden="true">*</span> <span data-i18n="card.requiredLegend">Pflichtfeld</span></p>
|
|
145
|
+
<footer>
|
|
146
|
+
<button type="button" id="deleteCardBtn" class="danger" hidden data-i18n="card.delete">Löschen</button>
|
|
147
|
+
<button type="button" id="transferCardBtn" hidden data-i18n="card.manage" data-i18n-title="card.manageTitle">Verwalten</button>
|
|
148
|
+
<span class="spacer"></span>
|
|
149
|
+
<button type="button" value="cancel" id="cancelCardBtn" data-i18n="card.cancel">Abbrechen</button>
|
|
150
|
+
<button type="submit" class="primary" id="saveCardBtn" data-i18n="card.save">Speichern</button>
|
|
151
|
+
</footer>
|
|
152
|
+
</form>
|
|
153
|
+
</dialog>
|
|
154
|
+
|
|
155
|
+
<dialog id="boardDialog">
|
|
156
|
+
<div id="boardDialogBody"></div>
|
|
157
|
+
</dialog>
|
|
158
|
+
<dialog id="shareDialog">
|
|
159
|
+
<div id="shareBody"></div>
|
|
160
|
+
</dialog>
|
|
161
|
+
<dialog id="transferDialog">
|
|
162
|
+
<div id="transferBody"></div>
|
|
163
|
+
</dialog>
|
|
164
|
+
<dialog id="confirmDialog">
|
|
165
|
+
<div id="confirmBody"></div>
|
|
166
|
+
</dialog>
|
|
167
|
+
<dialog id="descDialog">
|
|
168
|
+
<div id="descDialogBody"></div>
|
|
169
|
+
</dialog>
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
package/www/js/api.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// REST-Wrapper + WebSocket mit Reconnect und Polling-Fallback
|
|
2
|
+
|
|
3
|
+
// Schreib-Token: vom Server in index.html injiziert (<meta name="kanban-token">).
|
|
4
|
+
// Die Abfrage ist gegen ein fehlendes document abgesichert, damit sich das Modul
|
|
5
|
+
// auch ausserhalb des Browsers laden laesst — board.js importiert es, und board.js
|
|
6
|
+
// wird in den Tests geladen.
|
|
7
|
+
const WRITE_TOKEN = typeof document !== 'undefined'
|
|
8
|
+
? ((document.querySelector('meta[name="kanban-token"]') || {}).content || '')
|
|
9
|
+
: '';
|
|
10
|
+
|
|
11
|
+
export async function api(path, opts = {}) {
|
|
12
|
+
const init = { method: opts.method || 'GET', headers: {} };
|
|
13
|
+
if (opts.body !== undefined) {
|
|
14
|
+
init.headers['Content-Type'] = 'application/json';
|
|
15
|
+
init.body = JSON.stringify(opts.body);
|
|
16
|
+
}
|
|
17
|
+
if (init.method !== 'GET' && WRITE_TOKEN) init.headers['X-Kanban-Token'] = WRITE_TOKEN;
|
|
18
|
+
const res = await fetch(path, init);
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
let msg = res.statusText;
|
|
21
|
+
try { msg = (await res.json()).error || msg; } catch { /* leer */ }
|
|
22
|
+
throw new Error(msg);
|
|
23
|
+
}
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Live-Sync: WebSocket auf /ws; bei 'dirty' wird onDirty(boardId, rev) gerufen.
|
|
29
|
+
* Fällt der WS aus, greift alle 10 s ein Polling-Callback (onPoll).
|
|
30
|
+
*/
|
|
31
|
+
export function liveSync(onDirty, onPoll) {
|
|
32
|
+
let ws = null;
|
|
33
|
+
let retryMs = 1000;
|
|
34
|
+
let timer = null; // genau eine Reconnect-Kette, egal wie viele Events kommen
|
|
35
|
+
|
|
36
|
+
const isOpen = () => ws && ws.readyState === WebSocket.OPEN;
|
|
37
|
+
|
|
38
|
+
function connect() {
|
|
39
|
+
timer = null;
|
|
40
|
+
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
|
|
41
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
42
|
+
let sock;
|
|
43
|
+
try {
|
|
44
|
+
sock = new WebSocket(`${proto}//${location.host}/ws`);
|
|
45
|
+
} catch {
|
|
46
|
+
scheduleReconnect();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
ws = sock;
|
|
50
|
+
sock.onopen = () => { if (ws === sock) retryMs = 1000; };
|
|
51
|
+
sock.onmessage = ev => {
|
|
52
|
+
try {
|
|
53
|
+
const msg = JSON.parse(ev.data);
|
|
54
|
+
if (msg.type === 'dirty') onDirty(msg.boardId, msg.rev);
|
|
55
|
+
} catch { /* ignorieren */ }
|
|
56
|
+
};
|
|
57
|
+
// Nur der aktuelle Socket darf einen Reconnect auslösen — ein verspätetes
|
|
58
|
+
// close eines abgelösten Sockets würde sonst die Kette durcheinanderbringen.
|
|
59
|
+
sock.onclose = () => { if (ws === sock) scheduleReconnect(); };
|
|
60
|
+
sock.onerror = () => { try { sock.close(); } catch { /* leer */ } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function scheduleReconnect(delay) {
|
|
64
|
+
if (timer) return;
|
|
65
|
+
timer = setTimeout(connect, delay === undefined ? retryMs : delay);
|
|
66
|
+
retryMs = Math.min(retryMs * 2, 15000);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Sofort neu verbinden und abgleichen — nach Tabwechsel, Netzwechsel oder
|
|
70
|
+
* wenn der Wachhund merkt, dass die Verbindung weg ist. */
|
|
71
|
+
function revive() {
|
|
72
|
+
retryMs = 1000;
|
|
73
|
+
if (!isOpen()) {
|
|
74
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
75
|
+
connect();
|
|
76
|
+
}
|
|
77
|
+
onPoll();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
connect();
|
|
81
|
+
|
|
82
|
+
// Sicherheitsnetz 1: Wachhund. Verlässt sich nicht auf close-Events, sondern
|
|
83
|
+
// schaut selbst nach dem Zustand — nach einem Adapter-Neustart bleibt sonst
|
|
84
|
+
// eine tote Verbindung unbemerkt stehen.
|
|
85
|
+
setInterval(() => {
|
|
86
|
+
if (document.hidden) return;
|
|
87
|
+
if (!isOpen()) { scheduleReconnect(0); onPoll(); }
|
|
88
|
+
}, 10000);
|
|
89
|
+
|
|
90
|
+
// Sicherheitsnetz 2: auch bei offener Verbindung regelmäßig abgleichen. Dank
|
|
91
|
+
// ?rev= kostet das fast nichts und fängt verlorene Nachrichten auf.
|
|
92
|
+
setInterval(() => { if (!document.hidden && isOpen()) onPoll(); }, 60000);
|
|
93
|
+
|
|
94
|
+
// Sicherheitsnetz 3: Ereignisse, nach denen eine Verbindung typischerweise tot ist
|
|
95
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) revive(); });
|
|
96
|
+
window.addEventListener('online', revive);
|
|
97
|
+
window.addEventListener('pageshow', ev => { if (ev.persisted) revive(); });
|
|
98
|
+
}
|