superwiki 0.1.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/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +17 -0
- package/.codex-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/bin/superwiki.mjs +116 -0
- package/commands/config.md +5 -0
- package/commands/explain.md +5 -0
- package/commands/implement.md +5 -0
- package/commands/ingest.md +5 -0
- package/commands/init.md +5 -0
- package/commands/lint.md +5 -0
- package/commands/migrate.md +5 -0
- package/commands/plan.md +5 -0
- package/commands/triage.md +5 -0
- package/commands/visualize.md +5 -0
- package/install.sh +27 -0
- package/package.json +45 -0
- package/skills/sw-config/SKILL.md +39 -0
- package/skills/sw-config/assets/implementer.md +15 -0
- package/skills/sw-config/assets/planner.md +17 -0
- package/skills/sw-config/scripts/config.mjs +104 -0
- package/skills/sw-explain/SKILL.md +30 -0
- package/skills/sw-implement/SKILL.md +46 -0
- package/skills/sw-ingest/SKILL.md +43 -0
- package/skills/sw-init/SKILL.md +52 -0
- package/skills/sw-init/assets/agents-block.md +29 -0
- package/skills/sw-init/assets/sw.mjs +523 -0
- package/skills/sw-init/assets/templates/page.md +18 -0
- package/skills/sw-init/assets/templates/plan.md +25 -0
- package/skills/sw-init/assets/templates/task.md +33 -0
- package/skills/sw-init/assets/viewer.html +1660 -0
- package/skills/sw-init/scripts/init.mjs +118 -0
- package/skills/sw-lint/SKILL.md +61 -0
- package/skills/sw-migrate/SKILL.md +61 -0
- package/skills/sw-migrate/scripts/migrate.mjs +225 -0
- package/skills/sw-plan/SKILL.md +46 -0
- package/skills/sw-triage/SKILL.md +42 -0
- package/skills/sw-visualize/SKILL.md +28 -0
|
@@ -0,0 +1,1660 @@
|
|
|
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">
|
|
6
|
+
<title>Superwiki viewer</title>
|
|
7
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js" integrity="sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f6f6f3; --surface: #ffffff; --surface-2: #efeee9; --border: #e2e0d9; --border-strong: #cfccc2;
|
|
11
|
+
--text: #1c1c1a; --muted: #6a6963; --faint: #9a988f; --accent: #3355d1; --accent-bg: #e8edfc;
|
|
12
|
+
--done: #2b8a3e; --done-bg: #e3f3e6; --prog: #d9480f; --prog-bg: #fff0e6;
|
|
13
|
+
--ready: #1864ab; --ready-bg: #e3effb; --blocked: #6a6963; --blocked-bg: #f0efea;
|
|
14
|
+
--cancel: #a3a19a; --cancel-bg: #f3f2ee; --warn: #b7791f; --warn-bg: #fdf3dc;
|
|
15
|
+
--a-0: #7048e8; --a-1: #0c8599; --a-2: #c2255c; --a-3: #b06a00; --a-4: #3b5bdb; --a-5: #2b8a3e; --a-6: #9c36b5; --a-7: #5c677d;
|
|
16
|
+
--shadow: 0 8px 30px rgba(20, 20, 15, .12);
|
|
17
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
18
|
+
}
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root:not([data-theme="light"]) {
|
|
21
|
+
--bg: #151514; --surface: #1d1d1b; --surface-2: #262623; --border: #33322e; --border-strong: #46453f;
|
|
22
|
+
--text: #ecebe6; --muted: #a3a199; --faint: #75736b; --accent: #8ea6ff; --accent-bg: #222a45;
|
|
23
|
+
--done: #69db7c; --done-bg: #1b3022; --prog: #ffa94d; --prog-bg: #3a2614;
|
|
24
|
+
--ready: #74c0fc; --ready-bg: #182c40; --blocked: #a3a199; --blocked-bg: #262623;
|
|
25
|
+
--cancel: #75736b; --cancel-bg: #22221f; --warn: #ffd43b; --warn-bg: #3a3214;
|
|
26
|
+
--a-0: #b197fc; --a-1: #3bc9db; --a-2: #f783ac; --a-3: #fcc419; --a-4: #91a7ff; --a-5: #69db7c; --a-6: #da77f2; --a-7: #adb5bd;
|
|
27
|
+
--shadow: 0 8px 30px rgba(0, 0, 0, .5);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
:root[data-theme="dark"] {
|
|
31
|
+
--bg: #151514; --surface: #1d1d1b; --surface-2: #262623; --border: #33322e; --border-strong: #46453f;
|
|
32
|
+
--text: #ecebe6; --muted: #a3a199; --faint: #75736b; --accent: #8ea6ff; --accent-bg: #222a45;
|
|
33
|
+
--done: #69db7c; --done-bg: #1b3022; --prog: #ffa94d; --prog-bg: #3a2614;
|
|
34
|
+
--ready: #74c0fc; --ready-bg: #182c40; --blocked: #a3a199; --blocked-bg: #262623;
|
|
35
|
+
--cancel: #75736b; --cancel-bg: #22221f; --warn: #ffd43b; --warn-bg: #3a3214;
|
|
36
|
+
--a-0: #b197fc; --a-1: #3bc9db; --a-2: #f783ac; --a-3: #fcc419; --a-4: #91a7ff; --a-5: #69db7c; --a-6: #da77f2; --a-7: #adb5bd;
|
|
37
|
+
--shadow: 0 8px 30px rgba(0, 0, 0, .5);
|
|
38
|
+
}
|
|
39
|
+
* { box-sizing: border-box; }
|
|
40
|
+
html, body { margin: 0; }
|
|
41
|
+
body { background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
42
|
+
button, input, select { font: inherit; color: inherit; }
|
|
43
|
+
a { color: var(--accent); }
|
|
44
|
+
code { font-family: var(--mono); font-size: .9em; background: var(--surface-2); padding: 0 .3em; border-radius: 4px; }
|
|
45
|
+
.mono { font-family: var(--mono); }
|
|
46
|
+
.muted { color: var(--muted); }
|
|
47
|
+
[hidden] { display: none !important; }
|
|
48
|
+
|
|
49
|
+
/* header */
|
|
50
|
+
.top { position: sticky; top: 0; z-index: 20; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); }
|
|
51
|
+
.top-in { max-width: 1500px; margin: 0 auto; padding: 10px 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
52
|
+
.brand { font-weight: 700; letter-spacing: -.01em; font-size: 16px; }
|
|
53
|
+
.brand small { font-weight: 500; color: var(--muted); margin-left: 6px; }
|
|
54
|
+
.spacer { flex: 1; }
|
|
55
|
+
.meta { color: var(--muted); font-size: 12.5px; }
|
|
56
|
+
.btn { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: inherit; }
|
|
57
|
+
.btn:hover { border-color: var(--text); }
|
|
58
|
+
.btn.primary { background: var(--text); color: var(--bg); border-color: var(--text); font-weight: 600; }
|
|
59
|
+
.btn.primary:hover { opacity: .9; }
|
|
60
|
+
.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
|
61
|
+
.btn.ghost:hover { color: var(--text); }
|
|
62
|
+
.btn.warn { background: var(--warn-bg); border-color: transparent; color: var(--warn); }
|
|
63
|
+
.btn.bad { background: var(--prog-bg); border-color: transparent; color: var(--prog); }
|
|
64
|
+
.btn:disabled { opacity: .5; cursor: default; }
|
|
65
|
+
.spin { display: inline-block; animation: spin .8s linear infinite; }
|
|
66
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
67
|
+
|
|
68
|
+
main { max-width: 1500px; margin: 0 auto; padding: 16px; }
|
|
69
|
+
|
|
70
|
+
/* empty state */
|
|
71
|
+
.empty { max-width: 560px; margin: 12vh auto; text-align: center; }
|
|
72
|
+
.empty h1 { font-size: 22px; margin: 0 0 8px; }
|
|
73
|
+
.empty p { color: var(--muted); margin: 0 0 20px; }
|
|
74
|
+
.err { color: var(--prog); margin-top: 14px; }
|
|
75
|
+
|
|
76
|
+
/* summary */
|
|
77
|
+
.summary { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
|
78
|
+
.area-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; cursor: pointer; text-align: left; }
|
|
79
|
+
.area-card:hover { border-color: var(--border-strong); }
|
|
80
|
+
.area-card.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
81
|
+
.area-card .k { display: flex; align-items: baseline; gap: 6px; font-weight: 650; }
|
|
82
|
+
.area-card .k .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
83
|
+
.area-card .n { margin-left: auto; font-family: var(--mono); color: var(--muted); font-weight: 500; font-size: 12px; }
|
|
84
|
+
.bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: var(--surface-2); margin: 8px 0 6px; }
|
|
85
|
+
.bar i { display: block; height: 100%; }
|
|
86
|
+
.bar .d { background: var(--done); } .bar .p { background: var(--prog); } .bar .r { background: var(--ready); }
|
|
87
|
+
.area-card .s { font-size: 12px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
|
|
88
|
+
|
|
89
|
+
/* filters */
|
|
90
|
+
.filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
|
91
|
+
.frow { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
92
|
+
.flabel { width: 68px; flex: none; color: var(--muted); font-size: 12px; }
|
|
93
|
+
.search { flex: 1; min-width: 200px; border: 1px solid var(--border-strong); border-radius: 8px; padding: 7px 10px; background: var(--bg); color: var(--text); font: inherit; }
|
|
94
|
+
.search:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
95
|
+
.fchip { border: 1px solid var(--border); background: var(--bg); border-radius: 999px; padding: 3px 10px; font-size: 12.5px; cursor: pointer; display: inline-flex; gap: 5px; align-items: center; }
|
|
96
|
+
.fchip:hover { border-color: var(--border-strong); }
|
|
97
|
+
.fchip.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
|
98
|
+
.fchip .c { font-family: var(--mono); font-size: 11px; color: var(--faint); }
|
|
99
|
+
.fchip.on .c { color: inherit; }
|
|
100
|
+
.sel { border: 1px solid var(--border-strong); border-radius: 8px; padding: 5px 8px; background: var(--bg); color: var(--text); font: inherit; font-size: 12.5px; }
|
|
101
|
+
|
|
102
|
+
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
103
|
+
.tabs { display: inline-flex; background: var(--surface-2); border-radius: 9px; padding: 3px; }
|
|
104
|
+
.tab { border: 0; background: transparent; padding: 5px 14px; border-radius: 7px; cursor: pointer; color: var(--muted); }
|
|
105
|
+
.tab.on { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
|
|
106
|
+
.count { color: var(--muted); font-size: 12.5px; }
|
|
107
|
+
|
|
108
|
+
/* chips & badges */
|
|
109
|
+
.chip { font-family: var(--mono); font-size: 11.5px; border: 1px solid transparent; border-radius: 6px; padding: 1px 6px; cursor: pointer; white-space: nowrap; line-height: 1.5; text-decoration: none; display: inline-block; }
|
|
110
|
+
span.chip { cursor: default; }
|
|
111
|
+
.chip.soft { border-style: dashed; border-color: currentColor; background: transparent !important; }
|
|
112
|
+
.chip.pg { background: var(--accent-bg); color: var(--accent); }
|
|
113
|
+
.st-done { background: var(--done-bg); color: var(--done); }
|
|
114
|
+
.st-progress { background: var(--prog-bg); color: var(--prog); }
|
|
115
|
+
.st-ready { background: var(--ready-bg); color: var(--ready); }
|
|
116
|
+
.st-blocked { background: var(--blocked-bg); color: var(--blocked); }
|
|
117
|
+
.st-cancelled { background: var(--cancel-bg); color: var(--cancel); text-decoration: line-through; }
|
|
118
|
+
.st-unknown { background: var(--warn-bg); color: var(--warn); }
|
|
119
|
+
.badge { font-size: 11.5px; font-weight: 600; border-radius: 999px; padding: 1px 8px; white-space: nowrap; display: inline-block; }
|
|
120
|
+
.atag { font-family: var(--mono); font-size: 11px; font-weight: 700; }
|
|
121
|
+
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
122
|
+
.more { font-size: 11.5px; color: var(--muted); align-self: center; }
|
|
123
|
+
.tag { font-size: 10.5px; color: var(--muted); border: 1px solid var(--border-strong); border-radius: 5px; padding: 0 5px; margin-left: 6px; white-space: nowrap; }
|
|
124
|
+
|
|
125
|
+
/* list */
|
|
126
|
+
.tablewrap { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: auto; }
|
|
127
|
+
table { width: 100%; border-collapse: collapse; }
|
|
128
|
+
th { position: sticky; top: 0; background: var(--surface); text-align: left; font-size: 12px; font-weight: 600; color: var(--muted); padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; z-index: 1; }
|
|
129
|
+
th.sortable { cursor: pointer; } th.sortable:hover { color: var(--text); }
|
|
130
|
+
th .ar { color: var(--accent); }
|
|
131
|
+
td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
132
|
+
tr.row { cursor: pointer; }
|
|
133
|
+
tr.row:hover td { background: var(--surface-2); }
|
|
134
|
+
tr.row.sel td { background: var(--accent-bg); }
|
|
135
|
+
tr.row.flash td { animation: flash 2.4s ease-out; }
|
|
136
|
+
@keyframes flash { 0%, 30% { background: var(--warn-bg); } }
|
|
137
|
+
tr.grp td { background: var(--surface-2); font-weight: 650; font-size: 12.5px; padding: 6px 10px; position: sticky; top: 33px; }
|
|
138
|
+
tr.grp .gm { font-weight: 400; color: var(--muted); margin-left: 8px; }
|
|
139
|
+
td.id { white-space: nowrap; }
|
|
140
|
+
td.title { min-width: 240px; }
|
|
141
|
+
td.title .t { font-weight: 550; }
|
|
142
|
+
td.deps { min-width: 140px; max-width: 280px; }
|
|
143
|
+
td.num { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
|
|
144
|
+
td.dates { white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
|
|
145
|
+
.nores { padding: 40px; text-align: center; color: var(--muted); }
|
|
146
|
+
|
|
147
|
+
/* waves */
|
|
148
|
+
.waves { position: relative; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; max-height: calc(100vh - 150px); }
|
|
149
|
+
/* The board is dragged like a canvas; cards stay clickable. */
|
|
150
|
+
.waves { cursor: grab; user-select: none; -webkit-user-select: none; }
|
|
151
|
+
.waves .card { cursor: pointer; }
|
|
152
|
+
.waves.panning, .waves.panning .card { cursor: grabbing; }
|
|
153
|
+
.waves-in { position: relative; display: flex; gap: 14px; padding: 12px; align-items: flex-start; width: max-content; }
|
|
154
|
+
.waves svg { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 0; }
|
|
155
|
+
.wcol { width: 178px; flex: none; display: flex; flex-direction: column; gap: 6px; }
|
|
156
|
+
.wcol h3 { margin: 0 0 4px; font-size: 12px; color: var(--muted); font-weight: 600; display: flex; justify-content: space-between; position: sticky; top: 0; background: var(--surface); padding: 2px 0 4px; z-index: 3; }
|
|
157
|
+
.card { position: relative; z-index: 1; background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--blocked); border-radius: 8px; padding: 5px 8px; cursor: pointer; font-size: 12px; transition: opacity .15s; }
|
|
158
|
+
.card .h { display: flex; align-items: center; gap: 6px; }
|
|
159
|
+
.card .h .mono { font-weight: 700; font-size: 11.5px; }
|
|
160
|
+
.card .h .rd { margin-left: auto; font-size: 10.5px; color: var(--ready); font-weight: 600; }
|
|
161
|
+
.card .tt { color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.3; margin-top: 1px; }
|
|
162
|
+
.card.st-done { border-left-color: var(--done); background: var(--surface); color: var(--text); }
|
|
163
|
+
.card.st-progress { border-left-color: var(--prog); background: var(--surface); color: var(--text); }
|
|
164
|
+
.card.st-ready { border-left-color: var(--ready); background: var(--surface); color: var(--text); }
|
|
165
|
+
.card.st-blocked { background: var(--surface); color: var(--text); }
|
|
166
|
+
.card.st-cancelled { border-left-color: var(--cancel); background: var(--surface); color: var(--cancel); }
|
|
167
|
+
.card.dim { opacity: .22; }
|
|
168
|
+
.waves.focus .card { opacity: .18; }
|
|
169
|
+
.waves.focus .card.hl { opacity: 1; }
|
|
170
|
+
.card.hl-self { box-shadow: 0 0 0 2px var(--accent); }
|
|
171
|
+
.edge { fill: none; stroke: var(--accent); stroke-width: 1.6; opacity: .75; }
|
|
172
|
+
.edge.soft { stroke-dasharray: 4 4; opacity: .55; }
|
|
173
|
+
.legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); align-items: center; }
|
|
174
|
+
.legend span { display: inline-flex; align-items: center; gap: 5px; }
|
|
175
|
+
.legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
|
176
|
+
.hint { font-size: 12.5px; color: var(--muted); }
|
|
177
|
+
|
|
178
|
+
/* wiki */
|
|
179
|
+
.wiki { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 4px 0 8px; }
|
|
180
|
+
.wiki h3 { margin: 0; padding: 10px 14px 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); display: flex; gap: 8px; align-items: baseline; }
|
|
181
|
+
.wiki h3 .c { font-family: var(--mono); font-weight: 400; color: var(--faint); letter-spacing: 0; }
|
|
182
|
+
.wrow { display: grid; grid-template-columns: minmax(160px, 280px) 1fr auto; gap: 12px; width: 100%; text-align: left; border: 0; border-top: 1px solid var(--border); background: transparent; padding: 7px 14px; cursor: pointer; align-items: baseline; }
|
|
183
|
+
.wrow:hover { background: var(--surface-2); }
|
|
184
|
+
.wrow.sel { background: var(--accent-bg); }
|
|
185
|
+
.wrow .wn { font-weight: 600; overflow-wrap: anywhere; }
|
|
186
|
+
.wrow .ws { color: var(--muted); overflow-wrap: anywhere; }
|
|
187
|
+
.wrow .wi { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
|
|
188
|
+
|
|
189
|
+
/* drawer */
|
|
190
|
+
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(620px, 100vw); background: var(--surface); border-left: 1px solid var(--border); box-shadow: var(--shadow); z-index: 40; display: flex; flex-direction: column; transform: translateX(105%); transition: transform .2s ease, visibility 0s .2s; visibility: hidden; }
|
|
191
|
+
.drawer.open { transform: none; visibility: visible; transition: transform .2s ease, visibility 0s; }
|
|
192
|
+
.dhead { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
|
|
193
|
+
.dhead #dId { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
194
|
+
.dbody { overflow: auto; padding: 14px 16px 40px; }
|
|
195
|
+
.dtitle { font-size: 18px; font-weight: 650; margin: 0 0 10px; letter-spacing: -.01em; overflow-wrap: anywhere; }
|
|
196
|
+
.dtabs { margin-bottom: 12px; }
|
|
197
|
+
.lead { color: var(--muted); margin: -4px 0 12px; }
|
|
198
|
+
.grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
|
|
199
|
+
.grid > div { background: var(--surface-2); border-radius: 8px; padding: 6px 8px; min-width: 0; }
|
|
200
|
+
.grid label { display: block; font-size: 11px; color: var(--muted); }
|
|
201
|
+
.grid .v { font-weight: 600; font-size: 13px; overflow-wrap: anywhere; }
|
|
202
|
+
.callout { border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; font-size: 13px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
203
|
+
.callout.ok { background: var(--ready-bg); color: var(--ready); }
|
|
204
|
+
.callout.wait { background: var(--blocked-bg); }
|
|
205
|
+
.callout.soft { background: var(--warn-bg); color: var(--warn); }
|
|
206
|
+
.callout.done { background: var(--done-bg); color: var(--done); }
|
|
207
|
+
.callout.prog { background: var(--prog-bg); color: var(--prog); }
|
|
208
|
+
.callout.plain { background: var(--surface-2); }
|
|
209
|
+
.sec { margin-top: 16px; }
|
|
210
|
+
.sec h4 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); display: flex; gap: 8px; align-items: baseline; }
|
|
211
|
+
.sec h4 .muted { text-transform: none; letter-spacing: 0; font-weight: 400; }
|
|
212
|
+
.tree { font-size: 13px; }
|
|
213
|
+
.tree details > div { margin-left: 16px; border-left: 1px solid var(--border); padding-left: 8px; }
|
|
214
|
+
.tree summary { list-style: none; cursor: pointer; display: flex; gap: 6px; align-items: center; padding: 2px 0; }
|
|
215
|
+
.tree summary::-webkit-details-marker { display: none; }
|
|
216
|
+
.tree summary::before { content: "▸"; color: var(--faint); width: 10px; flex: none; }
|
|
217
|
+
.tree details[open] > summary::before { content: "▾"; }
|
|
218
|
+
.tree .leaf { display: flex; gap: 6px; align-items: center; padding: 2px 0 2px 16px; }
|
|
219
|
+
.tree .tl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
220
|
+
.tree .sm { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
|
|
221
|
+
.prose { font-size: 13.5px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
222
|
+
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 { font-size: 14px; margin: 14px 0 6px; }
|
|
223
|
+
.prose p { margin: 6px 0; }
|
|
224
|
+
.prose ul, .prose ol { padding-left: 20px; margin: 6px 0; }
|
|
225
|
+
.prose pre { background: var(--surface-2); border-radius: 8px; padding: 8px 10px; overflow: auto; font-size: 12.5px; }
|
|
226
|
+
.prose pre code { background: transparent; padding: 0; }
|
|
227
|
+
.prose pre.plain { white-space: pre-wrap; font-family: inherit; background: transparent; padding: 0; }
|
|
228
|
+
.prose table { font-size: 12.5px; margin: 8px 0; display: block; overflow: auto; }
|
|
229
|
+
.prose th, .prose td { border: 1px solid var(--border); padding: 4px 6px; position: static; }
|
|
230
|
+
.prose blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--border-strong); color: var(--muted); }
|
|
231
|
+
.prose hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
|
|
232
|
+
.wl { color: var(--accent); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; cursor: pointer; }
|
|
233
|
+
.wl.broken { color: var(--prog); text-decoration: line-through wavy; cursor: help; background: var(--prog-bg); border-radius: 4px; padding: 0 3px; }
|
|
234
|
+
.wl.att { color: var(--muted); text-decoration: none; cursor: default; font-family: var(--mono); font-size: .9em; }
|
|
235
|
+
a.rel { color: var(--muted); text-decoration: underline dashed; text-underline-offset: 2px; cursor: help; }
|
|
236
|
+
.scrim { position: fixed; inset: 0; z-index: 30; background: rgba(0,0,0,.15); }
|
|
237
|
+
|
|
238
|
+
.warnlist { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; margin-bottom: 12px; font-size: 13px; }
|
|
239
|
+
.warnlist ul { list-style: none; padding: 0; margin: 8px 0 0; max-height: 40vh; overflow: auto; }
|
|
240
|
+
.warnlist li { margin: 0; padding: 4px 0; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; border-top: 1px solid var(--border); }
|
|
241
|
+
.lv { font-size: 11px; font-weight: 700; border-radius: 5px; padding: 0 6px; text-transform: uppercase; }
|
|
242
|
+
.lv.error { background: var(--prog-bg); color: var(--prog); }
|
|
243
|
+
.lv.warn { background: var(--warn-bg); color: var(--warn); }
|
|
244
|
+
.fpath { font-family: var(--mono); font-size: 12px; background: transparent; border: 0; padding: 0; color: var(--accent); cursor: pointer; text-decoration: underline dotted; }
|
|
245
|
+
span.fpath { color: var(--muted); cursor: default; text-decoration: none; }
|
|
246
|
+
.fcode { font-family: var(--mono); font-size: 11.5px; color: var(--faint); }
|
|
247
|
+
|
|
248
|
+
.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 10px; box-shadow: var(--shadow); z-index: 60; max-width: min(640px, calc(100vw - 32px)); font-size: 13px; }
|
|
249
|
+
.toast ul { margin: 6px 0 0; padding-left: 18px; }
|
|
250
|
+
|
|
251
|
+
dialog { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); padding: 16px; width: min(520px, calc(100vw - 32px)); box-shadow: var(--shadow); }
|
|
252
|
+
dialog::backdrop { background: rgba(0,0,0,.3); }
|
|
253
|
+
dialog h2 { margin: 0 0 12px; font-size: 16px; }
|
|
254
|
+
dialog label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
|
255
|
+
dialog input[type="text"], dialog select { width: 100%; border: 1px solid var(--border-strong); border-radius: 8px; padding: 7px 10px; background: var(--bg); }
|
|
256
|
+
dialog .help { font-size: 12px; color: var(--muted); margin: 4px 0 0; }
|
|
257
|
+
dialog .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
258
|
+
|
|
259
|
+
@media (max-width: 640px) {
|
|
260
|
+
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
261
|
+
.flabel { width: 100%; }
|
|
262
|
+
.meta { display: none; }
|
|
263
|
+
.wrow { grid-template-columns: 1fr; gap: 2px; }
|
|
264
|
+
}
|
|
265
|
+
</style>
|
|
266
|
+
</head>
|
|
267
|
+
<body>
|
|
268
|
+
<header class="top">
|
|
269
|
+
<div class="top-in">
|
|
270
|
+
<div class="brand">Superwiki <small>viewer</small></div>
|
|
271
|
+
<span class="meta" id="meta"></span>
|
|
272
|
+
<span class="spacer"></span>
|
|
273
|
+
<button class="btn warn" id="warnBtn" hidden></button>
|
|
274
|
+
<button class="btn ghost" id="pickBtn" hidden title="Pick a different folder">Change folder</button>
|
|
275
|
+
<button class="btn ghost" id="settingsBtn" title="Settings: editor links and theme" aria-label="Settings">⚙</button>
|
|
276
|
+
<button class="btn primary" id="refreshBtn" title="Re-read the docs folder (R)"><span id="refreshIcon">↻</span> Refresh</button>
|
|
277
|
+
</div>
|
|
278
|
+
</header>
|
|
279
|
+
|
|
280
|
+
<main>
|
|
281
|
+
<section class="empty" id="empty">
|
|
282
|
+
<h1>Open a docs vault</h1>
|
|
283
|
+
<p id="emptyText">Pick the repo folder (the one containing <b>docs/</b>) or the <b>docs</b> folder itself, once. After that, <b>Refresh</b> re-reads the files every time. This page never writes to any file.</p>
|
|
284
|
+
<button class="btn primary" id="emptyBtn">Pick folder</button>
|
|
285
|
+
<div class="err" id="err" hidden></div>
|
|
286
|
+
</section>
|
|
287
|
+
|
|
288
|
+
<section id="app" hidden>
|
|
289
|
+
<div class="summary" id="summary"></div>
|
|
290
|
+
<div class="warnlist" id="warnlist" hidden></div>
|
|
291
|
+
|
|
292
|
+
<div class="filters">
|
|
293
|
+
<div class="frow">
|
|
294
|
+
<input class="search" id="q" type="search" autocomplete="off">
|
|
295
|
+
<button class="btn ghost" id="clearBtn">Clear filters</button>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="frow" id="rowState"><span class="flabel">State</span><span id="fState" class="chips"></span></div>
|
|
298
|
+
<div class="frow" id="rowArea"><span class="flabel">Area</span><span id="fArea" class="chips"></span></div>
|
|
299
|
+
<div class="frow" id="rowMilestone"><span class="flabel">Milestone</span><span id="fMilestone" class="chips"></span></div>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div class="toolbar">
|
|
303
|
+
<div class="tabs" id="viewTabs">
|
|
304
|
+
<button class="tab" data-view="list">Tasks</button>
|
|
305
|
+
<button class="tab" data-view="waves">Waves</button>
|
|
306
|
+
<button class="tab" data-view="wiki">Wiki</button>
|
|
307
|
+
</div>
|
|
308
|
+
<label class="count" id="groupWrap">Group by
|
|
309
|
+
<select class="sel" id="groupSel">
|
|
310
|
+
<option value="none">None</option>
|
|
311
|
+
<option value="area">Area</option>
|
|
312
|
+
<option value="wave">Wave</option>
|
|
313
|
+
<option value="milestone">Milestone</option>
|
|
314
|
+
<option value="state">State</option>
|
|
315
|
+
</select>
|
|
316
|
+
</label>
|
|
317
|
+
<label class="count" id="chainWrap">Highlight
|
|
318
|
+
<select class="sel" id="chainSel">
|
|
319
|
+
<option value="all">Whole chain</option>
|
|
320
|
+
<option value="direct">Direct links only</option>
|
|
321
|
+
</select>
|
|
322
|
+
</label>
|
|
323
|
+
<span class="count" id="rootLinks"></span>
|
|
324
|
+
<span class="spacer"></span>
|
|
325
|
+
<div class="legend" id="legend">
|
|
326
|
+
<span><i style="background:var(--ready)"></i>Ready</span>
|
|
327
|
+
<span><i style="background:var(--prog)"></i>In progress</span>
|
|
328
|
+
<span><i style="background:var(--blocked)"></i>Blocked</span>
|
|
329
|
+
<span><i style="background:var(--done)"></i>Done</span>
|
|
330
|
+
<span><i style="border:1.5px dashed var(--muted)"></i>Soft dependency</span>
|
|
331
|
+
</div>
|
|
332
|
+
<span class="count" id="count"></span>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<div id="listView"></div>
|
|
336
|
+
<div id="waveView" hidden></div>
|
|
337
|
+
<div id="wikiView" hidden></div>
|
|
338
|
+
</section>
|
|
339
|
+
</main>
|
|
340
|
+
|
|
341
|
+
<div class="scrim" id="scrim" hidden></div>
|
|
342
|
+
<aside class="drawer" id="drawer" aria-hidden="true">
|
|
343
|
+
<div class="dhead">
|
|
344
|
+
<button class="btn ghost" id="backBtn" title="Back">←</button>
|
|
345
|
+
<span class="mono" id="dId"></span>
|
|
346
|
+
<span id="dArea" class="muted"></span>
|
|
347
|
+
<span class="spacer"></span>
|
|
348
|
+
<a class="btn ghost" id="dOpen" hidden title="Open this file in your editor">Open in editor</a>
|
|
349
|
+
<button class="btn ghost" id="closeBtn" title="Close (Esc)">✕</button>
|
|
350
|
+
</div>
|
|
351
|
+
<div class="dbody" id="dBody"></div>
|
|
352
|
+
</aside>
|
|
353
|
+
<div class="toast" id="toast" hidden></div>
|
|
354
|
+
|
|
355
|
+
<dialog id="settings">
|
|
356
|
+
<form method="dialog" id="settingsForm">
|
|
357
|
+
<h2>Settings</h2>
|
|
358
|
+
<label for="setPath">Absolute path of your docs folder</label>
|
|
359
|
+
<input type="text" id="setPath" placeholder="/home/me/projects/my-repo/docs" autocomplete="off" spellcheck="false">
|
|
360
|
+
<p class="help">Browsers do not reveal where a picked folder lives. Enter it once to get an "Open in editor" link in the drawer; leave it empty to hide the link. Stored only in this browser.</p>
|
|
361
|
+
<label for="setEditor">Editor</label>
|
|
362
|
+
<select id="setEditor">
|
|
363
|
+
<option value="vscode">VS Code</option>
|
|
364
|
+
<option value="cursor">Cursor</option>
|
|
365
|
+
<option value="vscodium">VSCodium</option>
|
|
366
|
+
</select>
|
|
367
|
+
<label for="setTheme">Theme</label>
|
|
368
|
+
<select id="setTheme">
|
|
369
|
+
<option value="auto">Follow system</option>
|
|
370
|
+
<option value="light">Light</option>
|
|
371
|
+
<option value="dark">Dark</option>
|
|
372
|
+
</select>
|
|
373
|
+
<div class="err" id="setErr" hidden></div>
|
|
374
|
+
<div class="actions">
|
|
375
|
+
<button class="btn ghost" type="button" id="setCancel">Cancel</button>
|
|
376
|
+
<button class="btn primary" type="submit">Save</button>
|
|
377
|
+
</div>
|
|
378
|
+
</form>
|
|
379
|
+
</dialog>
|
|
380
|
+
|
|
381
|
+
<script>
|
|
382
|
+
// Superwiki core: vault model, derived task state and lint. Pure: no fs, no DOM.
|
|
383
|
+
// Runs in Node (docs/.sw/sw.mjs) and inlined in the viewer, so both report the same findings.
|
|
384
|
+
|
|
385
|
+
const STATUSES = ['todo', 'in-progress', 'done', 'cancelled'];
|
|
386
|
+
const VAULT_FOLDERS = ['wiki', 'tasks', 'plans'];
|
|
387
|
+
const ROOT_PAGES = ['index', 'log'];
|
|
388
|
+
|
|
389
|
+
// ---------- Frontmatter (the YAML subset Superwiki writes: scalars, [inline, lists], "- block" lists) ----------
|
|
390
|
+
function scalar(s) {
|
|
391
|
+
let v = String(s).trim();
|
|
392
|
+
if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
393
|
+
else if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1).replace(/''/g, "'");
|
|
394
|
+
if (v.startsWith('[[') && v.endsWith(']]')) v = v.slice(2, -2).trim();
|
|
395
|
+
return v;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function stripComment(line) {
|
|
399
|
+
return /["']/.test(line) ? line : line.replace(/(^|\s)#.*$/, '');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function parseFrontmatter(text) {
|
|
403
|
+
const src = String(text ?? '').replace(/^/, '');
|
|
404
|
+
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(src);
|
|
405
|
+
if (!m) return { data: null, body: src, bodyLine: 1 };
|
|
406
|
+
const data = {};
|
|
407
|
+
let key = null;
|
|
408
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
409
|
+
const line = stripComment(raw);
|
|
410
|
+
if (!line.trim()) continue;
|
|
411
|
+
const item = /^\s*-\s+(.*)$/.exec(line);
|
|
412
|
+
if (item && key) {
|
|
413
|
+
if (!Array.isArray(data[key])) data[key] = [];
|
|
414
|
+
data[key].push(scalar(item[1]));
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
|
|
418
|
+
if (!kv) continue;
|
|
419
|
+
key = kv[1];
|
|
420
|
+
const v = kv[2].trim();
|
|
421
|
+
if (v === '') data[key] = null;
|
|
422
|
+
else if (v.startsWith('[') && v.endsWith(']') && !v.startsWith('[[')) {
|
|
423
|
+
data[key] = v.slice(1, -1).split(',').map(scalar).filter(Boolean);
|
|
424
|
+
} else data[key] = scalar(v);
|
|
425
|
+
}
|
|
426
|
+
return { data, body: src.slice(m[0].length), bodyLine: m[0].split('\n').length };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function asList(v) {
|
|
430
|
+
if (v == null || v === '') return [];
|
|
431
|
+
return Array.isArray(v) ? v : [v];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------- Wikilinks ----------
|
|
435
|
+
const WIKILINK = /(!?)\[\[([^\[\]|#]+)(#[^\[\]|]*)?(?:\|([^\[\]]*))?\]\]/g;
|
|
436
|
+
const isAttachment = target => /\.[A-Za-z0-9]{2,5}$/.test(target) && !/\.md$/i.test(target);
|
|
437
|
+
|
|
438
|
+
// Links in a markdown body. Fenced and inline code is skipped; `line` is 1-based within the body.
|
|
439
|
+
function extractWikilinks(body) {
|
|
440
|
+
const out = [];
|
|
441
|
+
let fenced = false;
|
|
442
|
+
String(body ?? '').split(/\r?\n/).forEach((raw, i) => {
|
|
443
|
+
if (/^\s*(```|~~~)/.test(raw)) { fenced = !fenced; return; }
|
|
444
|
+
if (fenced) return;
|
|
445
|
+
const line = raw.replace(/`[^`]*`/g, '');
|
|
446
|
+
for (const m of line.matchAll(WIKILINK)) {
|
|
447
|
+
const target = m[2].trim().replace(/\.md$/i, '');
|
|
448
|
+
if (m[1] === '!' || isAttachment(m[2].trim())) continue;
|
|
449
|
+
out.push({ target, heading: m[3] ? m[3].slice(1) : null, alias: m[4] ?? null, line: i + 1 });
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------- Vault ----------
|
|
456
|
+
const key = name => String(name).toLowerCase();
|
|
457
|
+
const areaOf = id => (String(id).includes('-') ? String(id).slice(0, String(id).lastIndexOf('-')) : '');
|
|
458
|
+
|
|
459
|
+
// files: [{ path, text }] with paths relative to docs/ ("wiki/foo.md"). Anything outside
|
|
460
|
+
// index.md, log.md, wiki/, tasks/ and plans/ belongs to other tools and is ignored.
|
|
461
|
+
function buildVault(files) {
|
|
462
|
+
const pages = [];
|
|
463
|
+
const names = new Map();
|
|
464
|
+
for (const f of files) {
|
|
465
|
+
const path = String(f.path).replace(/\\/g, '/').replace(/^\.?\//, '');
|
|
466
|
+
if (!/\.md$/i.test(path)) continue;
|
|
467
|
+
const parts = path.split('/');
|
|
468
|
+
const name = parts[parts.length - 1].replace(/\.md$/i, '');
|
|
469
|
+
let folder;
|
|
470
|
+
if (parts.length === 1) { if (!ROOT_PAGES.includes(name)) continue; folder = 'root'; }
|
|
471
|
+
else if (VAULT_FOLDERS.includes(parts[0])) folder = parts[0];
|
|
472
|
+
else continue;
|
|
473
|
+
const fm = parseFrontmatter(f.text);
|
|
474
|
+
const page = { path, folder, name, nested: parts.length > 2, data: fm.data || {}, hasFrontmatter: !!fm.data, body: fm.body, bodyLine: fm.bodyLine, links: extractWikilinks(fm.body), inbound: [] };
|
|
475
|
+
pages.push(page);
|
|
476
|
+
if (!names.has(key(name))) names.set(key(name), []);
|
|
477
|
+
names.get(key(name)).push(page);
|
|
478
|
+
}
|
|
479
|
+
pages.sort((a, b) => a.path.localeCompare(b.path));
|
|
480
|
+
const vault = { pages, names, tasks: new Map(), plans: new Map(), index: null, log: null };
|
|
481
|
+
vault.index = pages.find(p => p.folder === 'root' && p.name === 'index') || null;
|
|
482
|
+
vault.log = pages.find(p => p.folder === 'root' && p.name === 'log') || null;
|
|
483
|
+
for (const p of pages) for (const l of p.links) {
|
|
484
|
+
const t = resolve(vault, l.target);
|
|
485
|
+
if (t && t !== p && !t.inbound.includes(p)) t.inbound.push(p);
|
|
486
|
+
}
|
|
487
|
+
for (const p of pages) {
|
|
488
|
+
if (p.folder === 'plans') vault.plans.set(key(p.name.replace(/-plan$/i, '')), p);
|
|
489
|
+
if (p.folder !== 'tasks') continue;
|
|
490
|
+
const d = p.data;
|
|
491
|
+
const id = String(d.id || p.name);
|
|
492
|
+
vault.tasks.set(key(id), {
|
|
493
|
+
id, page: p, area: areaOf(id), title: d.title || '', status: d.status || '',
|
|
494
|
+
deps: asList(d.deps).map(String), softDeps: asList(d.soft_deps).map(String),
|
|
495
|
+
milestone: d.milestone || '', priority: d.priority == null || d.priority === '' ? null : Number(d.priority),
|
|
496
|
+
started: d.started || '', finished: d.finished || '',
|
|
497
|
+
state: null, wave: 0, dependents: [], plan: null,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
derive(vault);
|
|
501
|
+
return vault;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function resolve(vault, target) {
|
|
505
|
+
const hit = vault.names.get(key(String(target).split('/').pop().replace(/\.md$/i, '')));
|
|
506
|
+
return hit ? hit[0] : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const taskOf = (vault, id) => vault.tasks.get(key(id)) || null;
|
|
510
|
+
|
|
511
|
+
// state: done | cancelled | progress | ready | blocked. wave: longest hard-dependency chain below the task.
|
|
512
|
+
function derive(vault) {
|
|
513
|
+
const depth = new Map();
|
|
514
|
+
const waveOf = (t, trail) => {
|
|
515
|
+
if (depth.has(t)) return depth.get(t);
|
|
516
|
+
if (trail.has(t)) return 0;
|
|
517
|
+
trail.add(t);
|
|
518
|
+
let w = 0;
|
|
519
|
+
for (const id of t.deps) { const d = taskOf(vault, id); if (d) w = Math.max(w, waveOf(d, trail) + 1); }
|
|
520
|
+
trail.delete(t);
|
|
521
|
+
depth.set(t, w);
|
|
522
|
+
return w;
|
|
523
|
+
};
|
|
524
|
+
for (const t of vault.tasks.values()) {
|
|
525
|
+
t.plan = vault.plans.get(key(t.id)) || null;
|
|
526
|
+
t.wave = waveOf(t, new Set());
|
|
527
|
+
const open = t.deps.filter(id => taskOf(vault, id)?.status !== 'done');
|
|
528
|
+
t.openDeps = open;
|
|
529
|
+
t.openSoftDeps = t.softDeps.filter(id => taskOf(vault, id)?.status !== 'done');
|
|
530
|
+
t.state = t.status === 'done' ? 'done' : t.status === 'cancelled' ? 'cancelled' : t.status === 'in-progress' ? 'progress' : open.length ? 'blocked' : 'ready';
|
|
531
|
+
for (const id of [...t.deps, ...t.softDeps]) {
|
|
532
|
+
const d = taskOf(vault, id);
|
|
533
|
+
if (d && !d.dependents.includes(t.id)) d.dependents.push(t.id);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function cycles(vault) {
|
|
539
|
+
const found = [];
|
|
540
|
+
const seen = new Set();
|
|
541
|
+
const state = new Map();
|
|
542
|
+
const visit = (t, trail) => {
|
|
543
|
+
state.set(t, 1);
|
|
544
|
+
trail.push(t.id);
|
|
545
|
+
for (const id of [...t.deps, ...t.softDeps]) {
|
|
546
|
+
const d = taskOf(vault, id);
|
|
547
|
+
if (!d) continue;
|
|
548
|
+
if (state.get(d) === 1) {
|
|
549
|
+
const loop = trail.slice(trail.indexOf(d.id));
|
|
550
|
+
const sig = [...loop].sort().join(',');
|
|
551
|
+
if (!seen.has(sig)) { seen.add(sig); found.push([...loop, d.id]); }
|
|
552
|
+
} else if (!state.get(d)) visit(d, trail);
|
|
553
|
+
}
|
|
554
|
+
trail.pop();
|
|
555
|
+
state.set(t, 2);
|
|
556
|
+
};
|
|
557
|
+
for (const t of vault.tasks.values()) if (!state.get(t)) visit(t, []);
|
|
558
|
+
return found;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------- Lint ----------
|
|
562
|
+
// Finding: { level: 'error' | 'warn', code, path, message }.
|
|
563
|
+
function lint(vault) {
|
|
564
|
+
const out = [];
|
|
565
|
+
const add = (level, code, path, message) => out.push({ level, code, path, message });
|
|
566
|
+
|
|
567
|
+
for (const [, list] of vault.names) {
|
|
568
|
+
if (list.length > 1) add('error', 'duplicate-name', list[0].path, `file name is not unique: ${list.map(p => p.path).join(', ')}`);
|
|
569
|
+
}
|
|
570
|
+
if (!vault.index) add('error', 'missing-index', 'index.md', 'docs/index.md is missing');
|
|
571
|
+
|
|
572
|
+
for (const p of vault.pages) {
|
|
573
|
+
if (p.nested) add('warn', 'nested-page', p.path, `${p.folder}/ is flat; move the file up`);
|
|
574
|
+
// The log is history: entries keep the names pages had when they were written.
|
|
575
|
+
if (p !== vault.log) for (const l of p.links) {
|
|
576
|
+
if (!resolve(vault, l.target)) add('error', 'broken-link', p.path, `line ${p.bodyLine + l.line - 1}: [[${l.target}]] has no target`);
|
|
577
|
+
}
|
|
578
|
+
if (p.folder === 'wiki') {
|
|
579
|
+
if (!p.data.type) add('error', 'missing-field', p.path, 'frontmatter `type` is missing');
|
|
580
|
+
if (!p.data.summary) add('warn', 'missing-field', p.path, 'frontmatter `summary` is missing');
|
|
581
|
+
if (vault.index && !vault.index.links.some(l => resolve(vault, l.target) === p)) add('warn', 'not-in-index', p.path, 'page is not listed in index.md');
|
|
582
|
+
// A source summary is reachable from the index and need not be cited yet; ingest stays a three-file change.
|
|
583
|
+
if (p.data.type !== 'source' && !p.inbound.some(q => q !== vault.index && q !== vault.log)) add('warn', 'orphan-page', p.path, 'no page links here');
|
|
584
|
+
}
|
|
585
|
+
if (p.folder === 'plans') {
|
|
586
|
+
const id = /-plan$/i.test(p.name) ? p.name.replace(/-plan$/i, '') : null;
|
|
587
|
+
if (!id) add('error', 'plan-name', p.path, 'plan files are named <ID>-plan.md');
|
|
588
|
+
else if (!taskOf(vault, id)) add('error', 'orphan-plan', p.path, `no task ${id} for this plan`);
|
|
589
|
+
else if (p.data.task && key(p.data.task) !== key(id)) add('error', 'plan-task-mismatch', p.path, `frontmatter task ${p.data.task} does not match file name`);
|
|
590
|
+
if (p.data.type !== 'plan') add('warn', 'missing-field', p.path, 'frontmatter `type: plan` is missing');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const t of vault.tasks.values()) {
|
|
595
|
+
const path = t.page.path;
|
|
596
|
+
if (t.page.data.type !== 'task') add('warn', 'missing-field', path, 'frontmatter `type: task` is missing');
|
|
597
|
+
if (!t.page.data.id) add('error', 'missing-field', path, 'frontmatter `id` is missing');
|
|
598
|
+
else if (key(t.id) !== key(t.page.name)) add('error', 'id-mismatch', path, `id ${t.id} does not match file name`);
|
|
599
|
+
if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(t.id)) add('error', 'bad-id', path, `id ${t.id} is not <AREA>-<number>`);
|
|
600
|
+
if (!t.title) add('error', 'missing-field', path, 'frontmatter `title` is missing');
|
|
601
|
+
if (!STATUSES.includes(t.status)) add('error', 'bad-status', path, `status "${t.status}" is not one of ${STATUSES.join(', ')}`);
|
|
602
|
+
for (const id of [...t.deps, ...t.softDeps]) {
|
|
603
|
+
const d = taskOf(vault, id);
|
|
604
|
+
if (!d) add('error', 'unknown-dep', path, `depends on ${id}, which does not exist`);
|
|
605
|
+
else if (d.status === 'cancelled' && t.status !== 'cancelled' && t.status !== 'done') add('warn', 'cancelled-dep', path, `depends on cancelled task ${id}`);
|
|
606
|
+
}
|
|
607
|
+
const hardOpen = t.openDeps.filter(id => taskOf(vault, id));
|
|
608
|
+
const softOpen = t.openSoftDeps.filter(id => taskOf(vault, id));
|
|
609
|
+
if (t.status === 'in-progress' && hardOpen.length) add('error', 'started-before-deps', path, `in-progress but not done: ${hardOpen.join(', ')}`);
|
|
610
|
+
if (t.status === 'done' && (hardOpen.length || softOpen.length)) add('error', 'done-before-deps', path, `done but not done: ${[...hardOpen, ...softOpen].join(', ')}`);
|
|
611
|
+
if ((t.status === 'in-progress' || t.status === 'done') && !t.started) add('warn', 'missing-date', path, '`started` is empty');
|
|
612
|
+
if (t.status === 'done' && !t.finished) add('warn', 'missing-date', path, '`finished` is empty');
|
|
613
|
+
}
|
|
614
|
+
for (const loop of cycles(vault)) add('error', 'dep-cycle', taskOf(vault, loop[0]).page.path, `dependency cycle: ${loop.join(' -> ')}`);
|
|
615
|
+
|
|
616
|
+
const rank = { error: 0, warn: 1 };
|
|
617
|
+
return out.sort((a, b) => rank[a.level] - rank[b.level] || a.path.localeCompare(b.path) || a.code.localeCompare(b.code));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------- Queries ----------
|
|
621
|
+
const STATES = ['ready', 'progress', 'blocked', 'done', 'cancelled'];
|
|
622
|
+
const idOrder = (a, b) => a.area.localeCompare(b.area) || Number(a.id.split('-').pop()) - Number(b.id.split('-').pop());
|
|
623
|
+
// Runnable order: fewest unfinished layers first, then explicit priority, then id.
|
|
624
|
+
const taskOrder = (a, b) => a.wave - b.wave || (a.priority ?? Infinity) - (b.priority ?? Infinity) || idOrder(a, b);
|
|
625
|
+
|
|
626
|
+
function summary(vault) {
|
|
627
|
+
const zero = () => Object.fromEntries([['total', 0], ...STATES.map(s => [s, 0])]);
|
|
628
|
+
const areas = new Map();
|
|
629
|
+
const total = zero();
|
|
630
|
+
for (const t of [...vault.tasks.values()].sort(idOrder)) {
|
|
631
|
+
if (!areas.has(t.area)) areas.set(t.area, zero());
|
|
632
|
+
for (const c of [areas.get(t.area), total]) { c.total++; c[t.state]++; }
|
|
633
|
+
}
|
|
634
|
+
return { areas, total, wikiPages: vault.pages.filter(p => p.folder === 'wiki').length };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function tasksIn(vault, state) {
|
|
638
|
+
return [...vault.tasks.values()].filter(t => t.state === state).sort(taskOrder);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Numbers are never reused: cancelled tasks stay on disk and keep theirs.
|
|
642
|
+
function nextId(vault, area) {
|
|
643
|
+
let max = 0;
|
|
644
|
+
let width = 2;
|
|
645
|
+
for (const t of vault.tasks.values()) {
|
|
646
|
+
if (key(t.area) !== key(area)) continue;
|
|
647
|
+
const n = t.id.split('-').pop();
|
|
648
|
+
if (/^\d+$/.test(n)) { max = Math.max(max, Number(n)); width = Math.max(width, n.length); }
|
|
649
|
+
}
|
|
650
|
+
return `${area}-${String(max + 1).padStart(width, '0')}`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Hard dependents that become ready the moment this task is done.
|
|
654
|
+
function unblockedBy(vault, id) {
|
|
655
|
+
const t = taskOf(vault, id);
|
|
656
|
+
if (!t) return [];
|
|
657
|
+
return t.dependents.map(d => taskOf(vault, d))
|
|
658
|
+
.filter(d => d && d.status === 'todo' && d.deps.includes(t.id) && d.openDeps.every(o => o === t.id))
|
|
659
|
+
.map(d => d.id);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Keyword search over the vault. Pages matching more distinct terms come first; then lessons; a hit
|
|
663
|
+
// in the name, title or summary outweighs hits in the body. Returns [{ page, score, line }].
|
|
664
|
+
function search(vault, query, limit = 8) {
|
|
665
|
+
const terms = [...new Set(String(query).toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter(w => w.length > 1))];
|
|
666
|
+
if (!terms.length) return [];
|
|
667
|
+
// A term matches at the start of a word, so "sync" finds "syncing" but "hang" does not find "change".
|
|
668
|
+
const res = terms.map(t => new RegExp(`(?<![\\p{L}\\p{N}])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'u'));
|
|
669
|
+
const out = [];
|
|
670
|
+
for (const p of vault.pages) {
|
|
671
|
+
if (p === vault.index || p === vault.log) continue;
|
|
672
|
+
const head = `${p.name} ${p.data.title || ''} ${p.data.summary || ''}`.toLowerCase();
|
|
673
|
+
const body = p.body.toLowerCase();
|
|
674
|
+
let matched = 0;
|
|
675
|
+
let score = 0;
|
|
676
|
+
for (const re of res) {
|
|
677
|
+
const inHead = re.test(head);
|
|
678
|
+
const hits = Math.min((body.match(new RegExp(re.source, 'gu')) || []).length, 5);
|
|
679
|
+
if (inHead || hits) matched++;
|
|
680
|
+
score += (inHead ? 5 : 0) + hits;
|
|
681
|
+
}
|
|
682
|
+
if (!matched) continue;
|
|
683
|
+
const line = p.body.split(/\r?\n/).find(l => res.some(re => re.test(l.toLowerCase()))) || '';
|
|
684
|
+
out.push({ page: p, matched, score, line: line.trim().slice(0, 140) });
|
|
685
|
+
}
|
|
686
|
+
// Among pages matching the same number of terms, a recorded lesson is the most useful thing to read first.
|
|
687
|
+
const lesson = h => (h.page.data.type === 'lesson' ? 1 : 0);
|
|
688
|
+
return out.sort((a, b) => b.matched - a.matched || lesson(b) - lesson(a) || b.score - a.score || a.page.path.localeCompare(b.page.path)).slice(0, limit);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
// ---------- App. Everything about the vault (frontmatter, links, task state, waves, lint) comes from the core above. ----------
|
|
693
|
+
(() => {
|
|
694
|
+
'use strict';
|
|
695
|
+
const STATE_LIST = [['ready', 'Ready'], ['progress', 'In progress'], ['blocked', 'Blocked'], ['done', 'Done'], ['cancelled', 'Cancelled']];
|
|
696
|
+
const STATE_LABEL = Object.fromEntries(STATE_LIST);
|
|
697
|
+
const STATE_ORDER = { progress: 0, ready: 1, blocked: 2, done: 3, cancelled: 4 };
|
|
698
|
+
const EDITORS = { vscode: 'vscode', cursor: 'cursor', vscodium: 'vscodium' };
|
|
699
|
+
const $ = s => document.querySelector(s);
|
|
700
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
701
|
+
const lower = s => String(s ?? '').toLowerCase();
|
|
702
|
+
|
|
703
|
+
// Every file:// page shares one origin, so copies of this viewer in different projects would share
|
|
704
|
+
// one remembered folder and one set of filters. Scope both to this copy's own location.
|
|
705
|
+
const HERE = location.pathname;
|
|
706
|
+
const store = {
|
|
707
|
+
get(k, d) { try { const v = localStorage.getItem('sw-viewer:' + HERE + ':' + k); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
|
708
|
+
set(k, v) { try { localStorage.setItem('sw-viewer:' + HERE + ':' + k, JSON.stringify(v)); } catch {} },
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// Loaded vault and the lookups derived from it for display.
|
|
712
|
+
let vault = null, findings = [], sum = null, source = null, sourceName = '', config = {};
|
|
713
|
+
let texts = new Map(); // path -> raw file text, for the refresh diff
|
|
714
|
+
let pageByPath = new Map(); // path -> page
|
|
715
|
+
let taskByPath = new Map(); // task page path -> task
|
|
716
|
+
let planOwner = new Map(); // plan page path -> task
|
|
717
|
+
let hay = new Map(); // path -> lowercased search text
|
|
718
|
+
let areas = [], milestones = [];
|
|
719
|
+
|
|
720
|
+
const defaultFilters = () => ({ q: '', areas: [], states: ['ready', 'progress', 'blocked', 'done'], milestones: [] });
|
|
721
|
+
const ui = {
|
|
722
|
+
filters: Object.assign(defaultFilters(), store.get('filters', {})),
|
|
723
|
+
view: store.get('view', 'list'),
|
|
724
|
+
group: store.get('group', 'none'),
|
|
725
|
+
chain: store.get('chain', 'all'),
|
|
726
|
+
sort: store.get('sort', { key: 'wave', dir: 1 }),
|
|
727
|
+
selected: null, dtab: 'main', history: [], hover: null, showWarn: false,
|
|
728
|
+
};
|
|
729
|
+
const saveUi = () => { store.set('filters', ui.filters); store.set('view', ui.view); store.set('group', ui.group); store.set('sort', ui.sort); store.set('chain', ui.chain); };
|
|
730
|
+
|
|
731
|
+
function applyTheme() {
|
|
732
|
+
const t = store.get('theme', 'auto');
|
|
733
|
+
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
|
734
|
+
else delete document.documentElement.dataset.theme;
|
|
735
|
+
}
|
|
736
|
+
applyTheme();
|
|
737
|
+
|
|
738
|
+
// ---------- Files: File System Access API, with a folder-input fallback. Read-only. ----------
|
|
739
|
+
function idb() {
|
|
740
|
+
return new Promise((res, rej) => {
|
|
741
|
+
const r = indexedDB.open('sw-viewer', 1);
|
|
742
|
+
r.onupgradeneeded = () => r.result.createObjectStore('kv');
|
|
743
|
+
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async function kv(op, key, val) {
|
|
747
|
+
try {
|
|
748
|
+
const db = await idb();
|
|
749
|
+
return await new Promise((res, rej) => {
|
|
750
|
+
const tx = db.transaction('kv', op === 'get' ? 'readonly' : 'readwrite');
|
|
751
|
+
const r = op === 'get' ? tx.objectStore('kv').get(key) : tx.objectStore('kv').put(val, key);
|
|
752
|
+
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
|
|
753
|
+
});
|
|
754
|
+
} catch { return undefined; }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const NOT_FOUND = 'No index.md found. Pick the repo folder (the one containing docs/) or the docs folder itself.';
|
|
758
|
+
const isRootPage = name => /^(index|log)\.md$/.test(name);
|
|
759
|
+
|
|
760
|
+
// A docs folder is recognised by its index.md.
|
|
761
|
+
async function docsDirOf(handle) {
|
|
762
|
+
for (const get of [() => handle, () => handle.getDirectoryHandle('docs')]) {
|
|
763
|
+
try { const dir = await get(); await dir.getFileHandle('index.md'); return dir; } catch {}
|
|
764
|
+
}
|
|
765
|
+
throw new Error(NOT_FOUND);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function walk(dir, prefix, out) {
|
|
769
|
+
const jobs = [];
|
|
770
|
+
for await (const [name, h] of dir.entries()) {
|
|
771
|
+
if (name.startsWith('.')) continue;
|
|
772
|
+
if (h.kind === 'directory') jobs.push(walk(h, prefix + name + '/', out));
|
|
773
|
+
else if (/\.md$/i.test(name)) jobs.push(h.getFile().then(f => f.text()).then(text => { out.push({ path: prefix + name, text }); }));
|
|
774
|
+
}
|
|
775
|
+
await Promise.all(jobs);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const hasFsApi = 'showDirectoryPicker' in window;
|
|
779
|
+
|
|
780
|
+
// `node docs/.sw/sw.mjs snapshot` writes docs/.sw/data.js. A script tag is the one way a file:// page can
|
|
781
|
+
// read a local file without the user picking it, so with a snapshot the viewer opens with no question asked.
|
|
782
|
+
// The second name is for static hosting (the public demo), where folders starting with a dot may not be served.
|
|
783
|
+
const SNAPSHOTS = ['.sw/data.js', 'sw-data.js'];
|
|
784
|
+
function loadScript(src) {
|
|
785
|
+
return new Promise(res => {
|
|
786
|
+
const el = document.createElement('script');
|
|
787
|
+
el.src = `${src}?t=${Date.now()}`;
|
|
788
|
+
el.onload = () => { el.remove(); res(window.SW_DATA || null); };
|
|
789
|
+
el.onerror = () => { el.remove(); res(null); };
|
|
790
|
+
document.head.append(el);
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
async function loadSnapshot() {
|
|
794
|
+
for (const src of SNAPSHOTS) { const d = await loadScript(src); if (d) return d; }
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
function snapshotSource() {
|
|
798
|
+
return {
|
|
799
|
+
snapshot: true,
|
|
800
|
+
async read() {
|
|
801
|
+
window.SW_DATA = null;
|
|
802
|
+
const d = await loadSnapshot();
|
|
803
|
+
if (!d) throw new Error('The snapshot file docs/.sw/data.js is gone. Run sw-visualize again, or pick the folder.');
|
|
804
|
+
return { files: d.files, name: d.name, config: d.config || {}, generated: d.live ? null : d.generated, live: !!d.live };
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function handleSource(handle) {
|
|
810
|
+
return {
|
|
811
|
+
handle,
|
|
812
|
+
async read() {
|
|
813
|
+
const docs = await docsDirOf(handle);
|
|
814
|
+
const out = [];
|
|
815
|
+
const jobs = [];
|
|
816
|
+
for (const name of ['index.md', 'log.md']) {
|
|
817
|
+
jobs.push(docs.getFileHandle(name).then(h => h.getFile()).then(f => f.text()).then(text => { out.push({ path: name, text }); }, () => {}));
|
|
818
|
+
}
|
|
819
|
+
// wiki/, tasks/ and plans/ are each optional; raw/ and everything else is never read.
|
|
820
|
+
for (const folder of VAULT_FOLDERS) {
|
|
821
|
+
jobs.push(docs.getDirectoryHandle(folder).then(d => walk(d, folder + '/', out), () => {}));
|
|
822
|
+
}
|
|
823
|
+
// sw-init records the project's name and area names here; a vault without it still loads.
|
|
824
|
+
let config = {};
|
|
825
|
+
jobs.push(docs.getDirectoryHandle('.sw').then(d => d.getFileHandle('config.json')).then(h => h.getFile()).then(f => f.text()).then(t => { config = JSON.parse(t); }).catch(() => {}));
|
|
826
|
+
await Promise.all(jobs);
|
|
827
|
+
return { files: out, name: handle.name, config };
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function fileListSource(list) {
|
|
833
|
+
return {
|
|
834
|
+
async read() {
|
|
835
|
+
const all = [...list].map(f => ({ f, p: f.webkitRelativePath || f.name }));
|
|
836
|
+
const top = all.length ? all[0].p.split('/')[0] : '';
|
|
837
|
+
const base = [top + '/', top + '/docs/'].find(b => all.some(x => x.p === b + 'index.md'));
|
|
838
|
+
if (base == null) throw new Error(NOT_FOUND);
|
|
839
|
+
const wanted = all.map(x => ({ f: x.f, rel: x.p.startsWith(base) ? x.p.slice(base.length) : null })).filter(x => {
|
|
840
|
+
if (!x.rel || !/\.md$/i.test(x.rel) || x.rel.split('/').some(s => s.startsWith('.'))) return false;
|
|
841
|
+
const parts = x.rel.split('/');
|
|
842
|
+
return parts.length === 1 ? isRootPage(parts[0]) : VAULT_FOLDERS.includes(parts[0]);
|
|
843
|
+
});
|
|
844
|
+
const files = await Promise.all(wanted.map(async x => ({ path: x.rel, text: await x.f.text() })));
|
|
845
|
+
let config = {};
|
|
846
|
+
const cf = all.find(x => x.p === base + '.sw/config.json');
|
|
847
|
+
if (cf) try { config = JSON.parse(await cf.f.text()); } catch {}
|
|
848
|
+
return { files, name: top, config };
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function pickFolder() {
|
|
854
|
+
if (hasFsApi) {
|
|
855
|
+
try {
|
|
856
|
+
const handle = await window.showDirectoryPicker({ id: 'sw-docs', mode: 'read' });
|
|
857
|
+
await docsDirOf(handle);
|
|
858
|
+
await kv('put', 'dir:' + HERE, handle);
|
|
859
|
+
source = handleSource(handle);
|
|
860
|
+
return load();
|
|
861
|
+
} catch (e) { if (e.name !== 'AbortError') showError(e.message); return; }
|
|
862
|
+
}
|
|
863
|
+
const input = document.createElement('input');
|
|
864
|
+
input.type = 'file'; input.webkitdirectory = true;
|
|
865
|
+
input.onchange = () => { if (input.files.length) { source = fileListSource(input.files); load(); } };
|
|
866
|
+
input.click();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function refresh() {
|
|
870
|
+
if (!source) return pickFolder();
|
|
871
|
+
if (source.snapshot) return load();
|
|
872
|
+
if (source.handle && (await source.handle.queryPermission({ mode: 'read' })) !== 'granted') {
|
|
873
|
+
if ((await source.handle.requestPermission({ mode: 'read' })) !== 'granted') return showError('Permission to read the folder was not granted.');
|
|
874
|
+
}
|
|
875
|
+
if (!hasFsApi) return pickFolder();
|
|
876
|
+
return load();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let loading = false;
|
|
880
|
+
async function load() {
|
|
881
|
+
if (loading) return;
|
|
882
|
+
loading = true;
|
|
883
|
+
$('#refreshIcon').classList.add('spin');
|
|
884
|
+
try {
|
|
885
|
+
const raw = await source.read();
|
|
886
|
+
const next = buildVault(raw.files);
|
|
887
|
+
if (!next.pages.length) throw new Error('The folder was read but it contains no vault pages.');
|
|
888
|
+
const prev = vault, prevTexts = texts;
|
|
889
|
+
vault = next; config = raw.config || {};
|
|
890
|
+
// The picked folder is often just "docs"; the project name says which vault this is.
|
|
891
|
+
sourceName = config.name || raw.name || '';
|
|
892
|
+
texts = new Map(raw.files.map(f => [f.path, f.text]));
|
|
893
|
+
index();
|
|
894
|
+
$('#empty').hidden = true; $('#app').hidden = false; $('#pickBtn').hidden = !hasFsApi;
|
|
895
|
+
$('#pickBtn').textContent = source.snapshot ? 'Read live folder' : 'Change folder';
|
|
896
|
+
// Served by `sw.mjs serve`: Refresh already reads the files as they are, so there is nothing to pick.
|
|
897
|
+
if (raw.live) $('#pickBtn').hidden = true;
|
|
898
|
+
$('#err').hidden = true;
|
|
899
|
+
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
900
|
+
const n = vault.tasks.size;
|
|
901
|
+
$('#meta').textContent = `${sourceName ? sourceName + ' · ' : ''}${n ? `${n} task${n === 1 ? '' : 's'} · ` : ''}${sum.wikiPages} wiki page${sum.wikiPages === 1 ? '' : 's'} · ${raw.generated ? `snapshot of ${new Date(raw.generated).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'medium' })}` : `read ${now}`}`;
|
|
902
|
+
$('#meta').title = raw.generated ? 'Shows the files as they were when sw-visualize last ran. Run it again for a new snapshot, or use "Read live folder" to pick the folder and read it directly.' : '';
|
|
903
|
+
renderAll();
|
|
904
|
+
ui.history = ui.history.filter(h => pageByPath.has(h.path));
|
|
905
|
+
if (ui.selected && pageByPath.has(ui.selected)) renderDrawer(); else if (ui.selected) closeDrawer();
|
|
906
|
+
if (prev) announceChanges(prev, prevTexts);
|
|
907
|
+
} catch (e) {
|
|
908
|
+
showError(e.message);
|
|
909
|
+
} finally {
|
|
910
|
+
loading = false;
|
|
911
|
+
setTimeout(() => $('#refreshIcon').classList.remove('spin'), 250);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Display lookups over the vault the core built. No vault logic lives here.
|
|
916
|
+
function index() {
|
|
917
|
+
findings = lint(vault);
|
|
918
|
+
sum = summary(vault);
|
|
919
|
+
pageByPath = new Map(vault.pages.map(p => [p.path, p]));
|
|
920
|
+
taskByPath = new Map(); planOwner = new Map(); hay = new Map();
|
|
921
|
+
for (const t of vault.tasks.values()) {
|
|
922
|
+
taskByPath.set(t.page.path, t);
|
|
923
|
+
if (t.plan) planOwner.set(t.plan.path, t);
|
|
924
|
+
hay.set(t.page.path, lower(`${t.id} ${t.title} ${t.milestone} ${t.page.body}`));
|
|
925
|
+
}
|
|
926
|
+
for (const p of vault.pages) {
|
|
927
|
+
if (!hay.has(p.path)) hay.set(p.path, lower(`${p.name} ${typeOf(p)} ${p.data.summary ?? ''} ${p.data.title ?? ''} ${p.body}`));
|
|
928
|
+
}
|
|
929
|
+
areas = [...sum.areas.keys()];
|
|
930
|
+
milestones = [...new Set([...vault.tasks.values()].map(t => String(t.milestone)).filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
931
|
+
// Filters remembered from another vault would hide everything without a visible reason.
|
|
932
|
+
ui.filters.areas = ui.filters.areas.filter(a => areas.includes(a));
|
|
933
|
+
ui.filters.milestones = ui.filters.milestones.filter(m => m === '' || milestones.includes(m));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function showError(msg) {
|
|
937
|
+
if (vault) return toast(esc(msg));
|
|
938
|
+
$('#err').hidden = false; $('#err').textContent = msg;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
let toastTimer;
|
|
942
|
+
function toast(html, ms = 3500) {
|
|
943
|
+
const t = $('#toast'); t.innerHTML = html; t.hidden = false;
|
|
944
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, ms);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function announceChanges(prev, prevTexts) {
|
|
948
|
+
const changes = [];
|
|
949
|
+
const sig = t => t.deps.join() + '|' + t.softDeps.join();
|
|
950
|
+
for (const t of vault.tasks.values()) {
|
|
951
|
+
const p = taskOf(prev, t.id);
|
|
952
|
+
if (!p) changes.push({ id: t.id, path: t.page.path, text: 'new task' });
|
|
953
|
+
else if (p.state !== t.state) changes.push({ id: t.id, path: t.page.path, text: `${STATE_LABEL[p.state]} → ${STATE_LABEL[t.state]}` });
|
|
954
|
+
else if (sig(p) !== sig(t)) changes.push({ id: t.id, path: t.page.path, text: 'dependencies changed' });
|
|
955
|
+
else if (prevTexts.get(p.page.path) !== texts.get(t.page.path)) changes.push({ id: t.id, path: t.page.path, text: 'content changed' });
|
|
956
|
+
}
|
|
957
|
+
for (const p of prev.tasks.values()) if (!taskOf(vault, p.id)) changes.push({ id: p.id, path: null, text: 'removed' });
|
|
958
|
+
let other = 0;
|
|
959
|
+
for (const [path, text] of texts) if (!taskByPath.has(path) && prevTexts.get(path) !== text) other++;
|
|
960
|
+
for (const path of prevTexts.keys()) if (!texts.has(path) && !/^tasks\//.test(path)) other++;
|
|
961
|
+
if (!changes.length && !other) return toast('Refreshed. No changes.', 2200);
|
|
962
|
+
const shown = changes.slice(0, 8).map(c => `<li><b class="mono">${esc(c.id)}</b> ${esc(c.text)}</li>`).join('');
|
|
963
|
+
const head = changes.length ? `${changes.length} task${changes.length === 1 ? '' : 's'} changed` : 'No task changes';
|
|
964
|
+
const tail = (changes.length > 8 ? `<div>… and ${changes.length - 8} more</div>` : '') + (other ? `<div>${other} other page${other === 1 ? '' : 's'} changed</div>` : '');
|
|
965
|
+
toast(`Refreshed. ${head}${changes.length ? ':' : '.'}${shown ? `<ul>${shown}</ul>` : ''}${tail}`, 6000);
|
|
966
|
+
const changed = new Set(changes.map(c => c.path));
|
|
967
|
+
document.querySelectorAll('tr.row').forEach(r => { if (changed.has(r.dataset.row)) r.classList.add('flash'); });
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ---------- Markdown: untrusted input. Raw HTML is escaped, the result is allow-listed, wikilinks are built as DOM nodes. ----------
|
|
971
|
+
const OPEN = '\uE000', CLOSE = '\uE001';
|
|
972
|
+
if (window.marked?.use) {
|
|
973
|
+
window.marked.use({ renderer: { html(token) { return esc(typeof token === 'string' ? token : (token.text ?? token.raw ?? '')); } } });
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Swaps every wikilink outside code for a placeholder that survives markdown rendering untouched.
|
|
977
|
+
// Parsing of each link is the core's (extractWikilinks), so the viewer and lint agree on what a link is.
|
|
978
|
+
function protectWikilinks(md, links) {
|
|
979
|
+
let fenced = false;
|
|
980
|
+
return md.split(/\r?\n/).map(line => {
|
|
981
|
+
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; return line; }
|
|
982
|
+
if (fenced) return line;
|
|
983
|
+
return line.split(/(`[^`]*`)/).map((part, i) => (i % 2 ? part : part.replace(/!?\[\[[^\[\]]+\]\]/g, m => {
|
|
984
|
+
const raw = m.replace(/\\\|/g, '|');
|
|
985
|
+
const parsed = extractWikilinks(raw)[0];
|
|
986
|
+
links.push(parsed || { plain: raw.replace(/^!?\[\[|\]\]$/g, '') });
|
|
987
|
+
return OPEN + (links.length - 1) + CLOSE;
|
|
988
|
+
}))).join('');
|
|
989
|
+
}).join('\n');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function wikilinkNode(l) {
|
|
993
|
+
if (l.plain != null) {
|
|
994
|
+
const s = document.createElement('span');
|
|
995
|
+
s.className = 'wl att'; s.textContent = l.plain; s.title = 'Attachment or embed; not shown in the viewer';
|
|
996
|
+
return s;
|
|
997
|
+
}
|
|
998
|
+
const label = l.alias || l.target + (l.heading ? ' › ' + l.heading : '');
|
|
999
|
+
const page = resolve(vault, l.target);
|
|
1000
|
+
if (!page) {
|
|
1001
|
+
const s = document.createElement('span');
|
|
1002
|
+
s.className = 'wl broken'; s.textContent = label; s.title = `Broken link: no page named "${l.target}"`;
|
|
1003
|
+
return s;
|
|
1004
|
+
}
|
|
1005
|
+
const a = document.createElement('a');
|
|
1006
|
+
const t = taskByPath.get(page.path);
|
|
1007
|
+
a.href = '#' + encodeURIComponent(page.name);
|
|
1008
|
+
a.dataset.page = page.path;
|
|
1009
|
+
if (t) { a.className = `chip st-${t.state}`; a.textContent = l.alias || t.id; a.title = `${t.title} · ${STATE_LABEL[t.state]}`; }
|
|
1010
|
+
else { a.className = 'wl'; a.textContent = label; a.title = String(page.data.summary || page.path); }
|
|
1011
|
+
return a;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function restoreWikilinks(root, links) {
|
|
1015
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
1016
|
+
const hits = [];
|
|
1017
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) if (n.nodeValue.includes(OPEN)) hits.push(n);
|
|
1018
|
+
for (const n of hits) {
|
|
1019
|
+
const frag = document.createDocumentFragment();
|
|
1020
|
+
n.nodeValue.split(new RegExp(`${OPEN}(\\d+)${CLOSE}`)).forEach((part, i) => {
|
|
1021
|
+
if (i % 2 === 0) { if (part) frag.append(part); }
|
|
1022
|
+
else if (links[+part]) frag.append(wikilinkNode(links[+part]));
|
|
1023
|
+
});
|
|
1024
|
+
n.replaceWith(frag);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const OK_TAGS = new Set('A B BLOCKQUOTE BR CODE DEL DIV EM H1 H2 H3 H4 H5 H6 HR I INPUT LI OL P PRE S SPAN STRONG SUB SUP TABLE TBODY TD TH THEAD TR UL'.split(' '));
|
|
1029
|
+
const OK_ATTRS = { A: ['href', 'title'], TD: ['align'], TH: ['align'], OL: ['start'], CODE: ['class'], PRE: ['class'], INPUT: ['type', 'checked', 'disabled'] };
|
|
1030
|
+
|
|
1031
|
+
function sanitize(root) {
|
|
1032
|
+
for (const el of [...root.querySelectorAll('*')]) {
|
|
1033
|
+
if (el.tagName === 'IMG') {
|
|
1034
|
+
const s = document.createElement('span');
|
|
1035
|
+
s.className = 'wl att'; s.textContent = `image: ${el.getAttribute('alt') || el.getAttribute('src') || ''}`; s.title = el.getAttribute('src') || '';
|
|
1036
|
+
el.replaceWith(s);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (!OK_TAGS.has(el.tagName)) { el.replaceWith(document.createTextNode(el.textContent)); continue; }
|
|
1040
|
+
const keep = OK_ATTRS[el.tagName] || [];
|
|
1041
|
+
for (const a of [...el.attributes]) if (!keep.includes(a.name)) el.removeAttribute(a.name);
|
|
1042
|
+
if (el.tagName === 'INPUT') { if (el.getAttribute('type') !== 'checkbox') el.remove(); else el.disabled = true; }
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Resolves a relative link against the linking page's folder; the result is relative to docs/.
|
|
1047
|
+
function relPath(fromPath, href) {
|
|
1048
|
+
let h = href.split('#')[0].split('?')[0];
|
|
1049
|
+
try { h = decodeURI(h); } catch {}
|
|
1050
|
+
const out = h.startsWith('/') ? [] : fromPath.split('/').slice(0, -1);
|
|
1051
|
+
let up = 0;
|
|
1052
|
+
for (const p of h.split('/')) {
|
|
1053
|
+
if (p === '..') { if (out.length) out.pop(); else up++; }
|
|
1054
|
+
else if (p && p !== '.') out.push(p);
|
|
1055
|
+
}
|
|
1056
|
+
return '../'.repeat(up) + out.join('/');
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function wireLinks(root, page) {
|
|
1060
|
+
root.querySelectorAll('a[href]').forEach(a => {
|
|
1061
|
+
const href = a.getAttribute('href').replace(/[\uE000\uE001]/g, '');
|
|
1062
|
+
if (/^(https?:|mailto:)/i.test(href)) {
|
|
1063
|
+
a.setAttribute('href', href); a.target = '_blank'; a.rel = 'noopener noreferrer';
|
|
1064
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) {
|
|
1065
|
+
// javascript:, data:, file: and friends are never navigable from here.
|
|
1066
|
+
a.removeAttribute('href'); a.className = 'rel'; a.title = href;
|
|
1067
|
+
} else if (href.startsWith('#')) {
|
|
1068
|
+
a.removeAttribute('href');
|
|
1069
|
+
} else {
|
|
1070
|
+
const path = relPath(page.path, href);
|
|
1071
|
+
const target = pageByPath.get(path);
|
|
1072
|
+
if (target) { a.setAttribute('href', '#' + encodeURIComponent(target.name)); a.dataset.page = target.path; a.className = 'wl'; }
|
|
1073
|
+
else { a.removeAttribute('href'); a.className = 'rel'; a.title = `${path} (a local file; the browser cannot open it from here)`; }
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function renderMd(md, page) {
|
|
1079
|
+
const links = [];
|
|
1080
|
+
const src = protectWikilinks(String(md ?? '').replace(/[\uE000\uE001]/g, ''), links);
|
|
1081
|
+
const tpl = document.createElement('template');
|
|
1082
|
+
tpl.innerHTML = window.marked?.parse ? window.marked.parse(src, { gfm: true }) : `<pre class="plain">${esc(src)}</pre>`;
|
|
1083
|
+
sanitize(tpl.content);
|
|
1084
|
+
wireLinks(tpl.content, page);
|
|
1085
|
+
restoreWikilinks(tpl.content, links);
|
|
1086
|
+
const d = document.createElement('div');
|
|
1087
|
+
d.className = 'prose';
|
|
1088
|
+
d.append(tpl.content);
|
|
1089
|
+
if (!d.textContent.trim()) d.innerHTML = '<span class="muted">(empty)</span>';
|
|
1090
|
+
return d;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---------- Filtering & sorting ----------
|
|
1094
|
+
const T = id => taskOf(vault, id);
|
|
1095
|
+
const same = (a, b) => lower(a) === lower(b);
|
|
1096
|
+
const typeOf = p => String(asList(p.data.type).join(', '));
|
|
1097
|
+
const areaColor = code => `var(--a-${Math.max(0, areas.indexOf(code)) % 8})`;
|
|
1098
|
+
const depRefs = t => [...t.deps.map(id => ({ id, soft: false })), ...t.softDeps.map(id => ({ id, soft: true }))];
|
|
1099
|
+
// The core lists hard and soft dependents together; a dependent is soft when it does not hard-depend on this task.
|
|
1100
|
+
const dependentRefs = t => t.dependents.map(id => ({ id, soft: !T(id)?.deps.some(d => same(d, t.id)) }));
|
|
1101
|
+
const idNum = id => Number(String(id).split('-').pop());
|
|
1102
|
+
const byId = (a, b) => a.area.localeCompare(b.area) || idNum(a.id) - idNum(b.id) || a.id.localeCompare(b.id);
|
|
1103
|
+
const words = () => lower(ui.filters.q).split(/\s+/).filter(Boolean);
|
|
1104
|
+
const view = () => (vault.tasks.size ? ui.view : 'wiki');
|
|
1105
|
+
|
|
1106
|
+
function matches(t, skip) {
|
|
1107
|
+
const f = ui.filters;
|
|
1108
|
+
if (skip !== 'areas' && f.areas.length && !f.areas.includes(t.area)) return false;
|
|
1109
|
+
if (skip !== 'states' && f.states.length && !f.states.includes(t.state)) return false;
|
|
1110
|
+
if (skip !== 'milestones' && f.milestones.length && !f.milestones.includes(String(t.milestone))) return false;
|
|
1111
|
+
if (f.q) {
|
|
1112
|
+
const h = hay.get(t.page.path) || '';
|
|
1113
|
+
if (!words().every(w => h.includes(w))) return false;
|
|
1114
|
+
}
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const SORTS = {
|
|
1119
|
+
id: byId,
|
|
1120
|
+
wave: taskOrder,
|
|
1121
|
+
state: (a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || taskOrder(a, b),
|
|
1122
|
+
milestone: (a, b) => (a.milestone ? 0 : 1) - (b.milestone ? 0 : 1) || String(a.milestone).localeCompare(String(b.milestone), undefined, { numeric: true }) || taskOrder(a, b),
|
|
1123
|
+
priority: (a, b) => (a.priority == null ? 1 : 0) - (b.priority == null ? 1 : 0) || (a.priority ?? 0) - (b.priority ?? 0) || taskOrder(a, b),
|
|
1124
|
+
blocks: (a, b) => b.dependents.length - a.dependents.length || taskOrder(a, b),
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
function visibleTasks() {
|
|
1128
|
+
const s = SORTS[ui.sort.key] || SORTS.wave;
|
|
1129
|
+
return [...vault.tasks.values()].filter(t => matches(t)).sort((a, b) => s(a, b) * ui.sort.dir);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ---------- Rendering ----------
|
|
1133
|
+
function chip(id, soft) {
|
|
1134
|
+
const t = T(id);
|
|
1135
|
+
if (!t) return `<span class="chip st-unknown${soft ? ' soft' : ''}" title="Unknown task">${soft ? '~' : ''}${esc(id)}</span>`;
|
|
1136
|
+
return `<button class="chip st-${t.state}${soft ? ' soft' : ''}" data-page="${esc(t.page.path)}" title="${esc(`${t.title} · ${STATE_LABEL[t.state]}`)}">${soft ? '~' : ''}${esc(t.id)}</button>`;
|
|
1137
|
+
}
|
|
1138
|
+
function chips(refs, max = 99) {
|
|
1139
|
+
if (!refs.length) return '<span class="muted">—</span>';
|
|
1140
|
+
const shown = refs.slice(0, max).map(r => chip(r.id, r.soft)).join('');
|
|
1141
|
+
return `<div class="chips">${shown}${refs.length > max ? `<span class="more">+${refs.length - max}</span>` : ''}</div>`;
|
|
1142
|
+
}
|
|
1143
|
+
function pageChip(p) {
|
|
1144
|
+
const t = taskByPath.get(p.path);
|
|
1145
|
+
if (t) return chip(t.id);
|
|
1146
|
+
return `<button class="chip pg" data-page="${esc(p.path)}" title="${esc(p.data.summary || p.path)}">${esc(p.name)}</button>`;
|
|
1147
|
+
}
|
|
1148
|
+
const stateBadge = t => `<span class="badge st-${t.state}">${STATE_LABEL[t.state]}</span>`;
|
|
1149
|
+
const areaTag = code => `<span class="atag" style="color:${areaColor(code)}">${esc(code)}</span>`;
|
|
1150
|
+
|
|
1151
|
+
function renderAll() {
|
|
1152
|
+
renderSummary(); renderWarnings(); renderFilters(); renderView();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function renderSummary() {
|
|
1156
|
+
$('#summary').hidden = !vault.tasks.size;
|
|
1157
|
+
const card = (code, c, on, total) => {
|
|
1158
|
+
const live = c.total - c.cancelled || 1;
|
|
1159
|
+
const pct = n => (100 * n / live).toFixed(1) + '%';
|
|
1160
|
+
return `<button class="area-card${on ? ' on' : ''}" ${total ? 'data-allareas' : `data-area="${esc(code)}"`}>
|
|
1161
|
+
<div class="k">${total ? 'Total' : `<i class="dot" style="background:${areaColor(code)}"></i>${esc(code || '(no area)')}${config.areas && config.areas[code] && config.areas[code] !== code ? ` <span class="muted" style="font-weight:400">${esc(config.areas[code])}</span>` : ''}`}<span class="n" title="done / not cancelled">${c.done}/${c.total - c.cancelled}</span></div>
|
|
1162
|
+
<div class="bar"><i class="d" style="width:${pct(c.done)}"></i><i class="p" style="width:${pct(c.progress)}"></i><i class="r" style="width:${pct(c.ready)}"></i></div>
|
|
1163
|
+
<div class="s"><span>${c.progress} in progress</span><span>${c.ready} ready</span><span>${c.blocked} blocked</span>${c.cancelled ? `<span>${c.cancelled} cancelled</span>` : ''}</div>
|
|
1164
|
+
</button>`;
|
|
1165
|
+
};
|
|
1166
|
+
$('#summary').innerHTML = (sum.areas.size > 1 ? [...sum.areas].map(([code, c]) => card(code, c, ui.filters.areas.includes(code), false)).join('') : '')
|
|
1167
|
+
+ card('', sum.total, !ui.filters.areas.length, true);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function renderWarnings() {
|
|
1171
|
+
const errors = findings.filter(f => f.level === 'error').length, warns = findings.length - errors;
|
|
1172
|
+
const btn = $('#warnBtn');
|
|
1173
|
+
btn.hidden = !findings.length;
|
|
1174
|
+
btn.className = 'btn ' + (errors ? 'bad' : 'warn');
|
|
1175
|
+
btn.textContent = '⚠ ' + [errors && `${errors} error${errors === 1 ? '' : 's'}`, warns && `${warns} warning${warns === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
|
|
1176
|
+
$('#warnlist').hidden = !findings.length || !ui.showWarn;
|
|
1177
|
+
if ($('#warnlist').hidden) return;
|
|
1178
|
+
// lint() already returns errors before warnings.
|
|
1179
|
+
$('#warnlist').innerHTML = `<b>Lint findings</b> <span class="muted">(information only; the viewer never changes a file)</span><ul>${findings.map(f => `<li>
|
|
1180
|
+
<span class="lv ${f.level === 'error' ? 'error' : 'warn'}">${esc(f.level)}</span>
|
|
1181
|
+
${pageByPath.has(f.path) ? `<button class="fpath" data-page="${esc(f.path)}">${esc(f.path)}</button>` : `<span class="fpath">${esc(f.path)}</span>`}
|
|
1182
|
+
<span>${esc(f.message)}</span> <span class="fcode">${esc(f.code)}</span></li>`).join('')}</ul>`;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function renderFilters() {
|
|
1186
|
+
const f = ui.filters, all = [...vault.tasks.values()], v = view(), has = all.length > 0;
|
|
1187
|
+
const group = (el, key, options, val) => {
|
|
1188
|
+
$(el).innerHTML = options.map(([value, label]) => {
|
|
1189
|
+
const n = all.filter(t => matches(t, key) && val(t) === value).length;
|
|
1190
|
+
return `<button class="fchip${f[key].includes(value) ? ' on' : ''}" data-f="${key}" data-v="${esc(value)}">${esc(label)} <span class="c">${n}</span></button>`;
|
|
1191
|
+
}).join('');
|
|
1192
|
+
};
|
|
1193
|
+
const tasksView = has && v !== 'wiki';
|
|
1194
|
+
$('#rowState').hidden = !tasksView;
|
|
1195
|
+
// A single-project vault has one area; its card and filter would only repeat the total.
|
|
1196
|
+
$('#rowArea').hidden = !tasksView || areas.length < 2;
|
|
1197
|
+
$('#rowMilestone').hidden = !tasksView || !milestones.length;
|
|
1198
|
+
if (tasksView) {
|
|
1199
|
+
group('#fState', 'states', STATE_LIST, t => t.state);
|
|
1200
|
+
group('#fArea', 'areas', areas.map(a => [a, a || '(no area)']), t => t.area);
|
|
1201
|
+
const none = all.some(t => !t.milestone);
|
|
1202
|
+
group('#fMilestone', 'milestones', [...milestones.map(m => [m, m]), ...(none ? [['', 'No milestone']] : [])], t => String(t.milestone));
|
|
1203
|
+
}
|
|
1204
|
+
if (document.activeElement !== $('#q')) $('#q').value = f.q;
|
|
1205
|
+
$('#q').placeholder = v === 'wiki' ? 'Search wiki: name, type, summary or text ( / )' : 'Search: ID, title or text ( / )';
|
|
1206
|
+
$('#viewTabs').hidden = !has;
|
|
1207
|
+
document.querySelectorAll('#viewTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.view === v));
|
|
1208
|
+
$('#groupSel').value = ui.group;
|
|
1209
|
+
$('#groupWrap').hidden = v !== 'list';
|
|
1210
|
+
$('#chainSel').value = ui.chain;
|
|
1211
|
+
$('#chainWrap').hidden = v !== 'waves';
|
|
1212
|
+
$('#legend').hidden = v === 'wiki';
|
|
1213
|
+
$('#rootLinks').hidden = v !== 'wiki';
|
|
1214
|
+
$('#rootLinks').innerHTML = [vault.index, vault.log].filter(Boolean).map(pageChip).join(' ');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function renderView() {
|
|
1218
|
+
const v = view();
|
|
1219
|
+
$('#listView').hidden = v !== 'list';
|
|
1220
|
+
$('#waveView').hidden = v !== 'waves';
|
|
1221
|
+
$('#wikiView').hidden = v !== 'wiki';
|
|
1222
|
+
if (v === 'wiki') return renderWiki();
|
|
1223
|
+
const list = visibleTasks();
|
|
1224
|
+
$('#count').textContent = `${list.length} / ${vault.tasks.size} tasks`;
|
|
1225
|
+
if (v === 'list') renderList(list); else renderWaves(list);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function groupKey(t) {
|
|
1229
|
+
switch (ui.group) {
|
|
1230
|
+
case 'area': return [areas.indexOf(t.area), t.area || '(no area)'];
|
|
1231
|
+
case 'wave': return [t.wave, `Wave ${t.wave}`];
|
|
1232
|
+
case 'milestone': return t.milestone ? [milestones.indexOf(String(t.milestone)), String(t.milestone)] : [Infinity, 'No milestone'];
|
|
1233
|
+
case 'state': return [STATE_ORDER[t.state], STATE_LABEL[t.state]];
|
|
1234
|
+
default: return [0, ''];
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const COLS = 9;
|
|
1239
|
+
function renderList(list) {
|
|
1240
|
+
if (!list.length) { $('#listView').innerHTML = `<div class="tablewrap"><div class="nores">No task matches the filters.</div></div>`; return; }
|
|
1241
|
+
const th = (key, label) => {
|
|
1242
|
+
const on = ui.sort.key === key;
|
|
1243
|
+
return `<th class="sortable" data-sort="${key}">${label}${on ? ` <span class="ar">${ui.sort.dir > 0 ? '↑' : '↓'}</span>` : ''}</th>`;
|
|
1244
|
+
};
|
|
1245
|
+
const row = t => `<tr class="row${ui.selected === t.page.path ? ' sel' : ''}" data-row="${esc(t.page.path)}">
|
|
1246
|
+
<td class="id">${chip(t.id)} ${areaTag(t.area)}</td>
|
|
1247
|
+
<td class="title"><span class="t">${esc(t.title) || '<span class="muted">(no title)</span>'}</span>${t.plan ? '<span class="tag">plan</span>' : ''}</td>
|
|
1248
|
+
<td>${stateBadge(t)}</td>
|
|
1249
|
+
<td class="num">${esc(t.milestone) || '<span class="muted">—</span>'}</td>
|
|
1250
|
+
<td class="num">${t.priority ?? '<span class="muted">—</span>'}</td>
|
|
1251
|
+
<td class="num">${t.wave}</td>
|
|
1252
|
+
<td class="deps">${chips(depRefs(t), 6)}</td>
|
|
1253
|
+
<td class="deps">${chips(dependentRefs(t), 6)}</td>
|
|
1254
|
+
<td class="dates">${esc(t.started)}${t.finished ? ` → ${esc(t.finished)}` : ''}</td>
|
|
1255
|
+
</tr>`;
|
|
1256
|
+
let body = '';
|
|
1257
|
+
if (ui.group === 'none') body = list.map(row).join('');
|
|
1258
|
+
else {
|
|
1259
|
+
const groups = new Map();
|
|
1260
|
+
for (const t of list) { const [k, label] = groupKey(t); if (!groups.has(label)) groups.set(label, { k, items: [] }); groups.get(label).items.push(t); }
|
|
1261
|
+
body = [...groups.entries()].sort((a, b) => a[1].k - b[1].k).map(([label, g]) => {
|
|
1262
|
+
const c = { done: 0, progress: 0, ready: 0, blocked: 0, cancelled: 0 };
|
|
1263
|
+
g.items.forEach(t => c[t.state]++);
|
|
1264
|
+
return `<tr class="grp"><td colspan="${COLS}">${esc(label)}<span class="gm">${g.items.length} task${g.items.length === 1 ? '' : 's'} · ${c.done} done · ${c.progress} in progress · ${c.ready} ready</span></td></tr>` + g.items.map(row).join('');
|
|
1265
|
+
}).join('');
|
|
1266
|
+
}
|
|
1267
|
+
$('#listView').innerHTML = `<div class="tablewrap"><table>
|
|
1268
|
+
<thead><tr>${th('id', 'ID')}<th>Task</th>${th('state', 'State')}${th('milestone', 'Milestone')}${th('priority', 'Priority')}${th('wave', 'Wave')}<th>Depends on</th>${th('blocks', 'Blocks')}<th>Started → Finished</th></tr></thead>
|
|
1269
|
+
<tbody>${body}</tbody></table></div>`;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function renderWaves(list) {
|
|
1273
|
+
const visible = new Set(list.map(t => t.id));
|
|
1274
|
+
const showCancelled = ui.filters.states.includes('cancelled');
|
|
1275
|
+
const byWave = new Map();
|
|
1276
|
+
for (const t of vault.tasks.values()) {
|
|
1277
|
+
if (t.state === 'cancelled' && !showCancelled) continue;
|
|
1278
|
+
if (!byWave.has(t.wave)) byWave.set(t.wave, []);
|
|
1279
|
+
byWave.get(t.wave).push(t);
|
|
1280
|
+
}
|
|
1281
|
+
const cols = [...byWave.keys()].sort((a, b) => a - b).map(w => {
|
|
1282
|
+
const ts = byWave.get(w).sort(taskOrder);
|
|
1283
|
+
const done = ts.filter(t => t.state === 'done').length;
|
|
1284
|
+
return `<div class="wcol"><h3><span>Wave ${w}</span><span>${done}/${ts.length}</span></h3>
|
|
1285
|
+
${ts.map(t => `<div class="card st-${t.state}${visible.has(t.id) ? '' : ' dim'}" data-card="${esc(t.id)}">
|
|
1286
|
+
<div class="h"><span class="mono">${esc(t.id)}</span>${t.state === 'ready' ? '<span class="rd">ready</span>' : t.state === 'progress' ? '<span class="rd" style="color:var(--prog)">in progress</span>' : ''}</div>
|
|
1287
|
+
<div class="tt">${esc(t.title)}</div></div>`).join('')}</div>`;
|
|
1288
|
+
}).join('');
|
|
1289
|
+
$('#waveView').innerHTML = `<div class="waves" id="waves"><div class="waves-in" id="wavesIn">${cols}<svg id="edges"></svg></div></div>
|
|
1290
|
+
<p class="hint">A wave is the longest chain of hard dependencies below a task. Drag the board to move around it. Hover a card to light up everything it depends on and everything it blocks. Click to open it and pin the highlight. Cards outside the filters are faded${showCancelled ? '' : '; cancelled tasks are left out unless the Cancelled filter is on'}.</p>`;
|
|
1291
|
+
highlight();
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function chainOf(t) {
|
|
1295
|
+
const up = new Set(), down = new Set();
|
|
1296
|
+
const walkRefs = (cur, refs, set) => {
|
|
1297
|
+
for (const r of refs(cur)) { const n = T(r.id); if (n && !set.has(n.id)) { set.add(n.id); walkRefs(n, refs, set); } }
|
|
1298
|
+
};
|
|
1299
|
+
walkRefs(t, depRefs, up); walkRefs(t, dependentRefs, down);
|
|
1300
|
+
return { up, down };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function highlight() {
|
|
1304
|
+
const wrap = $('#waves'), svg = $('#edges'), inner = $('#wavesIn');
|
|
1305
|
+
if (!wrap || $('#waveView').hidden) return;
|
|
1306
|
+
const focus = ui.hover ? T(ui.hover) : (ui.selected && taskByPath.get(ui.selected)) || null;
|
|
1307
|
+
const all = new Map();
|
|
1308
|
+
wrap.querySelectorAll('.card').forEach(c => { c.classList.remove('hl', 'hl-self'); all.set(c.dataset.card, c); });
|
|
1309
|
+
wrap.classList.toggle('focus', !!(focus && all.has(focus.id)));
|
|
1310
|
+
svg.innerHTML = '';
|
|
1311
|
+
if (!focus || !all.has(focus.id)) return;
|
|
1312
|
+
const ids = refs => new Set(refs.map(r => T(r.id)?.id).filter(Boolean));
|
|
1313
|
+
const { up, down } = ui.chain === 'direct' ? { up: ids(depRefs(focus)), down: ids(dependentRefs(focus)) } : chainOf(focus);
|
|
1314
|
+
const set = new Set([focus.id, ...up, ...down]);
|
|
1315
|
+
const cards = new Map();
|
|
1316
|
+
set.forEach(id => { const el = all.get(id); if (el) { el.classList.add('hl'); cards.set(id, el); } });
|
|
1317
|
+
cards.get(focus.id).classList.add('hl-self');
|
|
1318
|
+
svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
|
|
1319
|
+
const base = inner.getBoundingClientRect();
|
|
1320
|
+
const box = el => { const r = el.getBoundingClientRect(); return { l: r.left - base.left, r: r.right - base.left, y: r.top - base.top + r.height / 2 }; };
|
|
1321
|
+
let paths = '';
|
|
1322
|
+
for (const id of set) {
|
|
1323
|
+
const to = cards.get(id); if (!to) continue;
|
|
1324
|
+
for (const d of depRefs(T(id))) {
|
|
1325
|
+
const dep = T(d.id)?.id;
|
|
1326
|
+
if (!dep || !cards.get(dep)) continue;
|
|
1327
|
+
// Only edges on the focused chain: upstream edges into up ∪ focus, downstream edges out of focus ∪ down.
|
|
1328
|
+
const onUp = up.has(dep) && (up.has(id) || id === focus.id);
|
|
1329
|
+
const onDown = down.has(id) && (down.has(dep) || dep === focus.id);
|
|
1330
|
+
if (!onUp && !onDown) continue;
|
|
1331
|
+
const a = box(cards.get(dep)), b = box(to);
|
|
1332
|
+
const x1 = a.r, x2 = b.l, dx = Math.max(30, Math.abs(x2 - x1) / 2);
|
|
1333
|
+
paths += `<path class="edge${d.soft ? ' soft' : ''}" d="M${x1},${a.y} C${x1 + dx},${a.y} ${x2 - dx},${b.y} ${x2},${b.y}"/>`;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
svg.innerHTML = paths;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function renderWiki() {
|
|
1340
|
+
const all = vault.pages.filter(p => p.folder === 'wiki');
|
|
1341
|
+
const w = words();
|
|
1342
|
+
const list = w.length ? all.filter(p => { const h = hay.get(p.path) || ''; return w.every(x => h.includes(x)); }) : all;
|
|
1343
|
+
$('#count').textContent = `${list.length} / ${all.length} pages`;
|
|
1344
|
+
if (!list.length) { $('#wikiView').innerHTML = `<div class="wiki"><div class="nores">${all.length ? 'No wiki page matches the search.' : 'This vault has no wiki pages yet.'}</div></div>`; return; }
|
|
1345
|
+
const groups = new Map();
|
|
1346
|
+
for (const p of list) { const k = typeOf(p) || '(no type)'; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
|
|
1347
|
+
$('#wikiView').innerHTML = `<div class="wiki">${[...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([type, pages]) =>
|
|
1348
|
+
`<h3>${esc(type)} <span class="c">${pages.length}</span></h3>` + pages.map(p => `<button class="wrow${ui.selected === p.path ? ' sel' : ''}" data-page="${esc(p.path)}">
|
|
1349
|
+
<span class="wn">${esc(p.name)}</span>
|
|
1350
|
+
<span class="ws">${esc(p.data.summary) || '<span class="muted">(no summary)</span>'}</span>
|
|
1351
|
+
<span class="wi" title="Pages linking here">← ${p.inbound.length}</span></button>`).join('')).join('')}</div>`;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ---------- Drawer ----------
|
|
1355
|
+
function markSelected() {
|
|
1356
|
+
document.querySelectorAll('tr.row').forEach(r => r.classList.toggle('sel', r.dataset.row === ui.selected));
|
|
1357
|
+
document.querySelectorAll('.wrow').forEach(r => r.classList.toggle('sel', r.dataset.page === ui.selected));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function openPage(path, opts = {}) {
|
|
1361
|
+
let page = pageByPath.get(path);
|
|
1362
|
+
if (!page) return;
|
|
1363
|
+
let tab = opts.tab || 'main';
|
|
1364
|
+
// A plan is shown as the second tab of the task it belongs to.
|
|
1365
|
+
const owner = planOwner.get(page.path);
|
|
1366
|
+
if (owner) { page = owner.page; tab = 'plan'; }
|
|
1367
|
+
if (opts.push !== false && ui.selected && (ui.selected !== page.path || ui.dtab !== tab)) ui.history.push({ path: ui.selected, tab: ui.dtab });
|
|
1368
|
+
ui.selected = page.path; ui.dtab = tab;
|
|
1369
|
+
history.replaceState(null, '', '#' + encodeURIComponent(tab === 'plan' ? taskByPath.get(page.path).plan.name : page.name));
|
|
1370
|
+
renderDrawer();
|
|
1371
|
+
$('#drawer').classList.add('open'); $('#drawer').setAttribute('aria-hidden', 'false');
|
|
1372
|
+
$('#scrim').hidden = window.innerWidth > 1200;
|
|
1373
|
+
markSelected();
|
|
1374
|
+
highlight();
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function closeDrawer() {
|
|
1378
|
+
ui.selected = null; ui.history = []; ui.dtab = 'main';
|
|
1379
|
+
$('#drawer').classList.remove('open'); $('#drawer').setAttribute('aria-hidden', 'true');
|
|
1380
|
+
$('#scrim').hidden = true;
|
|
1381
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
1382
|
+
markSelected();
|
|
1383
|
+
highlight();
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function editorHref(path) {
|
|
1387
|
+
const root = String(store.get('docsPath', '') || '').trim();
|
|
1388
|
+
if (!root) return null;
|
|
1389
|
+
const abs = root.replace(/\\/g, '/').replace(/\/+$/, '') + '/' + path;
|
|
1390
|
+
const scheme = EDITORS[store.get('editor', 'vscode')] || 'vscode';
|
|
1391
|
+
return `${scheme}://file${abs.startsWith('/') ? '' : '/'}${encodeURI(abs).replace(/[?#]/g, encodeURIComponent)}`;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function treeRow(r) {
|
|
1395
|
+
const t = T(r.id);
|
|
1396
|
+
return `${chip(r.id, r.soft)}<span class="tl">${esc(t ? t.title : 'Unknown task')}</span><span class="sm">${t ? `${STATE_LABEL[t.state]} · W${t.wave}` : ''}</span>`;
|
|
1397
|
+
}
|
|
1398
|
+
function treeHtml(refs, dir) {
|
|
1399
|
+
if (!refs.length) return '<div class="muted">—</div>';
|
|
1400
|
+
return refs.map(r => {
|
|
1401
|
+
const t = T(r.id);
|
|
1402
|
+
const kids = t ? (dir === 'deps' ? depRefs(t) : dependentRefs(t)) : [];
|
|
1403
|
+
return kids.length
|
|
1404
|
+
? `<details data-tree="${esc(r.id)}" data-dir="${dir}"><summary>${treeRow(r)}</summary><div></div></details>`
|
|
1405
|
+
: `<div class="leaf">${treeRow(r)}</div>`;
|
|
1406
|
+
}).join('');
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const cell = (label, v) => `<div><label>${esc(label)}</label><div class="v">${v || '—'}</div></div>`;
|
|
1410
|
+
const SKIP_FIELDS = ['summary', 'title'];
|
|
1411
|
+
const PLAIN_FIELDS = ['type', 'status', 'id'];
|
|
1412
|
+
|
|
1413
|
+
// Frontmatter values that name another page (task: M-01, a sources list) become links.
|
|
1414
|
+
function fieldValue(k, v) {
|
|
1415
|
+
return asList(v).map(x => {
|
|
1416
|
+
const target = PLAIN_FIELDS.includes(k) ? null : resolve(vault, x);
|
|
1417
|
+
return target ? pageChip(target) : esc(x);
|
|
1418
|
+
}).join(', ');
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function linkedFrom(page) {
|
|
1422
|
+
return `<div class="sec"><h4>Linked from <span class="muted">${page.inbound.length} page${page.inbound.length === 1 ? '' : 's'}</span></h4>${page.inbound.length ? `<div class="chips">${page.inbound.map(pageChip).join('')}</div>` : '<div class="muted">—</div>'}</div>`;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Frontmatter fields, rendered body and backlinks: wiki pages, plans, index.md and log.md.
|
|
1426
|
+
function pageSections(page, heading) {
|
|
1427
|
+
const el = document.createElement('div');
|
|
1428
|
+
const fields = Object.entries(page.data).filter(([k, v]) => !SKIP_FIELDS.includes(k) && v != null && v !== '' && !(Array.isArray(v) && !v.length));
|
|
1429
|
+
el.innerHTML = `
|
|
1430
|
+
${heading ? `<h2 class="dtitle">${esc(page.data.title || page.name)}</h2>` : ''}
|
|
1431
|
+
${page.data.summary ? `<p class="lead">${esc(page.data.summary)}</p>` : ''}
|
|
1432
|
+
${fields.length ? `<div class="grid">${fields.map(([k, v]) => cell(k, fieldValue(k, v))).join('')}</div>` : ''}
|
|
1433
|
+
<div class="sec"><h4>${page.folder === 'plans' ? 'Plan' : 'Page'} <span class="muted">${esc(page.path)}</span></h4><div class="n-body"></div></div>
|
|
1434
|
+
${linkedFrom(page)}`;
|
|
1435
|
+
el.querySelector('.n-body').append(renderMd(page.body, page));
|
|
1436
|
+
return el;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function hardOpenAncestors(t, set = new Set()) {
|
|
1440
|
+
for (const id of t.deps) { const d = T(id); if (d && !set.has(d.id)) { set.add(d.id); hardOpenAncestors(d, set); } }
|
|
1441
|
+
return [...set].filter(id => T(id).state !== 'done');
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function taskSections(t) {
|
|
1445
|
+
const { up, down } = chainOf(t);
|
|
1446
|
+
const deps = depRefs(t), dependents = dependentRefs(t);
|
|
1447
|
+
const frees = dependents.filter(r => { const d = T(r.id); return !r.soft && d.state !== 'done' && d.state !== 'cancelled' && d.openDeps.length === 1 && same(d.openDeps[0], t.id); });
|
|
1448
|
+
const open = hardOpenAncestors(t);
|
|
1449
|
+
|
|
1450
|
+
let callout = '';
|
|
1451
|
+
if (t.state === 'done') callout = `<div class="callout done">✓ Done ${t.finished ? `(${esc(t.finished)})` : ''}</div>`;
|
|
1452
|
+
else if (t.state === 'cancelled') callout = `<div class="callout wait">Cancelled. The file stays on disk and keeps its number.</div>`;
|
|
1453
|
+
else if (t.state === 'ready') callout = `<div class="callout ok">✓ Ready: every hard dependency is done.</div>`;
|
|
1454
|
+
else if (t.state === 'blocked') callout = `<div class="callout wait">Blocked by ${t.openDeps.map(id => chip(id)).join(' ')} ${open.length > t.openDeps.length ? `<span class="muted">· ${open.length} unfinished tasks in the chain</span>` : ''}</div>`;
|
|
1455
|
+
else if (t.state === 'progress') callout = `<div class="callout prog">In progress ${t.started ? `(since ${esc(t.started)})` : ''}</div>`;
|
|
1456
|
+
if (t.openSoftDeps.length && t.state !== 'done' && t.state !== 'cancelled') callout += `<div class="callout soft">Cannot finish before these soft dependencies: ${t.openSoftDeps.map(id => chip(id, true)).join(' ')}</div>`;
|
|
1457
|
+
if (frees.length && t.state !== 'done' && t.state !== 'cancelled') callout += `<div class="callout plain">Finishing this unblocks: ${frees.map(r => chip(r.id)).join(' ')}</div>`;
|
|
1458
|
+
|
|
1459
|
+
const el = document.createElement('div');
|
|
1460
|
+
el.innerHTML = `
|
|
1461
|
+
<div class="grid">
|
|
1462
|
+
${cell('State', stateBadge(t))}
|
|
1463
|
+
${cell('Status', esc(t.status))}
|
|
1464
|
+
${cell('Milestone', esc(t.milestone))}
|
|
1465
|
+
${cell('Priority', t.priority == null ? '' : esc(t.priority))}
|
|
1466
|
+
${cell('Wave', t.wave)}
|
|
1467
|
+
${cell('Started', esc(t.started))}
|
|
1468
|
+
${cell('Finished', esc(t.finished))}
|
|
1469
|
+
${cell('Chain', `${up.size} ↑ · ${down.size} ↓`)}
|
|
1470
|
+
</div>
|
|
1471
|
+
${callout}
|
|
1472
|
+
<div class="sec"><h4>Depends on <span class="muted">direct; open ▸ to follow the chain; dashed = soft</span></h4><div class="tree">${treeHtml(deps, 'deps')}</div></div>
|
|
1473
|
+
<div class="sec"><h4>Blocks <span class="muted">tasks waiting on this one</span></h4><div class="tree">${treeHtml(dependents, 'dependents')}</div></div>
|
|
1474
|
+
<div class="sec"><h4>Details <span class="muted">${esc(t.page.path)}</span></h4><div class="n-body"></div></div>
|
|
1475
|
+
${linkedFrom(t.page)}`;
|
|
1476
|
+
el.querySelector('.n-body').append(renderMd(t.page.body, t.page));
|
|
1477
|
+
return el;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function renderDrawer() {
|
|
1481
|
+
const page = pageByPath.get(ui.selected);
|
|
1482
|
+
if (!page) return;
|
|
1483
|
+
const t = taskByPath.get(page.path);
|
|
1484
|
+
if (ui.dtab === 'plan' && !t?.plan) ui.dtab = 'main';
|
|
1485
|
+
const shown = ui.dtab === 'plan' ? t.plan : page;
|
|
1486
|
+
$('#dId').textContent = t ? t.id : page.name;
|
|
1487
|
+
$('#dArea').textContent = t ? '' : page.folder === 'root' ? '' : typeOf(page) || page.folder;
|
|
1488
|
+
$('#backBtn').disabled = !ui.history.length;
|
|
1489
|
+
const href = editorHref(shown.path);
|
|
1490
|
+
$('#dOpen').hidden = !href;
|
|
1491
|
+
if (href) $('#dOpen').href = href; else $('#dOpen').removeAttribute('href');
|
|
1492
|
+
|
|
1493
|
+
const body = document.createElement('div');
|
|
1494
|
+
if (t) {
|
|
1495
|
+
const head = document.createElement('div');
|
|
1496
|
+
head.innerHTML = `<h2 class="dtitle">${esc(t.title) || '<span class="muted">(no title)</span>'}</h2>
|
|
1497
|
+
${t.plan ? `<div class="tabs dtabs"><button class="tab${ui.dtab === 'main' ? ' on' : ''}" data-dtab="main">Task</button><button class="tab${ui.dtab === 'plan' ? ' on' : ''}" data-dtab="plan">Plan</button></div>` : ''}`;
|
|
1498
|
+
body.append(head, ui.dtab === 'plan' ? pageSections(t.plan, false) : taskSections(t));
|
|
1499
|
+
} else {
|
|
1500
|
+
body.append(pageSections(page, true));
|
|
1501
|
+
}
|
|
1502
|
+
$('#dBody').replaceChildren(body);
|
|
1503
|
+
$('#dBody').scrollTop = 0;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// ---------- Settings ----------
|
|
1507
|
+
function openSettings() {
|
|
1508
|
+
$('#setPath').value = store.get('docsPath', '');
|
|
1509
|
+
$('#setEditor').value = store.get('editor', 'vscode');
|
|
1510
|
+
$('#setTheme').value = store.get('theme', 'auto');
|
|
1511
|
+
$('#setErr').hidden = true;
|
|
1512
|
+
$('#settings').showModal();
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
$('#settingsForm').addEventListener('submit', e => {
|
|
1516
|
+
const path = $('#setPath').value.trim();
|
|
1517
|
+
if (path && !/^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path)) {
|
|
1518
|
+
e.preventDefault();
|
|
1519
|
+
$('#setErr').hidden = false; $('#setErr').textContent = 'Enter an absolute path, for example /home/me/projects/my-repo/docs or C:\\projects\\my-repo\\docs.';
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
store.set('docsPath', path); store.set('editor', $('#setEditor').value); store.set('theme', $('#setTheme').value);
|
|
1523
|
+
applyTheme();
|
|
1524
|
+
if (ui.selected) renderDrawer();
|
|
1525
|
+
});
|
|
1526
|
+
$('#setCancel').onclick = () => $('#settings').close();
|
|
1527
|
+
$('#settingsBtn').onclick = openSettings;
|
|
1528
|
+
|
|
1529
|
+
// ---------- Events ----------
|
|
1530
|
+
function toggleIn(arr, v) { const i = arr.indexOf(v); if (i >= 0) arr.splice(i, 1); else arr.push(v); }
|
|
1531
|
+
function rerender() { saveUi(); renderSummary(); renderFilters(); renderView(); }
|
|
1532
|
+
|
|
1533
|
+
document.addEventListener('click', e => {
|
|
1534
|
+
if (!vault) return;
|
|
1535
|
+
const pg = e.target.closest('[data-page]');
|
|
1536
|
+
if (pg) { e.preventDefault(); e.stopPropagation(); return openPage(pg.dataset.page); }
|
|
1537
|
+
const card = e.target.closest('[data-card]');
|
|
1538
|
+
if (card) { const t = T(card.dataset.card); return t && openPage(t.page.path); }
|
|
1539
|
+
const row = e.target.closest('tr.row');
|
|
1540
|
+
if (row) return openPage(row.dataset.row);
|
|
1541
|
+
const f = e.target.closest('[data-f]');
|
|
1542
|
+
if (f) { toggleIn(ui.filters[f.dataset.f], f.dataset.v); return rerender(); }
|
|
1543
|
+
if (e.target.closest('[data-allareas]')) { ui.filters.areas = []; return rerender(); }
|
|
1544
|
+
const a = e.target.closest('[data-area]');
|
|
1545
|
+
if (a) {
|
|
1546
|
+
const v = a.dataset.area, cur = ui.filters.areas;
|
|
1547
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey) toggleIn(cur, v);
|
|
1548
|
+
else ui.filters.areas = cur.length === 1 && cur[0] === v ? [] : [v];
|
|
1549
|
+
return rerender();
|
|
1550
|
+
}
|
|
1551
|
+
const s = e.target.closest('[data-sort]');
|
|
1552
|
+
if (s) { const k = s.dataset.sort; ui.sort = { key: k, dir: ui.sort.key === k ? -ui.sort.dir : 1 }; return rerender(); }
|
|
1553
|
+
const tab = e.target.closest('[data-view]');
|
|
1554
|
+
if (tab) { ui.view = tab.dataset.view; return rerender(); }
|
|
1555
|
+
const dtab = e.target.closest('[data-dtab]');
|
|
1556
|
+
if (dtab && ui.selected) return openPage(ui.selected, { tab: dtab.dataset.dtab });
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
document.addEventListener('mouseover', e => {
|
|
1560
|
+
const card = e.target.closest('[data-card]');
|
|
1561
|
+
const id = card ? card.dataset.card : null;
|
|
1562
|
+
if (id !== ui.hover && (card || e.target.closest('#waves'))) { ui.hover = id; highlight(); }
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
// Dependency trees expand lazily, one level per click, so a dependency cycle cannot recurse.
|
|
1566
|
+
$('#drawer').addEventListener('toggle', e => {
|
|
1567
|
+
const d = e.target;
|
|
1568
|
+
if (!d.matches?.('details[data-tree]') || !d.open || d.lastElementChild.childElementCount) return;
|
|
1569
|
+
const t = T(d.dataset.tree), dir = d.dataset.dir;
|
|
1570
|
+
if (t) d.lastElementChild.innerHTML = treeHtml(dir === 'deps' ? depRefs(t) : dependentRefs(t), dir);
|
|
1571
|
+
}, true);
|
|
1572
|
+
|
|
1573
|
+
let qTimer;
|
|
1574
|
+
$('#q').addEventListener('input', e => { clearTimeout(qTimer); qTimer = setTimeout(() => { ui.filters.q = e.target.value.trim(); if (vault) rerender(); }, 120); });
|
|
1575
|
+
$('#clearBtn').onclick = () => { ui.filters = defaultFilters(); $('#q').value = ''; rerender(); };
|
|
1576
|
+
$('#groupSel').onchange = e => { ui.group = e.target.value; rerender(); };
|
|
1577
|
+
$('#chainSel').onchange = e => { ui.chain = e.target.value; saveUi(); highlight(); };
|
|
1578
|
+
$('#refreshBtn').onclick = refresh;
|
|
1579
|
+
$('#emptyBtn').onclick = pickFolder;
|
|
1580
|
+
$('#pickBtn').onclick = pickFolder;
|
|
1581
|
+
$('#warnBtn').onclick = () => { ui.showWarn = !ui.showWarn; renderWarnings(); };
|
|
1582
|
+
$('#closeBtn').onclick = closeDrawer;
|
|
1583
|
+
$('#scrim').onclick = closeDrawer;
|
|
1584
|
+
$('#backBtn').onclick = () => { const h = ui.history.pop(); if (h) openPage(h.path, { push: false, tab: h.tab }); };
|
|
1585
|
+
window.addEventListener('resize', () => highlight());
|
|
1586
|
+
document.addEventListener('keydown', e => {
|
|
1587
|
+
if ($('#settings').open) return;
|
|
1588
|
+
const typing = /INPUT|SELECT|TEXTAREA/.test(document.activeElement?.tagName);
|
|
1589
|
+
if (e.key === 'Escape') { if (typing) document.activeElement.blur(); else closeDrawer(); }
|
|
1590
|
+
else if (!typing && e.key === '/') { e.preventDefault(); $('#q').focus(); }
|
|
1591
|
+
else if (!typing && (e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey && !e.altKey) refresh();
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
// ---------- Start ----------
|
|
1595
|
+
function openFromHash() {
|
|
1596
|
+
let name = location.hash.slice(1);
|
|
1597
|
+
try { name = decodeURIComponent(name); } catch {}
|
|
1598
|
+
const page = name && vault ? resolve(vault, name) : null;
|
|
1599
|
+
if (page) openPage(page.path);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Drag the waves board to move around it. The board is re-rendered on every filter change, so the
|
|
1603
|
+
// listeners sit on its stable parent.
|
|
1604
|
+
(function panWaves() {
|
|
1605
|
+
const host = $('#waveView');
|
|
1606
|
+
let drag = null;
|
|
1607
|
+
host.addEventListener('pointerdown', e => {
|
|
1608
|
+
const wrap = e.target.closest('.waves');
|
|
1609
|
+
if (!wrap || e.button !== 0) return;
|
|
1610
|
+
drag = { wrap, x: e.clientX, y: e.clientY, left: wrap.scrollLeft, top: wrap.scrollTop, moved: false };
|
|
1611
|
+
});
|
|
1612
|
+
window.addEventListener('pointermove', e => {
|
|
1613
|
+
if (!drag) return;
|
|
1614
|
+
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
|
1615
|
+
// A few pixels of slack, so a click with a slightly unsteady hand still opens the card.
|
|
1616
|
+
if (!drag.moved && Math.hypot(dx, dy) < 5) return;
|
|
1617
|
+
if (!drag.moved) { drag.moved = true; drag.wrap.classList.add('panning'); }
|
|
1618
|
+
drag.wrap.scrollLeft = drag.left - dx;
|
|
1619
|
+
drag.wrap.scrollTop = drag.top - dy;
|
|
1620
|
+
});
|
|
1621
|
+
const end = () => {
|
|
1622
|
+
if (!drag) return;
|
|
1623
|
+
const { wrap, moved } = drag;
|
|
1624
|
+
drag = null;
|
|
1625
|
+
// The click that ends a drag arrives right after pointerup; keep the flag until it has been swallowed.
|
|
1626
|
+
if (moved) setTimeout(() => wrap.classList.remove('panning'), 0);
|
|
1627
|
+
};
|
|
1628
|
+
window.addEventListener('pointerup', end);
|
|
1629
|
+
window.addEventListener('pointercancel', end);
|
|
1630
|
+
host.addEventListener('click', e => { if (e.target.closest('.waves.panning')) { e.stopPropagation(); e.preventDefault(); } }, true);
|
|
1631
|
+
})();
|
|
1632
|
+
|
|
1633
|
+
(async function start() {
|
|
1634
|
+
if (await loadSnapshot()) {
|
|
1635
|
+
source = snapshotSource();
|
|
1636
|
+
await load();
|
|
1637
|
+
openFromHash();
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
if (!hasFsApi) {
|
|
1641
|
+
$('#emptyText').innerHTML += '<br><br>This browser cannot remember a folder, so Refresh asks for it again each time (pick the docs folder itself to keep that fast). In Chrome or Edge you pick it once.';
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const handle = await kv('get', 'dir:' + HERE);
|
|
1645
|
+
if (!handle) return;
|
|
1646
|
+
source = handleSource(handle);
|
|
1647
|
+
$('#emptyBtn').textContent = `Open saved folder${handle.name ? ` (${handle.name})` : ''}`;
|
|
1648
|
+
$('#emptyBtn').onclick = refresh;
|
|
1649
|
+
$('#pickBtn').hidden = false;
|
|
1650
|
+
try {
|
|
1651
|
+
if ((await handle.queryPermission({ mode: 'read' })) === 'granted') {
|
|
1652
|
+
await load();
|
|
1653
|
+
openFromHash();
|
|
1654
|
+
}
|
|
1655
|
+
} catch {}
|
|
1656
|
+
})();
|
|
1657
|
+
})();
|
|
1658
|
+
</script>
|
|
1659
|
+
</body>
|
|
1660
|
+
</html>
|