@davmash/bygone 0.9.8 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/README.md +74 -1
- package/cli/commandSpec.js +2 -0
- package/cli/completions.js +3 -3
- package/cli/present.js +41 -14
- package/cli/tour.js +22 -1
- package/cli/tourExport.js +147 -0
- package/cli/tourFile.js +109 -4
- package/cli/tourHistory.js +31 -1
- package/completions/_bygone +1 -1
- package/completions/bygone +1 -1
- package/completions/bygone.fish +1 -1
- package/docs/change-tour-format.md +85 -68
- package/docs/explore.md +14 -3
- package/docs/generating-change-tours.md +84 -46
- package/docs/present.md +103 -14
- package/docs/product-surface.md +19 -8
- package/docs/sharing-tours.md +95 -0
- package/docs/visual-walkthrough/README.md +89 -0
- package/docs/visual-walkthrough/build-tour.mjs +54 -0
- package/docs/visual-walkthrough/capture-desktop.cjs +42 -0
- package/docs/visual-walkthrough/capture-desktop.mjs +21 -0
- package/docs/visual-walkthrough/captures.md +49 -0
- package/docs/visual-walkthrough/create-demo.mjs +57 -0
- package/docs/visual-walkthrough/deconstructed-demo.bygone +100 -0
- package/docs/visual-walkthrough/images/01-two-files.png +0 -0
- package/docs/visual-walkthrough/images/02-many-panels.png +0 -0
- package/docs/visual-walkthrough/images/03-directories.png +0 -0
- package/docs/visual-walkthrough/images/04-search.png +0 -0
- package/docs/visual-walkthrough/images/05-repository-history.png +0 -0
- package/docs/visual-walkthrough/images/06-file-history.png +0 -0
- package/docs/visual-walkthrough/images/07-branch-review.png +0 -0
- package/docs/visual-walkthrough/images/08-tour-reader.png +0 -0
- package/docs/visual-walkthrough/images/09-focused-step.png +0 -0
- package/docs/visual-walkthrough/images/10-deconstructed.png +0 -0
- package/docs/visual-walkthrough/images/11-exploration.png +0 -0
- package/docs/visual-walkthrough/images/12-authoring.png +0 -0
- package/docs/visual-walkthrough/images/13-export.png +0 -0
- package/docs/visual-walkthrough/images/14-vscode.png +0 -0
- package/docs/visual-walkthrough/images/15-cli.png +0 -0
- package/docs/visual-walkthrough/narrative.md +134 -0
- package/docs/visual-walkthrough/outline.md +52 -0
- package/docs/visual-walkthrough/walkthrough.bygone +340 -0
- package/docs/walkthrough.md +6 -101
- package/examples/bygone-history.bygone +11 -44
- package/examples/marker-focus.bygone +116 -0
- package/examples/navigation-lab.bygone.yaml +401 -336
- package/examples/tour-orientation.bygone +349 -0
- package/media/diff.worker.js +13 -3
- package/media/webview.css +1 -1
- package/media/webview.js +394 -319
- package/out/changeTour.js +865 -397
- package/out/deepLink.js +167 -0
- package/out/historyDirectory.js +66 -0
- package/out/standalone-main.js +20532 -16832
- package/out/tourCoverage.js +850 -386
- package/out/tourDocument.js +97 -0
- package/package.json +1 -1
- package/schemas/change-tour-source.schema.json +91 -64
- package/skills/pr-tour-guide/SKILL.md +144 -128
- package/skills/pr-tour-guide/references/expectations-and-feedback.md +57 -0
- package/standalone/index.html +10 -25
- package/web/export-bootstrap.js +62 -0
- package/web/export-licenses.txt +78 -0
- package/web/export-runtime.json +12 -0
- package/web/index.html +54 -47
- package/web/presenter.css +332 -178
- package/web/web-host.js +239 -137
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
- Fixed desktop tours showing unstyled HTML after temporary asset cleanup during long-running app sessions
|
|
6
|
+
- Made presentation asset read failures return HTTP 500 and added HTTP and Electron ASAR regression coverage
|
|
7
|
+
|
|
8
|
+
## 0.10.0
|
|
9
|
+
|
|
10
|
+
- Restored detailed highlighting for long prose and expanded instructions; unavailable word detail now has a uniform blue fill and an explanatory `≠` marker
|
|
11
|
+
- Made line-ending and final-newline differences visible instead of reporting identical files
|
|
12
|
+
- Unified overlapping multi-panel highlights without stacked fills, with optional focus on the selected comparison pair
|
|
13
|
+
- Added comparison-panel reordering, reliable revision selection, separate windows for new comparisons, and preserved initial change reveal
|
|
14
|
+
- Added file trees and changed-file filters across views, plus single-file and blank-panel comparisons
|
|
15
|
+
- Added portable HTML tour exports, pinned screenshot evidence, clearer tour introductions and navigation, and explanation expectations and reader feedback
|
|
16
|
+
- Added 2x narration playback and stabilized expanded playback controls
|
|
17
|
+
- Improved long-line and rewrapped YAML matching, with independent field-prefix scoring and ambiguity checks
|
|
18
|
+
- Merged touching blue change regions while preserving scroll anchors, and clipped gutter connectors to editor bounds
|
|
19
|
+
- Added desktop section links for tours saved outside their repository
|
|
20
|
+
- Replaced repeated Scene context with clickable sticky scene headings and expandable overviews
|
|
21
|
+
- Made History file navigation skip unchanged files; unified workspace controls, Files/Commits navigation, and disabled Tour tabs outside tours
|
|
22
|
+
- Added saved 2/3/4/Fit panel density, uncapped Fit, stable visible groups, broader Find, and preserved edits and scroll positions
|
|
23
|
+
- Unified window typography, surfaces, and control colors across tours, History, and Compare
|
|
24
|
+
- Added automatic `+dev.N` versions for uncommitted local rebuilds without consuming release patch numbers
|
|
25
|
+
- Bumped the tour authoring and compiled format to v4 and removed Review Notes; legacy sources offer conversion with review notes preserved in the tour flow
|
|
26
|
+
|
|
27
|
+
macOS desktop downloads are unsigned and not notarized. If Gatekeeper reports
|
|
28
|
+
the app as damaged, run `xattr -cr /Applications/Bygone.app`, then reopen it.
|
|
4
29
|
|
|
5
30
|
## 0.9.8
|
|
6
31
|
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Bygone is a VS Code extension and standalone desktop app for visual diff and fil
|
|
|
22
22
|
|
|
23
23
|
- Editable two-way side-by-side diff view
|
|
24
24
|
- Dynamic adjacent multi-panel diff view
|
|
25
|
-
- Flowing connectors, block contours, and inline change highlighting
|
|
25
|
+
- Flowing connectors, block contours, and [inline change highlighting](#change-blocks-and-inline-highlighting)
|
|
26
26
|
- Git file history viewer with commit-by-commit navigation
|
|
27
27
|
- Local branch-change exploration using merge-base-to-tip semantics and changed-file progress
|
|
28
28
|
- Binary-aware comparisons with side-by-side image previews and byte-equality reporting
|
|
@@ -31,6 +31,66 @@ Bygone is a VS Code extension and standalone desktop app for visual diff and fil
|
|
|
31
31
|
|
|
32
32
|
This project is usable as a VS Code extension and standalone desktop app. The two-way diff and git history flows are the most complete. The multi-panel view is diff-focused, not a merge tool.
|
|
33
33
|
|
|
34
|
+
## Change blocks and inline highlighting
|
|
35
|
+
|
|
36
|
+
Hover a changed block on either side or its gutter connector to show copy arrows.
|
|
37
|
+
Moving the text cursor into a changed block or jumping to a change also shows them.
|
|
38
|
+
Each arrow stays beside its source block's first line: the left arrow points right,
|
|
39
|
+
and the right arrow points left. Clicking copies that block to the opposite panel;
|
|
40
|
+
arrows targeting a read-only panel are disabled.
|
|
41
|
+
|
|
42
|
+
A touching run of deletions and insertions forms one blue replacement block,
|
|
43
|
+
even when the text has no matching words. One-sided changes stay green. Green
|
|
44
|
+
and blue blocks can still touch; exactly matching lines separate change regions.
|
|
45
|
+
When an unpaired gap contains exactly one line on each side, those lines are
|
|
46
|
+
compared for inline highlighting. Larger ambiguous gaps keep conservative line
|
|
47
|
+
alignment within their blue block. A short statement expanded from one line to
|
|
48
|
+
several, or collapsed back to one, is compared as a whole for inline highlights
|
|
49
|
+
when the combined text is similar enough. This preserves the line alignment and
|
|
50
|
+
the inline highlighting length limit.
|
|
51
|
+
|
|
52
|
+
Multi-panel comparisons show changes from all adjacent pairs. A shared panel
|
|
53
|
+
shows the union of changed lines and words, with overlaps painted once. A line
|
|
54
|
+
that is one-sided in one pair and replaced in another uses replacement blue;
|
|
55
|
+
connectors in each gutter still describe that pair. **Focus selected pair** is off
|
|
56
|
+
by default. Turn it on to show only the selected pair's highlights and connectors,
|
|
57
|
+
then select another gutter to inspect that comparison with its own block colors.
|
|
58
|
+
Toggling focus preserves your scroll position. Previous/next change always follows the selected pair.
|
|
59
|
+
Directory and file views share the **Visible panels** setting (2, 3, 4, or Fit),
|
|
60
|
+
so opening a file or returning to its directory keeps the chosen density.
|
|
61
|
+
New three-way and four-way comparisons start with 3 and 4 panels selected.
|
|
62
|
+
Those counts stay visible even in tight windows; choose **Fit** for automatic
|
|
63
|
+
width-based adjustment. You can still select a smaller count manually.
|
|
64
|
+
|
|
65
|
+
Bygone matches words first, then uses **edge trimming** inside each replacement:
|
|
66
|
+
leave the longest identical beginning and ending unhighlighted, and highlight
|
|
67
|
+
the remaining middle. The beginning and ending never overlap. If one complete
|
|
68
|
+
text is a prefix or suffix of the other, keep it unhighlighted: `Name` → `NewName`
|
|
69
|
+
shows `Name` → `[New]Name`. If either edge works, or otherwise when matching edges
|
|
70
|
+
overlap, prefer the beginning. Adjacent removed
|
|
71
|
+
and inserted tokens form one replacement until an unchanged token separates them.
|
|
72
|
+
|
|
73
|
+
Brackets below represent the stronger inline highlight:
|
|
74
|
+
|
|
75
|
+
| Change | Highlighted result |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `oldName` → `newName` | `[old]Name` → `[new]Name` |
|
|
78
|
+
| `nameOld` → `nameNew` | `name[Old]` → `name[New]` |
|
|
79
|
+
| `userCount` → `usersCount` | `userCount` → `user[s]Count` |
|
|
80
|
+
| `userStatisticalCount` → `userMetricsCount` | `user[Statistical]Count` → `user[Metrics]Count` |
|
|
81
|
+
| `oldNameOld` → `newNameNew` | `[oldNameOld]` → `[newNameNew]` |
|
|
82
|
+
|
|
83
|
+
This handles a single contiguous edit anywhere in a replacement. It does not
|
|
84
|
+
search for unchanged text inside the remaining middle: multiple separated edits
|
|
85
|
+
within one word highlight everything from the first difference through the last.
|
|
86
|
+
It also makes no semantic judgment: `timeoutMs` → `timeoutSeconds` preserves the
|
|
87
|
+
shared final `s`, showing `timeout[M]s` → `timeout[Second]s`.
|
|
88
|
+
|
|
89
|
+
Highlight boundaries preserve whole displayed Unicode characters, including
|
|
90
|
+
combining accents and emoji. Edge trimming changes inline emphasis, not line
|
|
91
|
+
pairing or block colors. Existing whitespace-only highlighting and limits on
|
|
92
|
+
inline highlighting for long lines still apply.
|
|
93
|
+
|
|
34
94
|
## Install For Development
|
|
35
95
|
|
|
36
96
|
Install dependencies and compile:
|
|
@@ -81,6 +141,19 @@ For a one-command local sync that builds the repo, installs the global CLI, pack
|
|
|
81
141
|
npm run dev:sync
|
|
82
142
|
```
|
|
83
143
|
|
|
144
|
+
A clean checkout installs the committed version. Rebuilding with uncommitted
|
|
145
|
+
changes automatically increments a local iteration: `0.9.9+dev.1`,
|
|
146
|
+
`0.9.9+dev.2`, and so on. The CLI, Desktop About dialog, and artifact names
|
|
147
|
+
include that label. This is SemVer build metadata, not a new release patch;
|
|
148
|
+
it does not change version precedence. `npm run reinstall` reuses the current
|
|
149
|
+
artifacts without incrementing the iteration.
|
|
150
|
+
|
|
151
|
+
For a real release, set the intended plain version with
|
|
152
|
+
`npm version 0.9.10 --no-git-tag-version`, consolidate the changelog, and commit
|
|
153
|
+
before `npm run dev:sync`. Local dev versions must not be published;
|
|
154
|
+
`release:build` and `release:publish` reject them. Keep dev iterations out of
|
|
155
|
+
committed release history.
|
|
156
|
+
|
|
84
157
|
CLI defaults:
|
|
85
158
|
- In a Git repo, `bygone` opens directory history for the current directory.
|
|
86
159
|
- Outside a Git repo, `bygone` opens a blank editable diff.
|
package/cli/commandSpec.js
CHANGED
|
@@ -18,6 +18,8 @@ const CLI_SPEC = Object.freeze({
|
|
|
18
18
|
'bygone tour compile <file.bygone> [--output <tour.json>]',
|
|
19
19
|
'bygone tour context [<head>] [--base <base>] [--output <context.json>]',
|
|
20
20
|
'bygone tour coverage <file.bygone> [--json] [--minimum-coverage <0-100>]',
|
|
21
|
+
'bygone tour export <file.bygone> --output <tour.html> [--profile minimal|full] [--runtime embedded|cdn]',
|
|
22
|
+
'bygone <bygone://open/v1?...>',
|
|
21
23
|
'bygone tour schema',
|
|
22
24
|
'bygone --branch-diff [-b BRANCH] [-m MAIN]',
|
|
23
25
|
'bygone completion <zsh|bash|fish>',
|
package/cli/completions.js
CHANGED
|
@@ -75,7 +75,7 @@ ${legacyOptions}
|
|
|
75
75
|
${historyOptions}
|
|
76
76
|
)
|
|
77
77
|
shells=('zsh:Z shell' 'bash:Bash' 'fish:Fish shell')
|
|
78
|
-
tour_actions=('context:Build structured change evidence' 'coverage:Measure authored tour coverage' 'validate:Validate source and anchors' 'compile:Compile a repository-bound manifest' 'schema:Print the source schema')
|
|
78
|
+
tour_actions=('context:Build structured change evidence' 'coverage:Measure authored tour coverage' 'validate:Validate source and anchors' 'compile:Compile a repository-bound manifest' 'export:Export a portable HTML tour' 'schema:Print the source schema')
|
|
79
79
|
|
|
80
80
|
if (( CURRENT == 2 )); then
|
|
81
81
|
_describe 'bygone command' root_items
|
|
@@ -183,7 +183,7 @@ _bygone() {
|
|
|
183
183
|
;;
|
|
184
184
|
tour)
|
|
185
185
|
if (( COMP_CWORD == 2 )); then
|
|
186
|
-
COMPREPLY=( $(compgen -W 'context coverage validate compile schema' -- "$cur") )
|
|
186
|
+
COMPREPLY=( $(compgen -W 'context coverage validate compile export schema' -- "$cur") )
|
|
187
187
|
elif [[ "\${COMP_WORDS[2]}" == "context" ]]; then
|
|
188
188
|
refs="$(_bygone_git_refs)"
|
|
189
189
|
if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
|
|
@@ -262,7 +262,7 @@ function generateFishCompletion() {
|
|
|
262
262
|
|
|
263
263
|
lines.push(
|
|
264
264
|
"complete -c bygone -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish' -d 'Shell'",
|
|
265
|
-
"complete -c bygone -n '__fish_seen_subcommand_from tour' -a 'context coverage validate compile schema' -d 'Tour action'",
|
|
265
|
+
"complete -c bygone -n '__fish_seen_subcommand_from tour' -a 'context coverage validate compile export schema' -d 'Tour action'",
|
|
266
266
|
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l base -r -a '(__bygone_git_refs)' -d 'Set the change-context base ref'",
|
|
267
267
|
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l output -s o -r -d 'Write the change context'",
|
|
268
268
|
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l max-patch-bytes -r -d 'Maximum included patch size per file'",
|
package/cli/present.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { existsSync, mkdirSync, readFile, readFileSync, realpathSync, writeFileSync } = require('fs');
|
|
2
2
|
const { createServer } = require('http');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { spawn } = require('child_process');
|
|
@@ -6,8 +6,10 @@ const { buildChangeTourManifest, parseChangeTourStory } = require('../out/change
|
|
|
6
6
|
const { buildTourAuthoringCoverage, buildTourCoverageReport } = require('../out/tourCoverage.js');
|
|
7
7
|
const { buildTourWindowTitle } = require('../out/windowTitle.js');
|
|
8
8
|
const { tokenMatches } = require('./commandSpec.js');
|
|
9
|
-
const { loadTourSource, parseTourSourceText, buildManifestForTourSource } = require('./tourFile.js');
|
|
9
|
+
const { loadTourSource, parseTourSourceText, inspectTourConversion, convertTourSourceText, buildManifestForTourSource } = require('./tourFile.js');
|
|
10
10
|
const { createTourHistory } = require('./tourHistory.js');
|
|
11
|
+
const { pathToFileURL } = require('url');
|
|
12
|
+
const { resolveDocumentFocus, serializeDocumentFragment } = require('../out/deepLink.js');
|
|
11
13
|
|
|
12
14
|
const MIME_TYPES = new Map([
|
|
13
15
|
['.css', 'text/css; charset=utf-8'],
|
|
@@ -24,7 +26,9 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
24
26
|
const story = !options.ignoreEnvironment && process.env.BYGONE_TOUR_STORY
|
|
25
27
|
? parseChangeTourStory(JSON.parse(readFileSync(path.resolve(cwd, process.env.BYGONE_TOUR_STORY), 'utf8')))
|
|
26
28
|
: undefined;
|
|
27
|
-
const
|
|
29
|
+
const loadedSource = tourPath ? await (options.loadTourSource || loadTourSource)(cwd, tourPath) : undefined;
|
|
30
|
+
if (tourPath && !loadedSource) return null;
|
|
31
|
+
const source = loadedSource?.source;
|
|
28
32
|
const builtManifest = buildChangeTourManifest(cwd, {
|
|
29
33
|
headRef: explicitHeadRef || source?.range?.head || headRef,
|
|
30
34
|
baseRef: baseRef || source?.range?.base,
|
|
@@ -36,6 +40,14 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
36
40
|
const manifest = source
|
|
37
41
|
? { ...builtManifest, authoringCoverage: buildTourAuthoringCoverage(buildTourCoverageReport(cwd, source)) }
|
|
38
42
|
: builtManifest;
|
|
43
|
+
if (options.location) resolveDocumentFocus(manifest, options.location.mode, options.location.focus);
|
|
44
|
+
if (tourPath) {
|
|
45
|
+
const root = realpathSync(builtManifest.repository?.root || cwd);
|
|
46
|
+
const documentPath = realpathSync(loadedSource.resolvedPath);
|
|
47
|
+
const relative = path.relative(root, documentPath).split(path.sep).join('/');
|
|
48
|
+
const contained = relative && relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative);
|
|
49
|
+
manifest.localSource = { repo: pathToFileURL(root).href, tour: contained ? relative : pathToFileURL(documentPath).href };
|
|
50
|
+
}
|
|
39
51
|
const history = createTourHistory(manifest);
|
|
40
52
|
const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
41
53
|
const outputPath = options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_OUTPUT;
|
|
@@ -68,16 +80,20 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
68
80
|
request.on('end', () => {
|
|
69
81
|
if (response.writableEnded) return;
|
|
70
82
|
try {
|
|
71
|
-
const
|
|
83
|
+
const input = JSON.parse(body);
|
|
84
|
+
const conversion = inspectTourConversion(input.source);
|
|
85
|
+
if (conversion && input.convertToV4 !== true) return respondJson(response, 409, { conversion });
|
|
86
|
+
const converted = conversion ? convertTourSourceText(input.source) : null;
|
|
87
|
+
const uploadedSource = converted?.source || parseTourSourceText(input.source);
|
|
72
88
|
const loaded = buildManifestForTourSource(cwd, uploadedSource);
|
|
73
89
|
const id = String(uploadedTours.size + 1);
|
|
74
90
|
uploadedTours.set(id, { manifest: loaded, history: createTourHistory(loaded) });
|
|
75
|
-
respondJson(response, 200, { manifestUrl: `/loaded/${id}/tour.json`, repository: loaded.repository?.root || cwd, range: loaded.range });
|
|
91
|
+
respondJson(response, 200, { manifestUrl: `/loaded/${id}/tour.json`, repository: loaded.repository?.root || cwd, range: loaded.range, convertedSource: converted?.text });
|
|
76
92
|
} catch (error) { respondJson(response, 400, { error: `Invalid tour for this repository: ${error.message}` }); }
|
|
77
93
|
});
|
|
78
94
|
return;
|
|
79
95
|
}
|
|
80
|
-
const loadedMatch = requestUrl.pathname.match(/^\/loaded\/(\d+)\/(tour\.json|history\/(?:list|diff|compare|compare-many|revisions))$/);
|
|
96
|
+
const loadedMatch = requestUrl.pathname.match(/^\/loaded\/(\d+)\/(tour\.json|history\/(?:list|diff|compare|compare-many|changed-files|revisions))$/);
|
|
81
97
|
const loadedTour = loadedMatch ? uploadedTours.get(loadedMatch[1]) : null;
|
|
82
98
|
if (loadedMatch && !loadedTour) return respondJson(response, 404, { error: 'Loaded document not found' });
|
|
83
99
|
if (loadedTour && loadedMatch[2] === 'tour.json') return respondJson(response, 200, loadedTour.manifest);
|
|
@@ -85,7 +101,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
85
101
|
if (loadedMatch) requestUrl.pathname = `/${loadedMatch[2]}`;
|
|
86
102
|
if (requestUrl.pathname === '/history/list' || requestUrl.pathname === '/history/diff'
|
|
87
103
|
|| requestUrl.pathname === '/history/compare' || requestUrl.pathname === '/history/compare-many'
|
|
88
|
-
|| requestUrl.pathname === '/history/revisions') {
|
|
104
|
+
|| requestUrl.pathname === '/history/changed-files' || requestUrl.pathname === '/history/revisions') {
|
|
89
105
|
if (request.method !== 'POST') return respondJson(response, 405, { error: 'Method not allowed' });
|
|
90
106
|
if (!isSameOriginLoopbackRequest(request)) return respondJson(response, 403, { error: 'Forbidden' });
|
|
91
107
|
if (!requestHistory) return respondJson(response, 404, { error: 'History requires a version 2 tour.' });
|
|
@@ -110,7 +126,9 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
110
126
|
? requestHistory.compare(input)
|
|
111
127
|
: requestUrl.pathname === '/history/compare-many'
|
|
112
128
|
? requestHistory.compareMany(input)
|
|
113
|
-
:
|
|
129
|
+
: requestUrl.pathname === '/history/changed-files'
|
|
130
|
+
? requestHistory.changedFiles(input)
|
|
131
|
+
: requestHistory.revisions(input);
|
|
114
132
|
respondJson(response, 200, result);
|
|
115
133
|
} catch {
|
|
116
134
|
respondJson(response, 400, { error: 'Could not load history for this file and revision.' });
|
|
@@ -160,11 +178,20 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
160
178
|
respond(response, 404, 'Not found');
|
|
161
179
|
return;
|
|
162
180
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
// Electron streams extract ASAR entries to cached temporary files. Those
|
|
182
|
+
// can disappear during a long-running session; readFile reads the archive
|
|
183
|
+
// directly and lets us report failures before committing success headers.
|
|
184
|
+
readFile(targetPath, (error, content) => {
|
|
185
|
+
if (error) {
|
|
186
|
+
respond(response, 500, 'Read failed');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
response.writeHead(200, {
|
|
190
|
+
'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
|
|
191
|
+
'Cache-Control': 'no-store'
|
|
192
|
+
});
|
|
193
|
+
response.end(content);
|
|
166
194
|
});
|
|
167
|
-
createReadStream(targetPath).on('error', () => respond(response, 500, 'Read failed')).pipe(response);
|
|
168
195
|
});
|
|
169
196
|
const requestedPort = readPort(options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_PORT);
|
|
170
197
|
await new Promise((resolve, reject) => {
|
|
@@ -176,7 +203,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
176
203
|
server.close();
|
|
177
204
|
throw new Error('Could not determine the presentation server address.');
|
|
178
205
|
}
|
|
179
|
-
const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json`;
|
|
206
|
+
const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json${options.location ? serializeDocumentFragment(options.location.mode, options.location.focus) : ''}`;
|
|
180
207
|
if (options.announce !== false) {
|
|
181
208
|
process.stdout.write(`Bygone change tour running at ${url}\n`);
|
|
182
209
|
process.stdout.write('Press Ctrl+C to stop.\n');
|
|
@@ -190,7 +217,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
|
190
217
|
throw error;
|
|
191
218
|
}
|
|
192
219
|
}
|
|
193
|
-
return { manifest, server, url };
|
|
220
|
+
return { manifest, server, url, sourcePath: loadedSource?.resolvedPath };
|
|
194
221
|
}
|
|
195
222
|
|
|
196
223
|
function parsePresentArgs(args) {
|
package/cli/tour.js
CHANGED
|
@@ -4,9 +4,30 @@ const { buildChangeTourContext } = require('../out/changeTour.js');
|
|
|
4
4
|
const { buildTourCoverageReport } = require('../out/tourCoverage.js');
|
|
5
5
|
const { buildManifestForTourSource, loadTourSource } = require('./tourFile.js');
|
|
6
6
|
|
|
7
|
-
const TOUR_ACTIONS = Object.freeze(['context', 'coverage', 'validate', 'compile', 'schema']);
|
|
7
|
+
const TOUR_ACTIONS = Object.freeze(['context', 'coverage', 'validate', 'compile', 'export', 'schema']);
|
|
8
8
|
|
|
9
9
|
function runTourCommand(args, cwd, packageRoot, output = process.stdout) {
|
|
10
|
+
if (args[0] === 'export') {
|
|
11
|
+
const { exportTour } = require('./tourExport');
|
|
12
|
+
let sourcePath, outputPath;
|
|
13
|
+
const options = {};
|
|
14
|
+
for (let i = 1; i < args.length; i++) {
|
|
15
|
+
const arg = args[i];
|
|
16
|
+
if (arg === '--overwrite') { options.overwrite = true; continue; }
|
|
17
|
+
if (['--output', '-o', '--profile', '--runtime', '--format', '--runtime-base'].includes(arg)) {
|
|
18
|
+
const value = args[++i];
|
|
19
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
|
|
20
|
+
if (arg === '--output' || arg === '-o') outputPath = value;
|
|
21
|
+
else if (arg === '--format') { if (value !== 'html') throw new Error('Only HTML export is supported.'); }
|
|
22
|
+
else options[arg === '--runtime-base' ? 'runtimeBase' : arg.slice(2)] = value;
|
|
23
|
+
} else if (arg.startsWith('-') || sourcePath) throw new Error(`Unexpected export argument: ${arg}`);
|
|
24
|
+
else sourcePath = arg;
|
|
25
|
+
}
|
|
26
|
+
if (!sourcePath || !outputPath) throw new Error('Usage: bygone tour export file.bygone --output tour.html [--profile minimal|full] [--runtime embedded|cdn]');
|
|
27
|
+
const result = exportTour(path.resolve(cwd, sourcePath), path.resolve(cwd, outputPath), packageRoot, options);
|
|
28
|
+
output.write(`Wrote ${result.profile} HTML tour to ${result.outputPath} (${result.bytes} bytes). ${result.summary}\n`);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
10
31
|
const options = parseTourArgs(args);
|
|
11
32
|
if (options.action === 'schema') {
|
|
12
33
|
output.write(readFileSync(path.join(packageRoot, 'schemas', 'change-tour-source.schema.json'), 'utf8'));
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { createHash } = require('crypto');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
const { TextDecoder } = require('util');
|
|
6
|
+
const { buildManifestForTourSource, loadTourSource } = require('./tourFile');
|
|
7
|
+
const { discoverAuthoredTourDocument } = require('../out/tourDocument');
|
|
8
|
+
|
|
9
|
+
const ASSETS = ['web/presenter.css', 'media/webview.css', 'web/web-host.js', 'media/webview.js', 'media/editor.worker.js', 'media/diff.worker.js'];
|
|
10
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
11
|
+
function digest(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
12
|
+
function json(value) { return JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); }
|
|
13
|
+
function html(value) { return String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); }
|
|
14
|
+
function git(root, args, binary = false) {
|
|
15
|
+
return execFileSync('git', args, { cwd: root, encoding: binary ? undefined : 'utf8', maxBuffer: MAX_BYTES, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
16
|
+
}
|
|
17
|
+
function buildHistory(root, manifest, texts) {
|
|
18
|
+
const base = manifest.range.mergeBaseOid;
|
|
19
|
+
const head = manifest.range.headOid;
|
|
20
|
+
const refs = new Set([head, ...git(root, ['rev-list', '--topo-order', `${base}..${head}`, '--']).trim().split('\n').filter(Boolean), base]);
|
|
21
|
+
function collect(value) {
|
|
22
|
+
if (!value || typeof value !== 'object') return;
|
|
23
|
+
if (value.kind === 'stacked-diff') value.stack.forEach(panel => refs.add(panel.oid));
|
|
24
|
+
if (value.kind === 'deconstructed-diff') value.panels.forEach(panel => { if (panel.oid) refs.add(panel.oid); });
|
|
25
|
+
Object.values(value).forEach(child => { if (Array.isArray(child)) child.forEach(collect); else if (child && typeof child === 'object') collect(child); });
|
|
26
|
+
}
|
|
27
|
+
collect(manifest);
|
|
28
|
+
if (refs.size > 256) throw new Error('Full export exceeds the initial 256-revision safety limit. Narrow the tour range.');
|
|
29
|
+
const paths = new Set(manifest.files.flatMap(file => [file.path, file.previousPath].filter(Boolean)));
|
|
30
|
+
function collectPaths(value) {
|
|
31
|
+
if (!value || typeof value !== 'object') return;
|
|
32
|
+
if (typeof value.path === 'string' && value.kind !== 'directory-diff') paths.add(value.path);
|
|
33
|
+
Object.values(value).forEach(child => { if (Array.isArray(child)) child.forEach(collectPaths); else if (child && typeof child === 'object') collectPaths(child); });
|
|
34
|
+
}
|
|
35
|
+
collectPaths(manifest.scenes); collectPaths(manifest.tours); collectPaths(manifest.zoom);
|
|
36
|
+
const commits = [...refs].map(oid => {
|
|
37
|
+
const [parents, summary, timestamp, author, authorEmail] = git(root, ['show', '-s', '--format=%P%x00%s%x00%cI%x00%an%x00%ae', oid, '--']).trimEnd().split('\0');
|
|
38
|
+
const parentIds = parents.split(' ').filter(Boolean);
|
|
39
|
+
// Include changes that disappear in the final endpoint diff, and rename predecessors.
|
|
40
|
+
if (oid !== base) for (const name of git(root, ['diff-tree', '--root', '-m', '--no-commit-id', '-r', '--name-only', '-z', oid, '--']).split('\0').filter(Boolean)) paths.add(name);
|
|
41
|
+
return { oid, commit: oid, parents: parentIds, parentCommit: parentIds[0] || null, shortCommit: oid.slice(0, 7), summary, timestamp, author, authorEmail, message: summary };
|
|
42
|
+
});
|
|
43
|
+
if (paths.size > 4096) throw new Error('Full export exceeds the initial 4096-path safety limit. Narrow the tour.');
|
|
44
|
+
const snapshots = {};
|
|
45
|
+
for (const oid of refs) {
|
|
46
|
+
const tree = new Map(git(root, ['ls-tree', '-r', '-z', oid]).split('\0').filter(Boolean).map(record => {
|
|
47
|
+
const split = record.indexOf('\t');
|
|
48
|
+
const [mode, type, object] = record.slice(0, split).split(' ');
|
|
49
|
+
return [record.slice(split + 1), { mode, type, object }];
|
|
50
|
+
}));
|
|
51
|
+
snapshots[oid] = Object.create(null);
|
|
52
|
+
for (const name of paths) {
|
|
53
|
+
const record = tree.get(name);
|
|
54
|
+
if (!record) { snapshots[oid][name] = null; continue; }
|
|
55
|
+
if (record.type !== 'blob') { snapshots[oid][name] = { omitted: 'Submodule content is not packaged.' }; continue; }
|
|
56
|
+
if (Number(git(root, ['cat-file', '-s', record.object]).trim()) > 2 * 1024 * 1024) { snapshots[oid][name] = { omitted: 'File exceeds 2 MiB.' }; continue; }
|
|
57
|
+
const bytes = git(root, ['cat-file', 'blob', record.object], true);
|
|
58
|
+
let text;
|
|
59
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); if (bytes.includes(0)) throw new Error('binary'); }
|
|
60
|
+
catch { snapshots[oid][name] = { omitted: 'Binary or non-UTF-8 content.' }; continue; }
|
|
61
|
+
if (text.startsWith('version https://git-lfs.github.com/spec/v1\n')) { snapshots[oid][name] = { omitted: 'Git LFS object is not packaged.' }; continue; }
|
|
62
|
+
if (text.split('\n').some(line => Buffer.byteLength(line) > 64 * 1024)) { snapshots[oid][name] = { omitted: 'A line exceeds 64 KiB.' }; continue; }
|
|
63
|
+
const blob = digest(text); texts[blob] = text;
|
|
64
|
+
snapshots[oid][name] = { blob, mode: record.mode, gitBlob: record.object };
|
|
65
|
+
}
|
|
66
|
+
if (Buffer.byteLength(JSON.stringify(texts)) > MAX_BYTES) throw new Error('Export evidence exceeds 128 MiB. Narrow the tour.');
|
|
67
|
+
}
|
|
68
|
+
return { commits, paths: [...paths].sort(), snapshots, comparison: 'Exact paths; renames appear as deletion and addition. Parents outside the included set are unavailable.' };
|
|
69
|
+
}
|
|
70
|
+
function materializeTour(sourcePath, profile) {
|
|
71
|
+
const document = discoverAuthoredTourDocument(sourcePath);
|
|
72
|
+
const originalBytes = fs.readFileSync(document.documentPath);
|
|
73
|
+
const { source } = loadTourSource(document.repoRoot, document.documentPath);
|
|
74
|
+
const manifest = buildManifestForTourSource(document.repoRoot, source);
|
|
75
|
+
const texts = {};
|
|
76
|
+
const history = profile === 'full' ? buildHistory(document.repoRoot, manifest, texts) : undefined;
|
|
77
|
+
delete manifest.repository;
|
|
78
|
+
delete manifest.localSource;
|
|
79
|
+
// A source URL is optional provenance and may carry credentials or local bindings.
|
|
80
|
+
delete manifest.sourceUrl;
|
|
81
|
+
const contentKeys = new Set(['leftContent', 'rightContent', 'content']);
|
|
82
|
+
function pack(value) {
|
|
83
|
+
if (Array.isArray(value)) return value.map(pack);
|
|
84
|
+
if (!value || typeof value !== 'object') return value;
|
|
85
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
86
|
+
if (contentKeys.has(key) && typeof child === 'string') {
|
|
87
|
+
const id = digest(child); texts[id] = child; return [key, { $text: id }];
|
|
88
|
+
}
|
|
89
|
+
return [key, pack(child)];
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
if (!fs.readFileSync(document.documentPath).equals(originalBytes)) throw new Error('The tour source changed during export. Retry from the saved document.');
|
|
93
|
+
const packed = pack(manifest);
|
|
94
|
+
const identity = { profile, manifest: { ...packed, generatedAt: undefined }, texts, history };
|
|
95
|
+
return { version: 1, profile, contentId: digest(JSON.stringify(identity)), sourceFingerprint: digest(originalBytes), manifest: packed, texts, ...(history ? { history } : {}) };
|
|
96
|
+
}
|
|
97
|
+
function exportTour(sourcePath, outputPath, packageRoot, options = {}) {
|
|
98
|
+
const profile = options.profile || 'minimal';
|
|
99
|
+
const runtime = options.runtime || 'embedded';
|
|
100
|
+
if (!['minimal', 'full'].includes(profile)) throw new Error('Export profile must be minimal or full.');
|
|
101
|
+
if (!['embedded', 'cdn'].includes(runtime)) throw new Error('Export runtime must be embedded or cdn.');
|
|
102
|
+
const data = materializeTour(sourcePath, profile);
|
|
103
|
+
const version = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version;
|
|
104
|
+
const runtimeManifestPath = path.join(packageRoot, 'web', 'export-runtime.json');
|
|
105
|
+
if (!fs.existsSync(runtimeManifestPath)) throw new Error('Export runtime assets are missing. Rebuild Bygone first.');
|
|
106
|
+
const runtimeManifest = JSON.parse(fs.readFileSync(runtimeManifestPath, 'utf8'));
|
|
107
|
+
if (runtimeManifest.version !== version || runtimeManifest.schema !== 1) throw new Error('Export runtime does not match this Bygone build.');
|
|
108
|
+
if (runtime === 'cdn' && !options.runtimeBase) throw new Error('CDN export requires a published, verified export runtime. Use --runtime embedded until the export-enabled release is published.');
|
|
109
|
+
if (options.runtimeBase && !/^https:\/\/cdn\.jsdelivr\.net\/npm\/@davmash\/bygone@\d+\.\d+\.\d+(?:-[\w.-]+)?\/$/.test(options.runtimeBase)) throw new Error('Runtime base must pin an exact Bygone release on jsDelivr.');
|
|
110
|
+
if (runtime === 'cdn' && options.runtimeBase !== `https://cdn.jsdelivr.net/npm/@davmash/bygone@${version}/`) throw new Error('CDN runtime version must match this exact Bygone build.');
|
|
111
|
+
const assets = ASSETS.map(name => {
|
|
112
|
+
const bytes = fs.readFileSync(path.join(packageRoot, name));
|
|
113
|
+
const integrity = `sha256-${createHash('sha256').update(bytes).digest('base64')}`;
|
|
114
|
+
if (runtimeManifest.assets[name] !== integrity) throw new Error(`Runtime integrity mismatch: ${name}. Rebuild Bygone.`);
|
|
115
|
+
return { name, integrity, ...(runtime === 'embedded' ? { base64: bytes.toString('base64') } : { url: options.runtimeBase + name }) };
|
|
116
|
+
});
|
|
117
|
+
const payload = { ...data, runtimeVersion: version, assets };
|
|
118
|
+
const summary = `${profile === 'full' ? 'Full' : 'Minimal'} snapshot · ${Object.keys(data.texts).length} unique texts · ${Buffer.byteLength(JSON.stringify(data.texts))} evidence bytes${data.history ? ` · ${data.history.commits.length} revisions · ${data.history.paths.length} paths` : ' · no browsable Git history'}. Full file contents, including surrounding/deleted code, are included.`;
|
|
119
|
+
let shell = fs.readFileSync(path.join(packageRoot, 'web', 'index.html'), 'utf8')
|
|
120
|
+
.replace(/<link href="\/(?:media|web)\/[^"]+" rel="stylesheet">/g, '')
|
|
121
|
+
.replace(/<script src="\/(?:web|media)\/[^"]+"><\/script>/g, '')
|
|
122
|
+
.replace('<title>Bygone Tour</title>', `<title>${html(data.manifest.title)} — Bygone export</title>`);
|
|
123
|
+
const network = runtime === 'cdn' ? ' https://cdn.jsdelivr.net' : '';
|
|
124
|
+
const policy = `default-src 'none'; script-src 'unsafe-inline' blob:${network}; style-src 'unsafe-inline' blob:${network}; font-src data:; img-src data: blob:; worker-src blob:; connect-src${network || " 'none'"}; frame-src 'none'; base-uri 'none'; form-action 'none'`;
|
|
125
|
+
shell = shell.replace('<meta charset="UTF-8">', `<meta charset="UTF-8"><meta http-equiv="Content-Security-Policy" content="${policy}">`)
|
|
126
|
+
.replace('<body class="web-host">', `<body class="web-host"><div id="export-loading" role="status">Loading ${html(data.manifest.title)}… ${runtime === 'cdn' ? 'Internet required to load the Bygone viewer.' : 'Embedded viewer; no internet required.'} If loading fails, reopen the file or obtain a new export.</div><noscript>This interactive tour requires JavaScript.</noscript>`)
|
|
127
|
+
.replace('<summary>Tour details</summary>', `<summary>Tour details</summary><p class="export-notice">${html(summary)} ${runtime === 'cdn' ? 'Internet required to load the Bygone viewer.' : 'Embedded viewer; no internet required.'} ${html(data.history?.comparison || '')}</p>`);
|
|
128
|
+
const bootstrap = fs.readFileSync(path.join(packageRoot, 'web', 'export-bootstrap.js'), 'utf8');
|
|
129
|
+
const licenses = fs.readFileSync(path.join(packageRoot, 'web', 'export-licenses.txt'), 'utf8');
|
|
130
|
+
shell = shell.replace('</body>', `<script id="bygone-export-data" type="application/json">${json(payload)}</script><script>${bootstrap}</script><details class="export-notice"><summary>Runtime licenses</summary><pre>${html(licenses)}</pre></details></body>`);
|
|
131
|
+
if (Buffer.byteLength(shell) > MAX_BYTES) throw new Error('HTML export exceeds the 128 MiB safety limit. Narrow the tour.');
|
|
132
|
+
if (fs.existsSync(outputPath) && !options.overwrite) throw new Error('Output exists. Choose another path or pass --overwrite.');
|
|
133
|
+
if (path.resolve(sourcePath) === path.resolve(outputPath)
|
|
134
|
+
|| (fs.existsSync(outputPath) && fs.realpathSync(sourcePath) === fs.realpathSync(outputPath))) throw new Error('Cannot overwrite the authored source.');
|
|
135
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
136
|
+
const temporary = `${outputPath}.${process.pid}.tmp`;
|
|
137
|
+
let created = false;
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(temporary, shell, { flag: 'wx', mode: 0o600 });
|
|
140
|
+
created = true;
|
|
141
|
+
if (options.overwrite) fs.renameSync(temporary, outputPath);
|
|
142
|
+
else fs.linkSync(temporary, outputPath); // Fail atomically if another writer creates the destination.
|
|
143
|
+
}
|
|
144
|
+
finally { if (created) fs.rmSync(temporary, { force: true }); }
|
|
145
|
+
return { outputPath, contentId: data.contentId, profile, runtime, bytes: Buffer.byteLength(shell), summary };
|
|
146
|
+
}
|
|
147
|
+
module.exports = { exportTour, materializeTour, ASSETS };
|
package/cli/tourFile.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const { readFileSync, statSync } = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { TextDecoder } = require('util');
|
|
4
|
-
const { loadAll: loadYamlDocuments } = require('js-yaml');
|
|
4
|
+
const { loadAll: loadYamlDocuments, dump: dumpYaml } = require('js-yaml');
|
|
5
|
+
const { serializeDocumentFragment } = require('../out/deepLink.js');
|
|
5
6
|
const { buildChangeTourManifest, parseChangeTourSource } = require('../out/changeTour.js');
|
|
6
7
|
|
|
7
8
|
const DEFAULT_MAX_TOUR_SOURCE_BYTES = 1024 * 1024;
|
|
@@ -13,6 +14,10 @@ function loadTourSource(cwd, sourcePath) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function readTourSourceDocument(sourcePath, options = {}) {
|
|
17
|
+
return parseTourSourceText(readTourSourceText(sourcePath, options), path.resolve(sourcePath), options);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readTourSourceText(sourcePath, options = {}) {
|
|
16
21
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_TOUR_SOURCE_BYTES;
|
|
17
22
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
18
23
|
throw new Error('The Bygone source size limit must be a positive safe integer.');
|
|
@@ -48,10 +53,10 @@ function readTourSourceDocument(sourcePath, options = {}) {
|
|
|
48
53
|
throw new Error(`Could not decode Bygone source ${sourceLabel} as UTF-8: ${errorMessage(error)}`, { cause: error });
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
return
|
|
56
|
+
return text;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
function
|
|
59
|
+
function readTourSourceValue(text, sourceLabel = 'uploaded tour', options = {}) {
|
|
55
60
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_TOUR_SOURCE_BYTES;
|
|
56
61
|
if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > maxBytes || text.includes('\0')) {
|
|
57
62
|
throw new Error('Invalid tour text or source size limit exceeded.');
|
|
@@ -66,13 +71,110 @@ function parseTourSourceText(text, sourceLabel = 'uploaded tour', options = {})
|
|
|
66
71
|
throw new Error(`Could not parse Bygone source ${sourceLabel}: expected one YAML document, found ${documents.length}.`);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
return documents[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseTourSourceText(text, sourceLabel = 'uploaded tour', options = {}) {
|
|
69
78
|
try {
|
|
70
|
-
return parseChangeTourSource(
|
|
79
|
+
return parseChangeTourSource(readTourSourceValue(text, sourceLabel, options));
|
|
71
80
|
} catch (error) {
|
|
72
81
|
throw new Error(`Invalid Bygone source ${sourceLabel}: ${errorMessage(error)}`, { cause: error });
|
|
73
82
|
}
|
|
74
83
|
}
|
|
75
84
|
|
|
85
|
+
// Inspect without accepting retired formats in the normal source parser.
|
|
86
|
+
function inspectTourConversion(text, sourceLabel) {
|
|
87
|
+
const value = readTourSourceValue(text, sourceLabel);
|
|
88
|
+
if (![1, 2, 3].includes(value?.version)) return null;
|
|
89
|
+
return {
|
|
90
|
+
fromVersion: value.version,
|
|
91
|
+
preservesReview: Object.prototype.hasOwnProperty.call(value, 'review'),
|
|
92
|
+
message: `Convert this version ${value.version} tour to v4?`,
|
|
93
|
+
detail: 'Conversion preserves tour IDs and revision references. Review notes are preserved as a Legacy review notes appendix in the final authored step of each available tour. Their recorded revisions and evidence references are retained, without claiming they have been revalidated. The original file stays unchanged. YAML formatting and comments are not retained in the copy.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function convertTourSourceText(text, sourceLabel) {
|
|
98
|
+
const conversion = inspectTourConversion(text, sourceLabel);
|
|
99
|
+
if (!conversion) throw new Error('Only authored tour versions 1, 2, and 3 can be converted to v4.');
|
|
100
|
+
const value = readTourSourceValue(text, sourceLabel);
|
|
101
|
+
const converted = { ...value, version: 4 };
|
|
102
|
+
delete converted.review;
|
|
103
|
+
try {
|
|
104
|
+
parseChangeTourSource(converted);
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(value, 'review')) appendLegacyReview(converted, value.review);
|
|
106
|
+
parseChangeTourSource(converted);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new Error(`This tour cannot be converted automatically to v4: ${errorMessage(error)} No evidence was changed; update the original tour manually.`, { cause: error });
|
|
109
|
+
}
|
|
110
|
+
const convertedText = dumpYaml(converted, { noRefs: true, lineWidth: -1 });
|
|
111
|
+
// Apply the same size and document checks to the serialized output.
|
|
112
|
+
const source = parseTourSourceText(convertedText, sourceLabel);
|
|
113
|
+
return { source, text: convertedText };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appendLegacyReview(source, review) {
|
|
117
|
+
const modes = Object.entries(source.tours || {});
|
|
118
|
+
const evidenceLink = (evidence) => {
|
|
119
|
+
if (!evidence || typeof evidence.sceneId !== 'string' || typeof evidence.stepId !== 'string') return '';
|
|
120
|
+
const candidates = [...modes];
|
|
121
|
+
if (!source.tours?.historical) candidates.push(['historical', { chapters: source.chapters }]);
|
|
122
|
+
for (const [mode, tour] of candidates) {
|
|
123
|
+
const scene = tour.chapters.flatMap(chapter => chapter.scenes).find(scene => scene.id === evidence.sceneId);
|
|
124
|
+
if (scene && (!scene.kind || scene.kind === 'walkthrough') && scene.steps.some(step => step.id === evidence.stepId)) {
|
|
125
|
+
return ` [Open referenced step](${serializeDocumentFragment(mode, { part: 'step', scene: evidence.sceneId, step: evidence.stepId })})`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return '';
|
|
129
|
+
};
|
|
130
|
+
let notes = '\n\n## Legacy review notes\n\nImported from the older tour. These notes have not been revalidated against the current revisions.';
|
|
131
|
+
// Render recognized review fields as ordinary prose. Keep unfamiliar or
|
|
132
|
+
// incomplete blocks verbatim so no legacy data is silently discarded.
|
|
133
|
+
if (review && typeof review === 'object' && Array.isArray(review.items)) {
|
|
134
|
+
notes += `\n\nRecorded revisions: ${review.baseOid ?? '(unspecified)'} → ${review.headOid ?? '(unspecified)'}.`;
|
|
135
|
+
for (const item of review.items) {
|
|
136
|
+
if (!item || typeof item !== 'object') continue;
|
|
137
|
+
notes += `\n\n### ${item.title ?? 'Review note'}\n\n${item.kind ?? 'Note'} (${item.id ?? 'no id'})\n\n${item.body ?? ''}`;
|
|
138
|
+
for (const evidence of Array.isArray(item.evidence) ? item.evidence : []) {
|
|
139
|
+
notes += `\n\nEvidence: ${evidence?.sceneId ?? '?'} / ${evidence?.stepId ?? '?'}.${evidenceLink(evidence)}`;
|
|
140
|
+
}
|
|
141
|
+
if (item.nextCheck !== undefined) notes += `\n\nNext check: ${item.nextCheck}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const onlyKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
|
|
145
|
+
&& Object.keys(value).every(key => keys.includes(key));
|
|
146
|
+
const recognized = onlyKeys(review, ['baseOid', 'headOid', 'items'])
|
|
147
|
+
&& typeof review.baseOid === 'string' && typeof review.headOid === 'string'
|
|
148
|
+
&& Array.isArray(review.items) && review.items.every(item =>
|
|
149
|
+
onlyKeys(item, ['id', 'kind', 'title', 'body', 'evidence', 'nextCheck'])
|
|
150
|
+
&& ['id', 'kind', 'title', 'body'].every(key => typeof item[key] === 'string')
|
|
151
|
+
&& (item.nextCheck === undefined || typeof item.nextCheck === 'string')
|
|
152
|
+
&& Array.isArray(item.evidence) && item.evidence.every(evidence =>
|
|
153
|
+
onlyKeys(evidence, ['sceneId', 'stepId']) && typeof evidence.sceneId === 'string' && typeof evidence.stepId === 'string'));
|
|
154
|
+
if (!recognized) {
|
|
155
|
+
const raw = dumpYaml(review, { noRefs: true, lineWidth: -1 }).trimEnd();
|
|
156
|
+
const fence = '`'.repeat(Math.max(3, ...[...raw.matchAll(/`+/g)].map(match => match[0].length + 1)));
|
|
157
|
+
notes += `\n\n### Original review data\n\n${fence}yaml\n${raw}\n${fence}`;
|
|
158
|
+
}
|
|
159
|
+
const appended = new Set();
|
|
160
|
+
const append = (target, field) => {
|
|
161
|
+
if (!target || appended.has(target)) return;
|
|
162
|
+
target[field] += notes;
|
|
163
|
+
appended.add(target);
|
|
164
|
+
};
|
|
165
|
+
for (const tour of [{ chapters: source.chapters }, ...modes.map(([, tour]) => tour)]) {
|
|
166
|
+
const scene = tour.chapters.at(-1).scenes.at(-1);
|
|
167
|
+
if (scene.kind === 'deconstructed-diff') {
|
|
168
|
+
append(scene.stages.at(-1), 'narration');
|
|
169
|
+
// Legacy deconstruction also exposes its real-evidence steps in the
|
|
170
|
+
// Historical fallback. Preserve the notes there too.
|
|
171
|
+
append(scene.steps?.at(-1), 'body');
|
|
172
|
+
} else {
|
|
173
|
+
append(scene.steps.at(-1), 'body');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
76
178
|
function errorMessage(error) {
|
|
77
179
|
return error instanceof Error ? error.message : String(error);
|
|
78
180
|
}
|
|
@@ -92,6 +194,9 @@ module.exports = {
|
|
|
92
194
|
DEFAULT_MAX_TOUR_SOURCE_BYTES,
|
|
93
195
|
buildManifestForTourSource,
|
|
94
196
|
loadTourSource,
|
|
197
|
+
readTourSourceText,
|
|
198
|
+
inspectTourConversion,
|
|
199
|
+
convertTourSourceText,
|
|
95
200
|
parseTourSourceText,
|
|
96
201
|
readTourSourceDocument
|
|
97
202
|
};
|