@starlove/orbit 1.3.5 → 1.4.1
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/Dockerfile +2 -1
- package/README.md +10 -9
- package/SKILL-ORBIT.md +41 -17
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/dist/full/fonts/Chakra_Petch/OFL.txt +93 -0
- package/dist/full/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/dist/full/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/dist/full/fonts/Inter/OFL.txt +93 -0
- package/dist/full/fonts/Inter/README.txt +118 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/dist/full/fonts/JetBrains_Mono/OFL.txt +93 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +79 -0
- package/dist/full/fonts/OpenRing-Regular.ttf +0 -0
- package/dist/full/fonts/Open_Sans/OFL.txt +93 -0
- package/dist/full/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/dist/full/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/dist/full/fonts/Open_Sans/README.txt +100 -0
- package/dist/full/fonts/Orbitron/OFL.txt +93 -0
- package/dist/full/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/dist/full/fonts/Orbitron/README.txt +68 -0
- package/dist/full/fonts/Rajdhani/OFL.txt +93 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/dist/full/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/dist/full/fonts/runescape-uf.ttf +0 -0
- package/dist/full/index.html +135 -25
- package/dist/full/js/app.4e6fbe20.js +952 -0
- package/dist/full/js/app.4e6fbe20.js.map +7 -0
- package/dist/full/styles.4e60d824.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +20 -13
- package/docs/ORBIT_DISPATCH.md +73 -38
- package/docs/UPDATING.md +114 -0
- package/package.json +5 -7
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +93 -0
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +93 -0
- package/public/fonts/Inter/README.txt +118 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/OFL.txt +93 -0
- package/public/fonts/JetBrains_Mono/README.txt +79 -0
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OFL.txt +93 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +100 -0
- package/public/fonts/Orbitron/OFL.txt +93 -0
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +68 -0
- package/public/fonts/Rajdhani/OFL.txt +93 -0
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +133 -23
- package/public/js/artifact-drawer.js +79 -0
- package/public/js/board-export.js +161 -0
- package/public/js/board-menu.js +219 -17
- package/public/js/card-actions.js +1 -1
- package/public/js/create-card.js +60 -23
- package/public/js/drawer.js +164 -4
- package/public/js/font-preference.js +230 -0
- package/public/js/format.js +0 -10
- package/public/js/icons.js +16 -0
- package/public/js/local-backend.js +31 -4
- package/public/js/main.js +7 -1
- package/public/js/modal.js +3 -1
- package/public/js/panel.js +58 -0
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +606 -275
- package/public/js/sse.js +3 -0
- package/public/js/state.js +1 -1
- package/public/js/text-editing.js +128 -0
- package/public/js/ticket-detail.js +377 -148
- package/public/js/ticket-typeahead.js +49 -0
- package/public/js/toast.js +19 -4
- package/public/js/topbar-notes.js +3 -3
- package/public/js/typeahead.js +206 -0
- package/public/js/update-check.js +58 -0
- package/public/styles.css +5447 -3572
- package/src/cli/orbit.js +9 -5
- package/src/core/agent.js +12 -4
- package/src/core/agents-md.js +3 -3
- package/src/core/artifacts.js +149 -0
- package/src/core/board.js +3 -2
- package/src/core/boards.js +0 -1
- package/src/core/css-bundle.js +1 -0
- package/src/core/db.js +6 -0
- package/src/core/dispatch.js +344 -34
- package/src/core/http.js +9 -2
- package/src/core/paths.js +26 -2
- package/src/core/router.js +33 -0
- package/src/core/search.js +3 -2
- package/src/core/snapshots.js +10 -5
- package/src/core/tickets.js +34 -3
- package/src/core/util.js +3 -2
- package/src/core/version-check.js +154 -0
- package/src/mcp/orbit-client.js +18 -2
- package/src/mcp-server.js +19 -14
- package/src/server.js +28 -3
- package/dist/full/js/app.4ae80764.js +0 -742
- package/dist/full/js/app.4ae80764.js.map +0 -7
- package/dist/full/styles.b9e0c130.css +0 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Board snapshot export: builds a ZIP of every board's `.orbit.json` and
|
|
2
|
+
// triggers a browser download. Extracted from board-menu.js so both the Board
|
|
3
|
+
// flyout's "Export All" and Settings → Appearance's "Back up all boards" can
|
|
4
|
+
// reuse the exact same archive without importing the heavier board-menu module
|
|
5
|
+
// (which would create a settings ↔ board-menu import cycle). Depends only on
|
|
6
|
+
// state/api/toast, so it stays safe to import from anywhere.
|
|
7
|
+
|
|
8
|
+
import { state } from "./state.js";
|
|
9
|
+
import { api } from "./api.js";
|
|
10
|
+
import { toast } from "./toast.js";
|
|
11
|
+
|
|
12
|
+
const crcTable = Array.from({ length: 256 }, (_, index) => {
|
|
13
|
+
let crc = index;
|
|
14
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
15
|
+
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
16
|
+
}
|
|
17
|
+
return crc >>> 0;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function crc32(bytes) {
|
|
21
|
+
let crc = 0xffffffff;
|
|
22
|
+
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
23
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeUint16(bytes, value) {
|
|
27
|
+
bytes.push(value & 0xff, (value >>> 8) & 0xff);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeUint32(bytes, value) {
|
|
31
|
+
bytes.push(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function zipDateTime(date = new Date()) {
|
|
35
|
+
return {
|
|
36
|
+
time:
|
|
37
|
+
((date.getHours() & 0x1f) << 11) |
|
|
38
|
+
((date.getMinutes() & 0x3f) << 5) |
|
|
39
|
+
(Math.floor(date.getSeconds() / 2) & 0x1f),
|
|
40
|
+
date:
|
|
41
|
+
(((date.getFullYear() - 1980) & 0x7f) << 9) |
|
|
42
|
+
(((date.getMonth() + 1) & 0xf) << 5) |
|
|
43
|
+
(date.getDate() & 0x1f)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createZip(files) {
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
const localParts = [];
|
|
50
|
+
const centralParts = [];
|
|
51
|
+
let offset = 0;
|
|
52
|
+
const timestamp = zipDateTime();
|
|
53
|
+
|
|
54
|
+
files.forEach((file) => {
|
|
55
|
+
const nameBytes = encoder.encode(file.name);
|
|
56
|
+
const dataBytes = encoder.encode(file.content);
|
|
57
|
+
const checksum = crc32(dataBytes);
|
|
58
|
+
const localHeader = [];
|
|
59
|
+
writeUint32(localHeader, 0x04034b50);
|
|
60
|
+
writeUint16(localHeader, 20);
|
|
61
|
+
writeUint16(localHeader, 0x0800);
|
|
62
|
+
writeUint16(localHeader, 0);
|
|
63
|
+
writeUint16(localHeader, timestamp.time);
|
|
64
|
+
writeUint16(localHeader, timestamp.date);
|
|
65
|
+
writeUint32(localHeader, checksum);
|
|
66
|
+
writeUint32(localHeader, dataBytes.length);
|
|
67
|
+
writeUint32(localHeader, dataBytes.length);
|
|
68
|
+
writeUint16(localHeader, nameBytes.length);
|
|
69
|
+
writeUint16(localHeader, 0);
|
|
70
|
+
localParts.push(new Uint8Array(localHeader), nameBytes, dataBytes);
|
|
71
|
+
|
|
72
|
+
const centralHeader = [];
|
|
73
|
+
writeUint32(centralHeader, 0x02014b50);
|
|
74
|
+
writeUint16(centralHeader, 20);
|
|
75
|
+
writeUint16(centralHeader, 20);
|
|
76
|
+
writeUint16(centralHeader, 0x0800);
|
|
77
|
+
writeUint16(centralHeader, 0);
|
|
78
|
+
writeUint16(centralHeader, timestamp.time);
|
|
79
|
+
writeUint16(centralHeader, timestamp.date);
|
|
80
|
+
writeUint32(centralHeader, checksum);
|
|
81
|
+
writeUint32(centralHeader, dataBytes.length);
|
|
82
|
+
writeUint32(centralHeader, dataBytes.length);
|
|
83
|
+
writeUint16(centralHeader, nameBytes.length);
|
|
84
|
+
writeUint16(centralHeader, 0);
|
|
85
|
+
writeUint16(centralHeader, 0);
|
|
86
|
+
writeUint16(centralHeader, 0);
|
|
87
|
+
writeUint16(centralHeader, 0);
|
|
88
|
+
writeUint32(centralHeader, 0);
|
|
89
|
+
writeUint32(centralHeader, offset);
|
|
90
|
+
centralParts.push(new Uint8Array(centralHeader), nameBytes);
|
|
91
|
+
|
|
92
|
+
offset += localHeader.length + nameBytes.length + dataBytes.length;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const centralOffset = offset;
|
|
96
|
+
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
|
|
97
|
+
const endHeader = [];
|
|
98
|
+
writeUint32(endHeader, 0x06054b50);
|
|
99
|
+
writeUint16(endHeader, 0);
|
|
100
|
+
writeUint16(endHeader, 0);
|
|
101
|
+
writeUint16(endHeader, files.length);
|
|
102
|
+
writeUint16(endHeader, files.length);
|
|
103
|
+
writeUint32(endHeader, centralSize);
|
|
104
|
+
writeUint32(endHeader, centralOffset);
|
|
105
|
+
writeUint16(endHeader, 0);
|
|
106
|
+
|
|
107
|
+
return new Blob([...localParts, ...centralParts, new Uint8Array(endHeader)], { type: "application/zip" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function downloadBlob(filename, blob) {
|
|
111
|
+
const url = URL.createObjectURL(blob);
|
|
112
|
+
const anchor = document.createElement("a");
|
|
113
|
+
anchor.href = url;
|
|
114
|
+
anchor.download = filename;
|
|
115
|
+
document.body.append(anchor);
|
|
116
|
+
anchor.click();
|
|
117
|
+
anchor.remove();
|
|
118
|
+
URL.revokeObjectURL(url);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function safeExportName(board, usedNames) {
|
|
122
|
+
const base = String(board?.slug || board?.name || board?.id || "board")
|
|
123
|
+
.trim()
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
126
|
+
.replace(/^-+|-+$/g, "") || "board";
|
|
127
|
+
let name = `${base}.orbit.json`;
|
|
128
|
+
let suffix = 2;
|
|
129
|
+
while (usedNames.has(name)) {
|
|
130
|
+
name = `${base}-${suffix}.orbit.json`;
|
|
131
|
+
suffix += 1;
|
|
132
|
+
}
|
|
133
|
+
usedNames.add(name);
|
|
134
|
+
return name;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Export every board the actor can see as a single ZIP of `.orbit.json`
|
|
138
|
+
* snapshots. Resolves `true` once the download starts, `false` if there are
|
|
139
|
+
* no boards. Throws on a failed export request so callers can surface it. */
|
|
140
|
+
export async function exportAllBoards({ includeAttachments = false } = {}) {
|
|
141
|
+
const boards = state.data?.boards || [];
|
|
142
|
+
if (!boards.length) {
|
|
143
|
+
toast.error("No boards to export");
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const suffix = includeAttachments ? "?include_attachments=true" : "";
|
|
147
|
+
const usedNames = new Set();
|
|
148
|
+
const files = [];
|
|
149
|
+
for (const board of boards) {
|
|
150
|
+
const snapshot = await api(`/api/boards/${encodeURIComponent(board.id)}/export${suffix}`);
|
|
151
|
+
files.push({
|
|
152
|
+
name: safeExportName(board, usedNames),
|
|
153
|
+
content: JSON.stringify(snapshot, null, 2)
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const archive = createZip(files);
|
|
157
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
158
|
+
downloadBlob(`orbit-boards-${date}${includeAttachments ? ".with-images" : ""}.zip`, archive);
|
|
159
|
+
toast.success(`Exported ${files.length} board${files.length === 1 ? "" : "s"}`);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
package/public/js/board-menu.js
CHANGED
|
@@ -11,11 +11,15 @@ import { closeCreateFlyout } from "./create-card.js";
|
|
|
11
11
|
import { toast } from "./toast.js";
|
|
12
12
|
import { load } from "./app.js";
|
|
13
13
|
import { features } from "./config.js";
|
|
14
|
+
import { exportAllBoards } from "./board-export.js";
|
|
15
|
+
|
|
16
|
+
let boardFlyoutView = "list";
|
|
14
17
|
|
|
15
18
|
export function closeBoardFlyout() {
|
|
16
19
|
if (!boardFlyout) return;
|
|
17
20
|
boardFlyout.hidden = true;
|
|
18
21
|
boardFlyout.innerHTML = "";
|
|
22
|
+
boardFlyoutView = "list";
|
|
19
23
|
boardMenuBtn?.setAttribute("aria-expanded", "false");
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -32,8 +36,8 @@ const boardChipIcon = `
|
|
|
32
36
|
fill="none"
|
|
33
37
|
aria-hidden="true"
|
|
34
38
|
>
|
|
35
|
-
<rect x="4" y="
|
|
36
|
-
<rect x="14" y="
|
|
39
|
+
<rect x="4" y="2" width="6" height="20" rx="0.75" stroke="currentColor" stroke-width="2" />
|
|
40
|
+
<rect x="14" y="2" width="6" height="20" rx="0.75" stroke="currentColor" stroke-width="2" />
|
|
37
41
|
</svg>`;
|
|
38
42
|
|
|
39
43
|
export function updateTopbarChips() {
|
|
@@ -46,6 +50,88 @@ export function updateTopbarChips() {
|
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
async function inflateZipEntry(bytes) {
|
|
54
|
+
if (typeof DecompressionStream === "undefined") {
|
|
55
|
+
throw new Error("Compressed ZIP entries are not supported in this browser.");
|
|
56
|
+
}
|
|
57
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
|
58
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseImportSnapshotText(text, filename = "snapshot") {
|
|
62
|
+
const trimmed = String(text || "").trimStart();
|
|
63
|
+
if (trimmed.startsWith("<")) throw new Error(`${filename} looks like HTML instead of JSON.`);
|
|
64
|
+
try {
|
|
65
|
+
const snapshot = JSON.parse(text);
|
|
66
|
+
if (!snapshot?.board) throw new Error("snapshot_missing_board");
|
|
67
|
+
return snapshot;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`${filename}: ${error?.message || "invalid JSON"}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readZipSnapshots(file) {
|
|
74
|
+
const buffer = await file.arrayBuffer();
|
|
75
|
+
const view = new DataView(buffer);
|
|
76
|
+
const bytes = new Uint8Array(buffer);
|
|
77
|
+
const decoder = new TextDecoder();
|
|
78
|
+
const snapshots = [];
|
|
79
|
+
let offset = 0;
|
|
80
|
+
|
|
81
|
+
while (offset + 30 <= bytes.length) {
|
|
82
|
+
const signature = view.getUint32(offset, true);
|
|
83
|
+
if (signature === 0x02014b50 || signature === 0x06054b50) break;
|
|
84
|
+
if (signature !== 0x04034b50) throw new Error("Invalid ZIP archive.");
|
|
85
|
+
|
|
86
|
+
const flags = view.getUint16(offset + 6, true);
|
|
87
|
+
const method = view.getUint16(offset + 8, true);
|
|
88
|
+
const compressedSize = view.getUint32(offset + 18, true);
|
|
89
|
+
const filenameLength = view.getUint16(offset + 26, true);
|
|
90
|
+
const extraLength = view.getUint16(offset + 28, true);
|
|
91
|
+
const nameStart = offset + 30;
|
|
92
|
+
const dataStart = nameStart + filenameLength + extraLength;
|
|
93
|
+
const dataEnd = dataStart + compressedSize;
|
|
94
|
+
if (flags & 0x0008) throw new Error("ZIP entries with data descriptors are not supported.");
|
|
95
|
+
if (dataEnd > bytes.length) throw new Error("Invalid ZIP archive.");
|
|
96
|
+
|
|
97
|
+
const name = decoder.decode(bytes.slice(nameStart, nameStart + filenameLength));
|
|
98
|
+
if (!name.endsWith("/")) {
|
|
99
|
+
if (!name.toLowerCase().endsWith(".orbit.json")) {
|
|
100
|
+
throw new Error("ZIP imports may contain only .orbit.json files.");
|
|
101
|
+
}
|
|
102
|
+
const compressed = bytes.slice(dataStart, dataEnd);
|
|
103
|
+
const data = method === 0 ? compressed : method === 8 ? await inflateZipEntry(compressed) : null;
|
|
104
|
+
if (!data) throw new Error("Unsupported ZIP compression method.");
|
|
105
|
+
snapshots.push(parseImportSnapshotText(decoder.decode(data), name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
offset = dataEnd;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!snapshots.length) throw new Error("ZIP archive did not contain any .orbit.json snapshots.");
|
|
112
|
+
return snapshots;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readImportSnapshots(file) {
|
|
116
|
+
const name = String(file?.name || "").toLowerCase();
|
|
117
|
+
if (name.endsWith(".zip")) return readZipSnapshots(file);
|
|
118
|
+
if (name.endsWith(".orbit.json")) return [parseImportSnapshotText(await file.text(), file.name)];
|
|
119
|
+
throw new Error("Choose a .zip archive or a single .orbit.json snapshot.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderBoardFlyoutHeader() {
|
|
123
|
+
const showListButton = boardFlyoutView !== "list";
|
|
124
|
+
const newLabel = showListButton ? "List" : "New";
|
|
125
|
+
return `
|
|
126
|
+
<div class="menu-flyout-head menu-flyout-head--actions">
|
|
127
|
+
<span>Board</span>
|
|
128
|
+
<span class="menu-flyout-actions">
|
|
129
|
+
${features.multiBoard ? `<button type="button" data-variant="ghost" data-board-view="${showListButton ? "list" : "new"}">${newLabel}</button>` : ""}
|
|
130
|
+
<button type="button" data-variant="ghost" data-board-view="export">Export</button>
|
|
131
|
+
</span>
|
|
132
|
+
</div>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
49
135
|
function renderBoardFlyoutInner() {
|
|
50
136
|
const boards = state.data.boards || [];
|
|
51
137
|
const rows = boards
|
|
@@ -60,10 +146,10 @@ function renderBoardFlyoutInner() {
|
|
|
60
146
|
const listBody =
|
|
61
147
|
rows ||
|
|
62
148
|
`<p class="menu-flyout-hint menu-flyout-hint--empty">No boards yet. Create one below if you can.</p>`;
|
|
63
|
-
const
|
|
64
|
-
|
|
149
|
+
const listBlock = `<div class="menu-flyout-list">${listBody}</div>`;
|
|
150
|
+
const createBlock = `
|
|
65
151
|
<div class="menu-flyout-section">
|
|
66
|
-
<h3 class="menu-flyout-heading">New
|
|
152
|
+
<h3 class="menu-flyout-heading">New Board</h3>
|
|
67
153
|
<form id="createBoardForm" class="menu-flyout-form">
|
|
68
154
|
<input name="name" type="text" required placeholder="Board name" autocomplete="off" />
|
|
69
155
|
<div class="folder-picker-field">
|
|
@@ -78,20 +164,57 @@ function renderBoardFlyoutInner() {
|
|
|
78
164
|
</label>
|
|
79
165
|
</div>
|
|
80
166
|
<input name="slug" type="text" placeholder="Slug (optional)" autocomplete="off" />
|
|
81
|
-
<input name="default_branch" type="text" placeholder="Default branch (optional)" autocomplete="off" />
|
|
82
|
-
<input name="repo_url" type="text" placeholder="Repo URL (optional)" autocomplete="off" />
|
|
83
167
|
<button type="submit">Create board</button>
|
|
84
168
|
</form>
|
|
85
|
-
</div
|
|
86
|
-
|
|
169
|
+
</div>`;
|
|
170
|
+
const exportBlock = `
|
|
171
|
+
<div class="menu-flyout-section menu-flyout-section--export">
|
|
172
|
+
<p class="menu-flyout-copy">Export every board as Orbit snapshots in a ZIP archive.</p>
|
|
173
|
+
${features.attachments ? `
|
|
174
|
+
<label class="orbit-check menu-flyout-toggle">
|
|
175
|
+
<input type="checkbox" id="exportAllBoardImages" />
|
|
176
|
+
<span class="orbit-check-box" aria-hidden="true">
|
|
177
|
+
<svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
|
|
178
|
+
</span>
|
|
179
|
+
<span class="menu-flyout-toggle-text">Include attached images</span>
|
|
180
|
+
</label>
|
|
181
|
+
` : ""}
|
|
182
|
+
<div class="menu-flyout-actions-row">
|
|
183
|
+
<button type="button" id="exportAllBoards">Export All</button>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="menu-flyout-divider" aria-hidden="true"></div>
|
|
186
|
+
<section class="menu-flyout-danger-section" aria-label="Import All">
|
|
187
|
+
<h3 class="menu-flyout-danger-heading">Import All</h3>
|
|
188
|
+
<p class="menu-flyout-danger-copy">Imports fully overwrite each matching board. Snapshots without a matching board create new boards.</p>
|
|
189
|
+
<div class="menu-flyout-actions-row">
|
|
190
|
+
<input type="file" id="importAllBoardsFile" class="import-file-input" accept=".zip,.orbit.json,application/zip" />
|
|
191
|
+
<button type="button" data-arc="danger" id="importAllBoards">Import All</button>
|
|
192
|
+
</div>
|
|
193
|
+
</section>
|
|
194
|
+
</div>`;
|
|
195
|
+
const body = boardFlyoutView === "export" ? exportBlock : boardFlyoutView === "new" && features.multiBoard ? createBlock : listBlock;
|
|
87
196
|
return `
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
${createBlock}
|
|
197
|
+
${renderBoardFlyoutHeader()}
|
|
198
|
+
${body}
|
|
91
199
|
`;
|
|
92
200
|
}
|
|
93
201
|
|
|
202
|
+
function rerenderBoardFlyout(view) {
|
|
203
|
+
boardFlyoutView = view;
|
|
204
|
+
boardFlyout.innerHTML = renderBoardFlyoutInner();
|
|
205
|
+
wireBoardFlyout();
|
|
206
|
+
if (view === "new") boardFlyout.querySelector('input[name="name"]')?.focus();
|
|
207
|
+
}
|
|
208
|
+
|
|
94
209
|
function wireBoardFlyout() {
|
|
210
|
+
boardFlyout.querySelectorAll("[data-board-view]").forEach((button) => {
|
|
211
|
+
button.addEventListener("click", (event) => {
|
|
212
|
+
event.stopPropagation();
|
|
213
|
+
const view = button.getAttribute("data-board-view") || "list";
|
|
214
|
+
rerenderBoardFlyout(view);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
95
218
|
boardFlyout.querySelectorAll("[data-pick-board]").forEach((btn) => {
|
|
96
219
|
btn.addEventListener("click", async (event) => {
|
|
97
220
|
event.stopPropagation();
|
|
@@ -137,8 +260,6 @@ function wireBoardFlyout() {
|
|
|
137
260
|
event.preventDefault();
|
|
138
261
|
const fd = new FormData(form);
|
|
139
262
|
const slug = String(fd.get("slug") || "").trim();
|
|
140
|
-
const branch = String(fd.get("default_branch") || "").trim();
|
|
141
|
-
const repo = String(fd.get("repo_url") || "").trim();
|
|
142
263
|
const repoPath = String(fd.get("repo_path") || "").trim();
|
|
143
264
|
// Folder is optional. Only forward the helper-files choice when a folder
|
|
144
265
|
// is set; the server ignores it otherwise.
|
|
@@ -147,9 +268,7 @@ function wireBoardFlyout() {
|
|
|
147
268
|
body: {
|
|
148
269
|
name: fd.get("name"),
|
|
149
270
|
...(repoPath ? { repo_path: repoPath, manage_helper_files: fd.get("manage_helper_files") === "on" } : {}),
|
|
150
|
-
...(slug ? { slug } : {})
|
|
151
|
-
...(branch ? { default_branch: branch } : {}),
|
|
152
|
-
...(repo ? { repo_url: repo } : {})
|
|
271
|
+
...(slug ? { slug } : {})
|
|
153
272
|
}
|
|
154
273
|
});
|
|
155
274
|
closeBoardFlyout();
|
|
@@ -161,10 +280,93 @@ function wireBoardFlyout() {
|
|
|
161
280
|
toast.success("Board created");
|
|
162
281
|
});
|
|
163
282
|
}
|
|
283
|
+
|
|
284
|
+
boardFlyout.querySelector("#exportAllBoards")?.addEventListener("click", async (event) => {
|
|
285
|
+
const button = event.currentTarget;
|
|
286
|
+
const includeAttachments = Boolean(boardFlyout.querySelector("#exportAllBoardImages")?.checked);
|
|
287
|
+
button.disabled = true;
|
|
288
|
+
const originalText = button.textContent;
|
|
289
|
+
button.textContent = "Exporting...";
|
|
290
|
+
try {
|
|
291
|
+
await exportAllBoards({ includeAttachments });
|
|
292
|
+
} catch (error) {
|
|
293
|
+
toast.error(`Export failed: ${error?.message || "request failed"}`);
|
|
294
|
+
} finally {
|
|
295
|
+
button.disabled = false;
|
|
296
|
+
button.textContent = originalText;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const importAllBoardsFile = boardFlyout.querySelector("#importAllBoardsFile");
|
|
301
|
+
boardFlyout.querySelector("#importAllBoards")?.addEventListener("click", () => {
|
|
302
|
+
importAllBoardsFile?.click();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
importAllBoardsFile?.addEventListener("change", async (event) => {
|
|
306
|
+
const input = event.currentTarget;
|
|
307
|
+
const file = input.files?.[0];
|
|
308
|
+
if (!file) return;
|
|
309
|
+
const importButton = boardFlyout.querySelector("#importAllBoards");
|
|
310
|
+
const originalText = importButton?.textContent;
|
|
311
|
+
if (importButton) {
|
|
312
|
+
importButton.disabled = true;
|
|
313
|
+
importButton.textContent = "Importing...";
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const snapshots = await readImportSnapshots(file);
|
|
317
|
+
const boardsById = new Map((state.data.boards || []).map((board) => [board.id, board]));
|
|
318
|
+
const boardsBySlug = new Map((state.data.boards || []).map((board) => [board.slug, board]));
|
|
319
|
+
let replaced = 0;
|
|
320
|
+
let created = 0;
|
|
321
|
+
|
|
322
|
+
for (const snapshot of snapshots) {
|
|
323
|
+
const incomingBoard = snapshot.board || {};
|
|
324
|
+
const match = boardsBySlug.get(incomingBoard.slug) || boardsById.get(incomingBoard.id);
|
|
325
|
+
if (match) {
|
|
326
|
+
await api("/api/admin/import", {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: {
|
|
329
|
+
board_id: match.id,
|
|
330
|
+
replace_existing: true,
|
|
331
|
+
snapshot
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
replaced += 1;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = await api("/api/admin/import", {
|
|
339
|
+
method: "POST",
|
|
340
|
+
body: {
|
|
341
|
+
...(state.boardId ? { board_id: state.boardId } : {}),
|
|
342
|
+
create_new: true,
|
|
343
|
+
snapshot
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
created += 1;
|
|
347
|
+
if (result.imported_board_id) boardsById.set(result.imported_board_id, { id: result.imported_board_id, slug: result.imported_board_slug });
|
|
348
|
+
if (result.imported_board_slug) boardsBySlug.set(result.imported_board_slug, { id: result.imported_board_id, slug: result.imported_board_slug });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await load();
|
|
352
|
+
toast.success(`Imported ${snapshots.length} board${snapshots.length === 1 ? "" : "s"} (${replaced} replaced, ${created} created)`);
|
|
353
|
+
closeBoardFlyout();
|
|
354
|
+
await navigate({ boardId: state.boardId, view: "board" }, { replace: true });
|
|
355
|
+
} catch (error) {
|
|
356
|
+
toast.error(`Import failed: ${error?.message || "invalid import file"}`);
|
|
357
|
+
} finally {
|
|
358
|
+
input.value = "";
|
|
359
|
+
if (importButton) {
|
|
360
|
+
importButton.disabled = false;
|
|
361
|
+
importButton.textContent = originalText;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
164
365
|
}
|
|
165
366
|
|
|
166
367
|
export function openBoardFlyout() {
|
|
167
368
|
closeCreateFlyout();
|
|
369
|
+
if (boardFlyoutView === "new" && !features.multiBoard) boardFlyoutView = "list";
|
|
168
370
|
boardFlyout.innerHTML = renderBoardFlyoutInner();
|
|
169
371
|
boardFlyout.hidden = false;
|
|
170
372
|
boardMenuBtn?.setAttribute("aria-expanded", "true");
|
|
@@ -133,7 +133,7 @@ export function createCardActionHandlers({ apiClient = api, navigator = null, co
|
|
|
133
133
|
async archive(ticket) {
|
|
134
134
|
if (typeof confirmer === "function" && !confirmer(`Archive "${ticket.title || "card"}"? You can restore it from Settings → Card Archive.`)) return false;
|
|
135
135
|
await apiClient(withBoardQuery(`/api/tickets/${encodeURIComponent(ticket.id)}/archive`), { method: "POST" });
|
|
136
|
-
toast.
|
|
136
|
+
toast.removed(`Archived: ${ticket.title || "card"}`);
|
|
137
137
|
return true;
|
|
138
138
|
}
|
|
139
139
|
};
|
package/public/js/create-card.js
CHANGED
|
@@ -8,12 +8,15 @@ import {
|
|
|
8
8
|
createFlyoutBackdrop
|
|
9
9
|
} from "./dom.js";
|
|
10
10
|
import { state, visibleStatesForProject, ticketsForProject } from "./state.js";
|
|
11
|
-
import { escapeHtml
|
|
11
|
+
import { escapeHtml } from "./format.js";
|
|
12
12
|
import { api } from "./api.js";
|
|
13
13
|
import { syncUrlFromState } from "./router.js";
|
|
14
|
+
import { handleTextareaIndentationKeydown } from "./text-editing.js";
|
|
14
15
|
import { closeDrawer } from "./drawer.js";
|
|
15
16
|
import { closeMenuFlyouts } from "./board-menu.js";
|
|
16
17
|
import { load } from "./app.js";
|
|
18
|
+
import { closeIconSvg } from "./icons.js";
|
|
19
|
+
import { attachTicketTypeahead, ticketCanonicalLabel } from "./ticket-typeahead.js";
|
|
17
20
|
|
|
18
21
|
/** Clears any pending hide timeout when reopening the create flyout quickly. */
|
|
19
22
|
let createFlyoutHideTimer = 0;
|
|
@@ -66,25 +69,21 @@ function renderCreateForm(preselectedLaneId) {
|
|
|
66
69
|
`<option value="${escapeHtml(item.id)}" ${item.id === effectiveStateId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
|
|
67
70
|
)
|
|
68
71
|
.join("");
|
|
69
|
-
const parentOptions = ticketsForProject()
|
|
70
|
-
.filter((ticket) => ticket.type === "epic")
|
|
71
|
-
.map((ticket) => `<option value="${escapeHtml(ticket.id)}">${escapeHtml(ticketLabel(ticket))}</option>`)
|
|
72
|
-
.join("");
|
|
73
72
|
return `
|
|
74
73
|
<div class="create-flyout-head">
|
|
75
74
|
<h2>New card</h2>
|
|
76
|
-
<button type="button" class="create-flyout-close ghost" id="createFlyoutClose" aria-label="Close"
|
|
75
|
+
<button type="button" class="create-flyout-close ghost" id="createFlyoutClose" aria-label="Close">${closeIconSvg}</button>
|
|
77
76
|
</div>
|
|
78
77
|
<form id="createTicketForm" class="create-flyout-form">
|
|
79
78
|
<input id="createTicketTitle" name="title" type="text" placeholder="Title — what is this card?" autocomplete="off" required />
|
|
80
79
|
<textarea id="createTicketDescription" name="description" placeholder="Description (optional)"></textarea>
|
|
81
|
-
<div class="
|
|
82
|
-
<label>
|
|
83
|
-
|
|
80
|
+
<div class="control-grid">
|
|
81
|
+
<label class="control-field">
|
|
82
|
+
<span class="control-field-label">LANE</span>
|
|
84
83
|
<select id="createTicketState" name="state" class="select-chevron-field" aria-label="Lane">${stateOptions}</select>
|
|
85
84
|
</label>
|
|
86
|
-
<label>
|
|
87
|
-
|
|
85
|
+
<label class="control-field">
|
|
86
|
+
<span class="control-field-label">TYPE</span>
|
|
88
87
|
<select id="createTicketType" name="type" class="select-chevron-field" aria-label="Ticket type">
|
|
89
88
|
<option value="epic">Epic</option>
|
|
90
89
|
<option value="feature">Feature</option>
|
|
@@ -92,23 +91,21 @@ function renderCreateForm(preselectedLaneId) {
|
|
|
92
91
|
<option value="bug">Bug</option>
|
|
93
92
|
</select>
|
|
94
93
|
</label>
|
|
95
|
-
<label>
|
|
96
|
-
|
|
94
|
+
<label class="control-field">
|
|
95
|
+
<span class="control-field-label">PRIORITY</span>
|
|
97
96
|
<select id="createTicketPriority" name="priority" class="select-chevron-field" aria-label="Priority">
|
|
98
|
-
<option value="0">MAYBE</option>
|
|
99
|
-
<option value="1">LOW</option>
|
|
100
|
-
<option value="2" selected>MED</option>
|
|
101
|
-
<option value="3">HIGH</option>
|
|
102
97
|
<option value="4">URGENT</option>
|
|
98
|
+
<option value="3">HIGH</option>
|
|
99
|
+
<option value="2" selected>MED</option>
|
|
100
|
+
<option value="1">LOW</option>
|
|
101
|
+
<option value="0">MAYBE</option>
|
|
103
102
|
</select>
|
|
104
103
|
</label>
|
|
105
104
|
</div>
|
|
106
|
-
<label>
|
|
107
|
-
Parent epic
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
${parentOptions}
|
|
111
|
-
</select>
|
|
105
|
+
<label class="control-field" id="createTicketParentField">
|
|
106
|
+
<span class="control-field-label">Parent epic</span>
|
|
107
|
+
<input id="createTicketParent" name="parent_target" type="text" placeholder="Search epic… (optional)" autocomplete="off" aria-label="Parent epic" />
|
|
108
|
+
<input type="hidden" name="parent_ticket_id" id="createTicketParentId" value="" />
|
|
112
109
|
</label>
|
|
113
110
|
<input id="createTicketLabels" name="labels" type="text" placeholder="Labels (comma-separated)" />
|
|
114
111
|
<button type="submit">Create card</button>
|
|
@@ -119,6 +116,46 @@ function renderCreateForm(preselectedLaneId) {
|
|
|
119
116
|
function wireCreateForm() {
|
|
120
117
|
const form = $("#createTicketForm");
|
|
121
118
|
if (!form) return;
|
|
119
|
+
const description = form.querySelector("#createTicketDescription");
|
|
120
|
+
description?.addEventListener("keydown", (event) => {
|
|
121
|
+
handleTextareaIndentationKeydown(event, description);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Parent epic is chosen via the shared styled typeahead. The visible field
|
|
125
|
+
// holds the "#12 — Title" label; the resolved epic id lives in a hidden input
|
|
126
|
+
// that the submit reads. Typing (or clearing) drops a stale id so only an
|
|
127
|
+
// explicit pick is submitted.
|
|
128
|
+
const parentInput = form.querySelector("#createTicketParent");
|
|
129
|
+
const parentId = form.querySelector("#createTicketParentId");
|
|
130
|
+
parentInput.addEventListener("input", () => {
|
|
131
|
+
parentId.value = "";
|
|
132
|
+
});
|
|
133
|
+
attachTicketTypeahead(
|
|
134
|
+
parentInput,
|
|
135
|
+
() => ticketsForProject().filter((ticket) => ticket.type === "epic"),
|
|
136
|
+
{
|
|
137
|
+
onSelect: (ticket) => {
|
|
138
|
+
parentId.value = ticket.id;
|
|
139
|
+
parentInput.value = ticketCanonicalLabel(ticket);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Epics cannot have a parent (server rejects with `epic_cannot_have_parent`),
|
|
145
|
+
// so hide the Parent epic field whenever Type is Epic.
|
|
146
|
+
const typeSelect = form.querySelector("#createTicketType");
|
|
147
|
+
const parentField = form.querySelector("#createTicketParentField");
|
|
148
|
+
const syncParentVisibility = () => {
|
|
149
|
+
const isEpic = typeSelect.value === "epic";
|
|
150
|
+
parentField.hidden = isEpic;
|
|
151
|
+
if (isEpic) {
|
|
152
|
+
parentInput.value = "";
|
|
153
|
+
parentId.value = "";
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
typeSelect.addEventListener("change", syncParentVisibility);
|
|
157
|
+
syncParentVisibility();
|
|
158
|
+
|
|
122
159
|
form.addEventListener("submit", createTicketFromDrawer);
|
|
123
160
|
}
|
|
124
161
|
|