@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.12
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 +344 -0
- package/README.md +10 -6
- package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
- package/contracts/compat-baseline.json +1 -1
- package/docs/assurance-controls.md +3 -2
- package/docs/install.md +3 -3
- package/docs/local-services.md +62 -11
- package/docs/obsidian-contract.md +669 -35
- package/docs/obsidian-plugin.md +585 -0
- package/docs/obsidian.md +233 -50
- package/docs/release-engineering.md +7 -1
- package/docs/upgrade.md +23 -3
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
- package/fixtures/obsidian/edits/workspace.json +1 -1
- package/fixtures/obsidian/materialization/expected-full.json +21 -21
- package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
- package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
- package/package.json +7 -3
- package/plugins/obsidian/main.js +444 -0
- package/plugins/obsidian/manifest.json +9 -0
- package/plugins/obsidian/styles.css +22 -0
- package/src/commands/obsidian.mjs +89 -10
- package/src/contracts/corpus.mjs +6 -0
- package/src/egress/forbidden-egress.mjs +2 -0
- package/src/project/private-state.mjs +10 -1
- package/src/projection/obsidian/contracts.mjs +224 -18
- package/src/projection/obsidian/edits/apply.mjs +12 -7
- package/src/projection/obsidian/edits/regions.mjs +137 -42
- package/src/projection/obsidian/materialize/index.mjs +6 -3
- package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
- package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
- package/src/projection/obsidian/materialize/redaction.mjs +327 -0
- package/src/projection/obsidian/materialize/settings.mjs +113 -34
- package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
- package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
- package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
- package/src/projection/obsidian/proposals/observation.mjs +10 -5
- package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
- package/src/projection/obsidian/publication/index.mjs +2 -1
- package/src/projection/obsidian/publication/publisher.mjs +173 -26
- package/src/projection/obsidian/publication/transport.mjs +63 -8
- package/src/projection/obsidian/publication/vault-list.mjs +199 -0
- package/src/projection/obsidian/recovery/restart.mjs +3 -1
- package/src/projection/obsidian/recovery/store.mjs +11 -4
- package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
- package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
- package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
- package/src/runtime/obsidian/app-capability.mjs +104 -12
- package/src/runtime/obsidian/app-production-seams.mjs +126 -39
- package/src/runtime/obsidian/app-registration.mjs +166 -0
- package/src/runtime/obsidian/engine.mjs +127 -11
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +5 -1
- package/src/runtime/obsidian/launch-plan.mjs +88 -0
- package/src/runtime/obsidian/lifecycle.mjs +105 -14
- package/src/runtime/obsidian/opening.mjs +227 -55
- package/src/runtime/obsidian/pending-edits.mjs +10 -0
- package/src/runtime/obsidian/plugin-channel.mjs +281 -0
- package/src/runtime/obsidian/plugin-choice.mjs +170 -0
- package/src/runtime/obsidian/plugin-drift.mjs +76 -0
- package/src/runtime/obsidian/plugin-presence.mjs +48 -0
- package/src/runtime/obsidian/service-main.mjs +18 -5
- package/src/runtime/obsidian/service-record.mjs +36 -0
- package/src/runtime/obsidian/service-server.mjs +50 -12
- package/src/runtime/obsidian/service.mjs +100 -6
- package/src/runtime/obsidian/state-store.mjs +13 -1
- package/src/server/local-sidecar.mjs +24 -5
- package/src/server/server.mjs +18 -5
- package/src/upgrade/upgrade.mjs +1 -1
- package/templates/external-project-workspace/pack-lifecycle.json +1 -1
package/src/contracts/corpus.mjs
CHANGED
|
@@ -171,6 +171,12 @@ export const CONTRACT_CORPUS = [
|
|
|
171
171
|
name: `atelier-obsidian-${shape}`, contractFile: `contracts/atelier-obsidian-${shape}.v1.schema.json`,
|
|
172
172
|
fixtureRoot: `fixtures/obsidian/contracts/${shape}`, registry: false,
|
|
173
173
|
})),
|
|
174
|
+
// Vault layout 2's manifest: a new major, beside v1, which stays unchanged
|
|
175
|
+
// and still validates every generation an earlier release wrote.
|
|
176
|
+
{
|
|
177
|
+
name: 'atelier-obsidian-generation-manifest-v2', contractFile: 'contracts/atelier-obsidian-generation-manifest.v2.schema.json',
|
|
178
|
+
fixtureRoot: 'fixtures/obsidian/contracts/generation-manifest-v2', registry: false,
|
|
179
|
+
},
|
|
174
180
|
{
|
|
175
181
|
name: 'analysis-adapter-claim-output',
|
|
176
182
|
contractFile: 'contracts/analysis-adapter.v1.schema.json',
|
|
@@ -17,6 +17,8 @@ export const DEFAULT_EGRESS_SCAN_PATHS = [
|
|
|
17
17
|
'templates',
|
|
18
18
|
'examples',
|
|
19
19
|
'skills',
|
|
20
|
+
// Atelier's own Obsidian plugin: shipped, and run inside the app of every vault Atelier manages.
|
|
21
|
+
'plugins',
|
|
20
22
|
]
|
|
21
23
|
|
|
22
24
|
const SCRIPT_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.sh'])
|
|
@@ -62,7 +62,7 @@ export function openRegularFileNoFollow(file, flags = fs.constants.O_RDONLY, mod
|
|
|
62
62
|
// leaf before open and bind the opened descriptor back to that same file
|
|
63
63
|
// identity where the filesystem supplies stable device/inode values.
|
|
64
64
|
if (before && before.ino !== 0 && (before.dev !== opened.dev || before.ino !== opened.ino)) {
|
|
65
|
-
throw new Error('state leaf changed while opening')
|
|
65
|
+
throw Object.assign(new Error('state leaf changed while opening'), { code: 'ELEAFCHANGED' })
|
|
66
66
|
}
|
|
67
67
|
return descriptor
|
|
68
68
|
} catch (error) {
|
|
@@ -80,6 +80,15 @@ export function readRegularTextNoFollow(file) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// The real path of a target as the file system stores it: on a volume that
|
|
84
|
+
// folds letter case (macOS by default) each component in the case it is stored
|
|
85
|
+
// in, which is also how the operating system reports a working directory, while
|
|
86
|
+
// Node's own resolution keeps the case it was given. On Windows Node's own
|
|
87
|
+
// resolution is kept: the native one also expands short 8.3 names.
|
|
88
|
+
export function realPathAsStored(target) {
|
|
89
|
+
return process.platform === 'win32' ? fs.realpathSync(target) : fs.realpathSync.native(target)
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
export function syncPrivateDirectory(directory) {
|
|
84
93
|
let fd;
|
|
85
94
|
try { fd = fs.openSync(directory, fs.constants.O_RDONLY); fs.fsyncSync(fd); }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { validateJsonSchema } from '../../export/atelier-export-contract.mjs'
|
|
4
|
+
import { titleCase } from '../../graph/knowledge-graph.mjs'
|
|
4
5
|
|
|
5
6
|
// Obsidian projection contracts: closed v1 document shapes, the checks a JSON
|
|
6
7
|
// schema cannot express, and the scope selector algebra.
|
|
@@ -29,12 +30,15 @@ const MAX_SELECTOR_DEPTH = 64
|
|
|
29
30
|
|
|
30
31
|
// Portable shapes may be committed or shared and must never carry a
|
|
31
32
|
// machine-local absolute path, port or policy. Private shapes live only in
|
|
32
|
-
// ignored machine-private state.
|
|
33
|
+
// ignored machine-private state. A shape registered in more than one major
|
|
34
|
+
// version is validated against the version its `schema` names; the generation
|
|
35
|
+
// manifest has two, one per vault layout.
|
|
33
36
|
export const OBSIDIAN_CONTRACTS = Object.freeze([
|
|
34
37
|
['corpus-profile', 'portable'],
|
|
35
38
|
['scope', 'portable'],
|
|
36
39
|
['source-snapshot', 'portable'],
|
|
37
40
|
['generation-manifest', 'portable'],
|
|
41
|
+
['generation-manifest', 'portable', 2],
|
|
38
42
|
['publication-journal', 'private'],
|
|
39
43
|
['service-state', 'private'],
|
|
40
44
|
['edit-operation', 'portable'],
|
|
@@ -42,13 +46,14 @@ export const OBSIDIAN_CONTRACTS = Object.freeze([
|
|
|
42
46
|
['proposal-receipt', 'portable'],
|
|
43
47
|
['acceptance-receipt', 'portable'],
|
|
44
48
|
['ext-settings', 'portable'],
|
|
45
|
-
].map(([shape, portability]) => Object.freeze({
|
|
49
|
+
].map(([shape, portability, version = 1]) => Object.freeze({
|
|
46
50
|
shape,
|
|
47
51
|
portability,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
version,
|
|
53
|
+
name: version === 1 ? `atelier-obsidian-${shape}` : `atelier-obsidian-${shape}-v${version}`,
|
|
54
|
+
schemaConst: `atelier-obsidian-${shape}/v${version}`,
|
|
55
|
+
contractFile: `contracts/atelier-obsidian-${shape}.v${version}.schema.json`,
|
|
56
|
+
fixtureRoot: version === 1 ? `fixtures/obsidian/contracts/${shape}` : `fixtures/obsidian/contracts/${shape}-v${version}`,
|
|
52
57
|
})))
|
|
53
58
|
|
|
54
59
|
export class ObsidianContractRefusal extends Error {
|
|
@@ -80,18 +85,33 @@ function compareIds(left, right) {
|
|
|
80
85
|
|
|
81
86
|
const schemaCache = new Map()
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
// The contract a document is validated against: the registered version its
|
|
89
|
+
// `schema` names, else the first version of the shape, whose refusal then
|
|
90
|
+
// names the mismatch.
|
|
91
|
+
function contractFor(shape, doc) {
|
|
92
|
+
const versions = OBSIDIAN_CONTRACTS.filter((item) => item.shape === shape)
|
|
93
|
+
if (versions.length === 0) refuse('unknown-contract-shape', 'no Obsidian contract is registered for the requested shape')
|
|
94
|
+
return versions.find((item) => isPlainObject(doc) && doc.schema === item.schemaConst) ?? versions[0]
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
function loadSchema(contract) {
|
|
90
|
-
if (!schemaCache.has(contract.
|
|
98
|
+
if (!schemaCache.has(contract.name)) {
|
|
91
99
|
const url = new URL(`../../../${contract.contractFile}`, import.meta.url)
|
|
92
|
-
schemaCache.set(contract.
|
|
100
|
+
schemaCache.set(contract.name, JSON.parse(fs.readFileSync(url, 'utf8')))
|
|
93
101
|
}
|
|
94
|
-
return schemaCache.get(contract.
|
|
102
|
+
return schemaCache.get(contract.name)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Whether a value is an identifier as the contracts define one: the
|
|
106
|
+
// `identifier` of the scope contract, which names a view, read from the
|
|
107
|
+
// shipped contract rather than restated.
|
|
108
|
+
let identifierRule = null
|
|
109
|
+
export function isContractIdentifier(value) {
|
|
110
|
+
if (identifierRule === null) {
|
|
111
|
+
const { minLength, maxLength, pattern } = loadSchema(contractFor('scope')).$defs.identifier
|
|
112
|
+
identifierRule = { minLength, maxLength, pattern: new RegExp(pattern, 'u') }
|
|
113
|
+
}
|
|
114
|
+
return typeof value === 'string' && value.length >= identifierRule.minLength && value.length <= identifierRule.maxLength && identifierRule.pattern.test(value)
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
const ABSOLUTE_PATH = /^(?:\/|~[\\/]|[A-Za-z]:[\\/]|\\\\|file:)/
|
|
@@ -172,12 +192,30 @@ const SEMANTIC_CHECKS = {
|
|
|
172
192
|
}
|
|
173
193
|
})
|
|
174
194
|
},
|
|
175
|
-
'generation-manifest'(doc, errors) {
|
|
195
|
+
'generation-manifest'(doc, errors, version) {
|
|
176
196
|
const identities = doc.notes.map((note) => `${note.repoId}\u0000${note.nodeId}`)
|
|
177
197
|
if (duplicates(identities).length > 0) errors.push({ code: 'duplicate-identity', message: '/notes repeats a canonical identity' })
|
|
178
198
|
if (duplicates(doc.notes.map((note) => note.path.toLowerCase())).length > 0) {
|
|
179
199
|
errors.push({ code: 'path-collision', message: '/notes allocates one path to more than one note' })
|
|
180
200
|
}
|
|
201
|
+
if (version === 2) {
|
|
202
|
+
// Notes and files share folders in layout 2: no two of them may be one file on a case- or
|
|
203
|
+
// normalization-insensitive file system, and no file may have the name of a folder.
|
|
204
|
+
const files = [...doc.notes.map((note) => note.path), ...doc.attachments.map((attachment) => attachment.path)]
|
|
205
|
+
if (duplicates(files.map(collisionKey)).length > 0) errors.push({ code: 'path-collision', message: '/notes and /attachments allocate one path to more than one file' })
|
|
206
|
+
const folders = new Set(files.flatMap((file) => file.split('/').slice(0, -1).map((_, index, parts) => collisionKey(parts.slice(0, index + 1).join('/')))))
|
|
207
|
+
if (files.some((file) => folders.has(collisionKey(file)))) errors.push({ code: 'path-collision', message: '/notes and /attachments give a file the name of a folder' })
|
|
208
|
+
doc.notes.forEach((note, index) => {
|
|
209
|
+
const { identity, body, generated } = note.regions
|
|
210
|
+
const last = generated.at(-1)
|
|
211
|
+
const inPrefix = identity.end <= body.start
|
|
212
|
+
const atEnd = last?.kind === 'identity' && last.range.start === identity.start && last.range.end === identity.end
|
|
213
|
+
if (!inPrefix && !atEnd) errors.push({ code: 'identity-region-misplaced', message: `/notes/${index}/regions/identity is neither in the front matter nor the last generated region` })
|
|
214
|
+
if (generated.some((region, at) => region.kind === 'identity' && at !== generated.length - 1)) {
|
|
215
|
+
errors.push({ code: 'identity-region-misplaced', message: `/notes/${index}/regions/generated has an identity region that is not the last` })
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
}
|
|
181
219
|
if (duplicates(doc.links.map((link) => link.edgeId)).length > 0) errors.push({ code: 'duplicate-identity', message: '/links repeats an edge identity' })
|
|
182
220
|
const nodeIds = new Set(doc.notes.map((note) => note.nodeId))
|
|
183
221
|
doc.links.forEach((link, index) => {
|
|
@@ -187,7 +225,7 @@ const SEMANTIC_CHECKS = {
|
|
|
187
225
|
}
|
|
188
226
|
})
|
|
189
227
|
doc.notes.forEach((note, index) => {
|
|
190
|
-
const ranges = [note.regions.frontmatter, note.regions.body, ...note.regions.generated.map((region) => region.range)].filter(Boolean)
|
|
228
|
+
const ranges = [note.regions.frontmatter, note.regions.identity, note.regions.body, ...note.regions.generated.map((region) => region.range)].filter(Boolean)
|
|
191
229
|
if (ranges.some((range) => range.end < range.start)) errors.push({ code: 'invalid-byte-range', message: `/notes/${index}/regions has a range that ends before it starts` })
|
|
192
230
|
})
|
|
193
231
|
if (doc.completeness.status === 'complete' && doc.completeness.writtenNotes !== doc.completeness.expectedNotes) {
|
|
@@ -227,11 +265,11 @@ const SEMANTIC_CHECKS = {
|
|
|
227
265
|
// { layer: 'schema' | 'semantic', code, message }. Semantic checks run only on
|
|
228
266
|
// schema-valid documents, so they can rely on the shape.
|
|
229
267
|
export function validateObsidianContract(shape, doc) {
|
|
230
|
-
const contract = contractFor(shape)
|
|
268
|
+
const contract = contractFor(shape, doc)
|
|
231
269
|
const schemaErrors = validateJsonSchema(loadSchema(contract), doc)
|
|
232
270
|
if (schemaErrors.length > 0) return schemaErrors.map((message) => ({ layer: 'schema', code: 'schema-violation', message }))
|
|
233
271
|
const errors = []
|
|
234
|
-
SEMANTIC_CHECKS[shape](doc, errors)
|
|
272
|
+
SEMANTIC_CHECKS[shape](doc, errors, contract.version)
|
|
235
273
|
if (contract.portability === 'portable') {
|
|
236
274
|
const found = []
|
|
237
275
|
collectAbsolutePaths(doc, '', found)
|
|
@@ -260,9 +298,12 @@ export function readObsidianExtSettings(projectConfigDoc) {
|
|
|
260
298
|
}
|
|
261
299
|
|
|
262
300
|
// ---------------------------------------------------------------------------
|
|
263
|
-
// Note identity and readable paths
|
|
301
|
+
// Note identity and readable paths, vault layout 1
|
|
264
302
|
// ---------------------------------------------------------------------------
|
|
265
303
|
|
|
304
|
+
// Layout 1, what releases up to 0.2.0-alpha.11 wrote and what a view held for
|
|
305
|
+
// an open edit is still prepared in: `notes/<readable title>--<suffix>.md`.
|
|
306
|
+
|
|
266
307
|
const RESERVED_BASENAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
|
|
267
308
|
|
|
268
309
|
// Stable identity suffix: a digest of repository identity and node identity,
|
|
@@ -313,6 +354,171 @@ export function allocateNotePaths(nodes) {
|
|
|
313
354
|
return allocated
|
|
314
355
|
}
|
|
315
356
|
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Vault layout 2: readable names
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
// The file name is the title a person reads and the folders are the
|
|
362
|
+
// repository's own. These are the pure naming rules; allocation, collisions
|
|
363
|
+
// and stability are the path registry's (materialize/path-registry.mjs).
|
|
364
|
+
|
|
365
|
+
export const VAULT_LAYOUT_VERSION = 2
|
|
366
|
+
export const NAME_BYTES = 150
|
|
367
|
+
export const FOLDER_NAME_BYTES = 120
|
|
368
|
+
export const QUALIFIER_STEM_BYTES = 60
|
|
369
|
+
export const MIN_NAME_BYTES = 16
|
|
370
|
+
|
|
371
|
+
// Windows device names, compared on the part before the first dot.
|
|
372
|
+
const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i
|
|
373
|
+
// Characters no name may hold: controls, path separators, what Windows
|
|
374
|
+
// forbids, and what ends or splits an Obsidian link (`# ^ [ ] |`).
|
|
375
|
+
const NAME_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029/\\:*?"<>|#^[\]]/g
|
|
376
|
+
// Invisible formatting that can make a name read as another: bidirectional
|
|
377
|
+
// embedding, override and isolate controls, and a stray U+FEFF.
|
|
378
|
+
const NAME_INVISIBLE = /[\u202a-\u202e\u2066-\u2069\ufeff]/g
|
|
379
|
+
// What a cut may leave dangling at the end of a name.
|
|
380
|
+
const NAME_DANGLING = /[\p{M}\u200d\ufe00-\ufe0f]+$/u
|
|
381
|
+
|
|
382
|
+
const trimName = (value) => value.replace(/^[ .]+|[ .]+$/g, '')
|
|
383
|
+
|
|
384
|
+
// Two paths that a case-insensitive or normalization-insensitive file system
|
|
385
|
+
// would treat as one share a collision key.
|
|
386
|
+
export function collisionKey(value) {
|
|
387
|
+
return value.normalize('NFKC').toUpperCase().toLowerCase().normalize('NFKC')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The longest prefix of `value` within `maxBytes` of UTF-8, cut between code
|
|
391
|
+
// points, without a combining character, joiner or variation selector left
|
|
392
|
+
// dangling at the end.
|
|
393
|
+
export function cutName(value, maxBytes) {
|
|
394
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
|
|
395
|
+
let kept = ''
|
|
396
|
+
let bytes = 0
|
|
397
|
+
for (const character of value) {
|
|
398
|
+
bytes += Buffer.byteLength(character, 'utf8')
|
|
399
|
+
if (bytes > maxBytes) break
|
|
400
|
+
kept += character
|
|
401
|
+
}
|
|
402
|
+
return trimName(kept.replace(NAME_DANGLING, ''))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// One file or folder name made safe for macOS, Linux, Windows and Obsidian
|
|
406
|
+
// links, or '' when nothing is left of it. The author's casing and words are
|
|
407
|
+
// kept; see "File names" in docs/obsidian-contract.md for each rule.
|
|
408
|
+
export function vaultName(value, maxBytes = NAME_BYTES) {
|
|
409
|
+
const cleaned = trimName(String(value ?? '')
|
|
410
|
+
.toWellFormed()
|
|
411
|
+
.normalize('NFC')
|
|
412
|
+
.replace(NAME_INVISIBLE, '')
|
|
413
|
+
.replace(NAME_FORBIDDEN, ' ')
|
|
414
|
+
.replace(/ {2,}/g, ' '))
|
|
415
|
+
const name = cutName(cleaned, maxBytes)
|
|
416
|
+
if (name === '' || !WINDOWS_DEVICE_NAMES.test(name.split('.')[0].trimEnd())) return name
|
|
417
|
+
return `_${cutName(name, maxBytes - 1)}`
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function fileNameOf(sourcePath) {
|
|
421
|
+
return String(sourcePath ?? '').split('/').at(-1)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A node's extension: the canonical record's, else its path's.
|
|
425
|
+
export function extensionOf(node) {
|
|
426
|
+
if (typeof node?.extension === 'string' && node.extension !== '') return node.extension
|
|
427
|
+
const name = fileNameOf(node?.path)
|
|
428
|
+
const dot = name.lastIndexOf('.')
|
|
429
|
+
return dot > 0 ? name.slice(dot + 1) : ''
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function fileStemOf(sourcePath) {
|
|
433
|
+
const name = fileNameOf(sourcePath)
|
|
434
|
+
const dot = name.lastIndexOf('.')
|
|
435
|
+
return dot > 0 ? name.slice(0, dot) : name
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// A title that ends in the source's own extension (`notes.md`) came from a file
|
|
439
|
+
// name; the extension is dropped. Only the source's own extension: `Node.js`
|
|
440
|
+
// or `Version 2.0` keep their ending.
|
|
441
|
+
function withoutOwnExtension(title, extension) {
|
|
442
|
+
if (!/^[A-Za-z0-9]{1,16}$/.test(extension)) return title
|
|
443
|
+
const trimmed = title.trimEnd()
|
|
444
|
+
const suffix = `.${extension}`
|
|
445
|
+
const before = trimmed.length - suffix.length
|
|
446
|
+
if (before < 1 || trimmed.slice(before).toLowerCase() !== suffix.toLowerCase() || /\s/.test(trimmed[before - 1])) return title
|
|
447
|
+
return trimmed.slice(0, before)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The name of a Markdown note, from the node record alone: its canonical
|
|
451
|
+
// title (front-matter title, else first H1), or the file stem as written when
|
|
452
|
+
// the title is the graph's fallback, the title-cased file name. Never ''.
|
|
453
|
+
export function noteNameOf(node) {
|
|
454
|
+
const fileName = fileNameOf(node.path)
|
|
455
|
+
const stem = fileStemOf(node.path)
|
|
456
|
+
const title = typeof node.title === 'string' ? node.title : ''
|
|
457
|
+
const chosen = title.trim() === '' || title === titleCase(fileName) ? stem : withoutOwnExtension(title, extensionOf(node))
|
|
458
|
+
return vaultName(chosen) || vaultName(stem) || 'Untitled'
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A wrapped or embedded file keeps its own name: `{ stem, extension }`, made
|
|
462
|
+
// safe, the stem bounded so that the extension still fits. Never an empty stem.
|
|
463
|
+
export function fileNameParts(sourcePath) {
|
|
464
|
+
const name = fileNameOf(sourcePath)
|
|
465
|
+
const dot = name.lastIndexOf('.')
|
|
466
|
+
const extension = dot > 0 ? vaultName(name.slice(dot + 1), 16) : ''
|
|
467
|
+
const budget = NAME_BYTES - (extension === '' ? 0 : Buffer.byteLength(extension, 'utf8') + 1)
|
|
468
|
+
const stem = vaultName(dot > 0 ? name.slice(0, dot) : name, budget) || 'Untitled'
|
|
469
|
+
return { stem, extension }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export const joinFileName = ({ stem, extension }) => (extension === '' ? stem : `${stem}.${extension}`)
|
|
473
|
+
|
|
474
|
+
// A folder segment of the mirrored source directory, or the repository folder.
|
|
475
|
+
export function folderNameOf(segment) {
|
|
476
|
+
return vaultName(segment, FOLDER_NAME_BYTES) || '_'
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The stable short identity a colliding name is qualified with: hexadecimal
|
|
480
|
+
// characters of the SHA-256 of the repository and node identity (the digest
|
|
481
|
+
// the layout 1 suffix is a prefix of).
|
|
482
|
+
export function qualifierIdOf(repoId, nodeId, length = 6) {
|
|
483
|
+
return createHash('sha256').update(`${repoId}\u0000${nodeId}`).digest('hex').slice(0, length)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Whether `value` is a path of layout 2: relative, every segment a name the
|
|
487
|
+
// rules above can produce (no forbidden character, no leading or trailing
|
|
488
|
+
// space or dot, at most 255 bytes).
|
|
489
|
+
const HAS_FORBIDDEN = new RegExp(NAME_FORBIDDEN.source)
|
|
490
|
+
const HAS_INVISIBLE = new RegExp(NAME_INVISIBLE.source)
|
|
491
|
+
|
|
492
|
+
export function isReadableVaultPath(value) {
|
|
493
|
+
if (typeof value !== 'string' || value === '' || Buffer.byteLength(value, 'utf8') > 1024) return false
|
|
494
|
+
return value.split('/').every((segment) => segment !== ''
|
|
495
|
+
&& trimName(segment) === segment
|
|
496
|
+
&& !HAS_FORBIDDEN.test(segment)
|
|
497
|
+
&& !HAS_INVISIBLE.test(segment)
|
|
498
|
+
&& !WINDOWS_DEVICE_NAMES.test(segment.split('.')[0].trimEnd())
|
|
499
|
+
&& segment === segment.normalize('NFC')
|
|
500
|
+
&& Buffer.byteLength(segment, 'utf8') <= 255)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// The layout a generation manifest was prepared in.
|
|
504
|
+
export function manifestLayoutVersion(manifest) {
|
|
505
|
+
return manifest?.schema === 'atelier-obsidian-generation-manifest/v2' ? manifest.layoutVersion : 1
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The three generated properties that name a note's identity, in order.
|
|
509
|
+
export const IDENTITY_KEYS = Object.freeze(['atelier-id', 'atelier-repo', 'atelier-source'])
|
|
510
|
+
|
|
511
|
+
// A YAML double-quoted scalar: JSON's escapes, plus `\u` for what YAML does
|
|
512
|
+
// not allow unescaped (DEL, C1 controls, U+2028, U+2029, U+FEFF).
|
|
513
|
+
export function yamlQuoted(value) {
|
|
514
|
+
return JSON.stringify(String(value)).replace(/[\u007f-\u009f\u2028\u2029\ufeff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The identity lines of one note, without line endings.
|
|
518
|
+
export function identityLineTexts(node) {
|
|
519
|
+
return [[IDENTITY_KEYS[0], node.id], [IDENTITY_KEYS[1], node.repo], [IDENTITY_KEYS[2], node.path]].map(([key, value]) => `${key}: ${yamlQuoted(value)}`)
|
|
520
|
+
}
|
|
521
|
+
|
|
316
522
|
// ---------------------------------------------------------------------------
|
|
317
523
|
// Canonical snapshot and profile views
|
|
318
524
|
// ---------------------------------------------------------------------------
|
|
@@ -11,7 +11,7 @@ import { ObsidianMaintenanceRefusal } from '../../../runtime/obsidian/errors.mjs
|
|
|
11
11
|
import { protectedRoots, readInstalledApplyPolicy, readLocalPointer, readMachineSettings, resolveDataRoot, workspaceStateRoot } from '../../../runtime/obsidian/machine-settings.mjs'
|
|
12
12
|
import { DEFAULT_ELIGIBILITY, createProductionSeams } from '../../../runtime/obsidian/pipeline.mjs'
|
|
13
13
|
import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
|
|
14
|
-
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
|
|
14
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../contracts.mjs'
|
|
15
15
|
import { readMarkdownLens } from '../materialize/byte-lens.mjs'
|
|
16
16
|
import { exchangeFiles, probeExchange } from '../publication/exchange.mjs'
|
|
17
17
|
import { createJournal, newJournalId } from '../recovery/journal.mjs'
|
|
@@ -338,22 +338,27 @@ export function createSourceApplyForOracleTests(primitives = SOURCE_APPLY_PRIMIT
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
// The note exactly as it was published, which the lens needs to tell generated bytes from authored ones. The
|
|
341
|
-
// publisher keeps no copy of a note it created, so the note is prepared again from the sources as they are now
|
|
342
|
-
// and used only when it has the digest the manifest recorded; a retained
|
|
341
|
+
// publisher keeps no copy of a note it created, so the note is prepared again from the sources as they are now,
|
|
342
|
+
// in the vault layout of that generation, and used only when it has the digest the manifest recorded; a retained
|
|
343
|
+
// object of that digest serves as well.
|
|
343
344
|
function publishedNoteOf(workspace, { scope, manifest, noteEntry }) {
|
|
344
345
|
const store = workspace.storeOf(scope.scopeId)
|
|
345
346
|
try { return store.readObject(noteEntry.noteDigest) } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'recovery-object-corrupt') throw error }
|
|
346
347
|
workspace.prepared ??= new Map()
|
|
347
|
-
|
|
348
|
+
const preparedKey = `${scope.scopeId}\u0000${manifest.generationId}`
|
|
349
|
+
if (!workspace.prepared.has(preparedKey)) {
|
|
348
350
|
let files = []
|
|
349
351
|
try {
|
|
350
352
|
const { graph, profile } = currentCorpus(workspace)
|
|
351
353
|
const snapshot = seams.captureSnapshot({ project: workspace.project, graph, workspaceId: workspace.workspaceId, index: new Map(), configDigest: manifest.ext?.[EXT]?.configDigest ?? `sha256:${'0'.repeat(64)}`, capturedAt: isoTime(clock) })
|
|
352
|
-
files = seams.prepareView({
|
|
354
|
+
files = seams.prepareView({
|
|
355
|
+
snapshot, profile, scope, persistentPathRegistry: workspace.stateStore.readPathRegistry(), priorManifest: manifest, existingSettings: null, clock,
|
|
356
|
+
vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), layout: manifestLayoutVersion(manifest),
|
|
357
|
+
}).files
|
|
353
358
|
} catch (error) { if (!isTyped(error) && !GONE.has(error?.code) && !UNREADABLE.has(error?.code)) throw error }
|
|
354
|
-
workspace.prepared.set(
|
|
359
|
+
workspace.prepared.set(preparedKey, files)
|
|
355
360
|
}
|
|
356
|
-
const file = workspace.prepared.get(
|
|
361
|
+
const file = workspace.prepared.get(preparedKey).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
|
|
357
362
|
return file ? file.bytes : null
|
|
358
363
|
}
|
|
359
364
|
|