@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
|
@@ -10,16 +10,19 @@ here enables a feature. The publisher under
|
|
|
10
10
|
## Registered shapes
|
|
11
11
|
|
|
12
12
|
Eleven closed v1 schemas, `contracts/atelier-obsidian-<shape>.v1.schema.json`,
|
|
13
|
+
and one v2 schema, `contracts/atelier-obsidian-generation-manifest.v2.schema.json`,
|
|
13
14
|
are registered in `src/contracts/corpus.mjs` with valid and invalid fixtures
|
|
14
|
-
under `fixtures/obsidian/contracts/<shape
|
|
15
|
-
|
|
15
|
+
under `fixtures/obsidian/contracts/<shape>/` (the v2 manifest under
|
|
16
|
+
`generation-manifest-v2/`). `src/projection/obsidian/contracts.mjs` validates
|
|
17
|
+
them and adds the refusals a schema cannot express; a generation manifest is
|
|
18
|
+
validated against the version its `schema` names.
|
|
16
19
|
|
|
17
20
|
| Shape | Portable | Purpose |
|
|
18
21
|
| --- | --- | --- |
|
|
19
22
|
| `corpus-profile` | yes | Workspace and repository identities, enrollment, audience |
|
|
20
23
|
| `scope` | yes | Mode and selector set-AST; an empty selection is valid and empty |
|
|
21
24
|
| `source-snapshot` | yes | Raw byte digests, graph pin, dirty state, single consistent read |
|
|
22
|
-
| `generation-manifest` | yes | Path map, byte regions, link inversion map, completeness |
|
|
25
|
+
| `generation-manifest` | yes | Path map, byte regions, link inversion map, completeness. v1 is vault layout 1; v2 is layout 2 (see "Vault layout") |
|
|
23
26
|
| `publication-journal` | no | Conditional operations, partial transition, restart; protocol ID required |
|
|
24
27
|
| `service-state` | no | Literal loopback, port, runtime ID, PID, executable, consent |
|
|
25
28
|
| `edit-operation` | yes | Object identity, origin generation, base digest, idempotency key |
|
|
@@ -46,15 +49,38 @@ refusals live in `fixtures/obsidian/contracts/oracles/scope-cases.json`.
|
|
|
46
49
|
|
|
47
50
|
### What redaction covers
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
The guarantee: Atelier never generates a reference to a note outside the
|
|
53
|
+
view. No link, embed or relation row the emitter writes targets one, no
|
|
54
|
+
identity block names one, and no folder name or collision qualifier is
|
|
55
|
+
derived from one; manifest entries and link inversions carry nothing about
|
|
56
|
+
one either.
|
|
57
|
+
|
|
58
|
+
Text that comes from an author is carried as authored. Emission is
|
|
59
|
+
byte-faithful: a visible author's bytes are emitted unchanged, so a visible
|
|
60
|
+
author's own references to withheld or out-of-selection documents (canonical
|
|
61
|
+
identities in front matter, repository-relative paths, link text) appear in
|
|
62
|
+
the vault exactly as written. Generated regions repeat author text as well:
|
|
63
|
+
the titles of in-view notes in relation rows, and a wrapped file's title,
|
|
64
|
+
summary and tags in its note. That text is carried as its authors wrote it
|
|
65
|
+
too, escaped for Markdown or, in file names and link aliases, with the
|
|
66
|
+
characters those cannot hold turned into spaces. A scoped vault is not a
|
|
67
|
+
confidentiality boundary against what visible authors wrote (owner decision,
|
|
68
|
+
2026-09-22). A redaction boundary over authored bytes cannot coexist with
|
|
69
|
+
byte-faithful emission; it would be a separate mode with its own evidence.
|
|
70
|
+
The deny-list below is defence in depth over the author text that generated
|
|
71
|
+
regions repeat, not the guarantee, and it follows the audience: a note outside
|
|
72
|
+
the selection that the view's audience may see is not withheld from the
|
|
73
|
+
vault's readers, so text naming it is reported, not refused (see "The
|
|
74
|
+
redaction guard").
|
|
75
|
+
|
|
76
|
+
A layout 2 vault shows, in its folder names, the repository identity and the
|
|
77
|
+
source directory chain of the notes in the view, and every note names its
|
|
78
|
+
repository and source path in its `atelier-repo` and `atelier-source`
|
|
79
|
+
properties; layout 1 showed neither. A folder exists only because a note of
|
|
80
|
+
the view, a file the view copies for an embed, or a file held for an open edit
|
|
81
|
+
is in it, though it may hold only some of that source directory's files. This
|
|
82
|
+
surface is acceptable for every vault, scoped ones included (owner decision,
|
|
83
|
+
2026-09-24).
|
|
58
84
|
|
|
59
85
|
Each note may carry the generated line `Relationships leading outside this
|
|
60
86
|
view: N`. N counts that note's relationships to notes that are visible but not
|
|
@@ -62,15 +88,416 @@ selected, so it reveals that count, and two views of one workspace can be
|
|
|
62
88
|
compared. It never counts a withheld node: edges to a withheld endpoint are
|
|
63
89
|
dropped before the count is taken.
|
|
64
90
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
A collision qualifier is the qualified note's own source file stem, or a
|
|
92
|
+
short id of its own identity (see "Collisions"); a qualified folder carries a
|
|
93
|
+
short id of its own source folder or repository. A qualifier appears only
|
|
94
|
+
because something this vault shows took the name first: another note of the
|
|
95
|
+
view, an asset the view copies, or a file held for an open edit, which stays
|
|
96
|
+
in the vault while its edit is open, also after its note has left the view.
|
|
97
|
+
The one exception is fixed and names no note: a repository whose folder would
|
|
98
|
+
be `notes` or `attachments` spelled another way (see "Folders"). It stays while the
|
|
99
|
+
qualified note is in the view, also after the other has left, because a path
|
|
100
|
+
never moves while its note is in the view. A note of another view, a withheld
|
|
101
|
+
note, or one that was never in this view never causes a qualifier by itself:
|
|
102
|
+
each view allocates among its own notes and the files its vault holds.
|
|
103
|
+
|
|
104
|
+
### The redaction guard
|
|
105
|
+
|
|
106
|
+
Before anything is returned, `prepareView` checks the whole result, every
|
|
107
|
+
note included, whether it was emitted on this call or reused from the
|
|
108
|
+
preparation cache. Any failure refuses the view with `redaction-failure`,
|
|
109
|
+
whose detail names the rule (`allow-list`, `identity-block` or `deny-list`)
|
|
110
|
+
and, where there is one, the note or file of this view it concerns: a path
|
|
111
|
+
allocated to this view (`notePath` or `filePath`), never the value that was
|
|
112
|
+
found and never a path in question. A deny-list refusal names the note whose
|
|
113
|
+
generated region holds the text, which for an incoming relation row is the
|
|
114
|
+
note the row is in, not the note whose title it repeats. An allow-list
|
|
115
|
+
refusal names the note by its allocated path, or the rule alone when the path
|
|
116
|
+
in question is the only one at hand (an attachment that is not the view's, a
|
|
117
|
+
manifest entry of no note of the view). The engine records the same in the
|
|
118
|
+
view's freshness entry, and `atelier obsidian status` shows it. Three rules,
|
|
119
|
+
in the vault layout the view is prepared in:
|
|
120
|
+
|
|
121
|
+
1. Allow-list. Every path the emitter wrote must be a path allocated to this
|
|
122
|
+
view: every note and attachment path of the manifest, every rewritten link
|
|
123
|
+
or embed target (the bytes an inversion emitted must be such a path, as it
|
|
124
|
+
is or percent-encoded the way the emitter encodes it; the words an
|
|
125
|
+
author's wikilink keeps after `|` are the author's), every attachment an
|
|
126
|
+
embed names, and every `[[…]]` or `![[…]]` target in a generated region,
|
|
127
|
+
without its `#` fragment or `|` alias (relation rows, a wrapper's link and
|
|
128
|
+
embed of its file). The identity block of each note must name exactly that
|
|
129
|
+
note's own repository, identity and source path, and nothing else may
|
|
130
|
+
appear in it.
|
|
131
|
+
2. Deny-list, defence in depth, following the audience. The free text of the
|
|
132
|
+
generated regions (the titles in relation rows, a wrapper's title, summary
|
|
133
|
+
and tags), the link targets excluded, is read as a reader sees it, with the
|
|
134
|
+
backslashes the emitter adds to escape Markdown removed, and compared in
|
|
135
|
+
NFC, against the identifiers of every census node and embedded asset
|
|
136
|
+
outside this view:
|
|
137
|
+
- Of a node or asset the view's audience may not see (withheld), an
|
|
138
|
+
unambiguous identifier refuses the view: an identity qualified by any
|
|
139
|
+
repository of the census (`<repository>:…`, whichever repository holds
|
|
140
|
+
the note), a repository-qualified source path (`<repository>/<path>`), a
|
|
141
|
+
repository-relative path that holds a folder (a `/`), or a vault path
|
|
142
|
+
recorded for it (see below). An identity that is a bare
|
|
143
|
+
word, and a file name at a repository's root (`README.md`, `index.md`),
|
|
144
|
+
are ambiguous and only reported: in-view text names such words and files
|
|
145
|
+
all the time.
|
|
146
|
+
- Of a node or asset the audience may see but this view does not hold (not
|
|
147
|
+
selected, or an asset the view does not copy), every identifier, a vault
|
|
148
|
+
path of it in another view included, is only reported: every view of a
|
|
149
|
+
workspace has one audience today, so such a note is not withheld from
|
|
150
|
+
anyone who reads this vault (decided 2026-09-24 under the owner decision
|
|
151
|
+
of 2026-09-22). So is a vault path of a note no longer in the census.
|
|
152
|
+
The rule follows the audience: a view with an audience of its own would
|
|
153
|
+
refuse for whatever that audience may not see, with no change to this
|
|
154
|
+
rule.
|
|
155
|
+
- A value that also identifies a note of this view names nothing outside it
|
|
156
|
+
and is neither.
|
|
157
|
+
|
|
158
|
+
What is reported is recorded as a diagnostic (see "Notes that were laid out
|
|
159
|
+
anyway"). A match is a whole token when no letter or digit is beside it,
|
|
160
|
+
and no one of `. _ : / -` joined to a letter or digit (a full stop that
|
|
161
|
+
ends a sentence is beside a whole token; `x.north-desk:a` is one longer
|
|
162
|
+
token); it is compared exactly, case included. The identity block, the one
|
|
163
|
+
place a note names its own repository-relative path, is held to rule 1.
|
|
164
|
+
3. Coverage. Rules 1 and 2 run over every note of the view on every
|
|
165
|
+
preparation; a reused note is judged exactly as an emitted one.
|
|
166
|
+
|
|
167
|
+
What an author wrote inside the authored body or front matter of an in-view
|
|
168
|
+
note (a withheld node's canonical identity in a relation list, a link to its
|
|
169
|
+
path) is authored bytes: it is emitted as written and never triggers the
|
|
170
|
+
guard. When the same words reach generated prose unchanged, as an incoming
|
|
171
|
+
relation row repeats an in-view title or a wrapper repeats its summary, rule 2
|
|
172
|
+
refuses the view. Where they reach generated text only in a form made safe for
|
|
173
|
+
a name, the outgoing relation row's alias and every file name, `:` and `/`
|
|
174
|
+
have become spaces and runs of whitespace one space; that form is the
|
|
175
|
+
author's text carried as authored, and rule 2 does not match it: there is no
|
|
176
|
+
fuzzy matching (the residual of the 2026-09-22 decision above).
|
|
177
|
+
|
|
178
|
+
A refusing deny-list is itself a signal. Someone who can edit the text of an
|
|
179
|
+
in-view note and watch the view go stale learns that the words they wrote name
|
|
180
|
+
something the audience may not see; the refusal never says which note or
|
|
181
|
+
value. That existence oracle is open only to a person who can already edit
|
|
182
|
+
in-view text, and is accepted with the rule.
|
|
183
|
+
|
|
184
|
+
The vault paths the deny-list knows come from two places only: the sections of
|
|
185
|
+
the persistent registry, which hold each view's last allocation, this view's
|
|
186
|
+
included, and this view's prior generation. So after the registry is lost, a
|
|
187
|
+
vault path another view holds is not denied until that view is prepared again
|
|
188
|
+
and writes its section; within one tick, what a view denies of another view
|
|
189
|
+
depends on the order the views are prepared in; and a view still held in
|
|
190
|
+
layout 1 records its layout 2 allocation in its section, not the layout 1
|
|
191
|
+
paths its vault holds, which other views therefore do not deny. The
|
|
192
|
+
identities and source paths of the same notes are always denied.
|
|
193
|
+
|
|
194
|
+
Identities are what the graph records. A sidecar's identity is checked for its
|
|
195
|
+
form, but a Markdown note's `kg.id` may be any non-empty string, a bare word
|
|
196
|
+
included; Atelier's own documents use repository-qualified identities
|
|
197
|
+
(`<repository>:<name>`). An identity qualified by any repository of the census
|
|
198
|
+
is unambiguous, whichever repository holds its note. A bare word could be any
|
|
199
|
+
word of a title, and a root file name such as `README.md` any repository's, so
|
|
200
|
+
both are reported, never refused. The matcher is one Aho–Corasick automaton
|
|
201
|
+
over every value, with the token boundary tested at each hit: building it
|
|
202
|
+
takes time and memory linear in the total length of the values, on every
|
|
203
|
+
preparation (some tenths of a second and about 100 MB at 120,000 values), and
|
|
204
|
+
it then reads a text once, however many values there are.
|
|
205
|
+
|
|
206
|
+
In layout 1 the guard additionally refuses any `--<hex>` identity suffix of a
|
|
207
|
+
census identity outside the view, anywhere in generated text or emitted paths,
|
|
208
|
+
as the earlier release did. `test/obsidian-materialization.test.mjs` carries a
|
|
209
|
+
mutation control per rule: with that rule removed, a view that breaks it is
|
|
210
|
+
accepted.
|
|
211
|
+
|
|
212
|
+
## Vault layout
|
|
213
|
+
|
|
214
|
+
A prepared view lays its files out in layout 2: the file name is the title a
|
|
215
|
+
person reads, the folders are the repository's own folders, and the identity a
|
|
216
|
+
machine needs is written into the note, not into its name. Obsidian shows a
|
|
217
|
+
file name as the note's title in the file explorer, tabs, the graph, search and
|
|
218
|
+
link completion, so the file name is the human title. The generation manifest
|
|
219
|
+
records the layout (`layoutVersion: 2`, in
|
|
220
|
+
`atelier-obsidian-generation-manifest/v2`) and keeps the path of every note and
|
|
221
|
+
attachment beside its identity, as before. Layout 1
|
|
222
|
+
(`notes/<readable title>--<identity suffix>.md`, flat, with attachments under
|
|
223
|
+
`attachments/`) is what releases up to 0.2.0-alpha.11 wrote; see "Upgrade from
|
|
224
|
+
layout 1".
|
|
225
|
+
|
|
226
|
+
### Folders
|
|
227
|
+
|
|
228
|
+
A note lives at `<repository folder>/<source directory>/<file name>`.
|
|
229
|
+
`<source directory>` is the directory of its source relative to its repository
|
|
230
|
+
root, segment by segment, and `<repository folder>` is the repository's
|
|
231
|
+
identity (`repoId`); each segment is made safe as a name (below). Nothing is
|
|
232
|
+
stripped or shared across repositories: a vault of several repositories has
|
|
233
|
+
one top-level folder per repository. No part of a path depends on a property
|
|
234
|
+
of the whole census, such as a prefix every path shares, so adding or removing
|
|
235
|
+
a file never moves another.
|
|
236
|
+
|
|
237
|
+
Two folders whose names differ only in case or Unicode normalization are one
|
|
238
|
+
folder on a case-insensitive file system (APFS, NTFS), with one spelling. The
|
|
239
|
+
first allocation of a folder in a view fixes its spelling, and a later note
|
|
240
|
+
whose folder differs from it only that way takes the existing spelling: every
|
|
241
|
+
path of a vault spells each folder one way, so the app reports each note under
|
|
242
|
+
exactly the path the manifest records, which is how the publisher finds the
|
|
243
|
+
note's open editors. Two repositories of a view whose folder names would
|
|
244
|
+
collide that way are told apart: the one allocated later gets ` (<id>)`, the
|
|
245
|
+
first 6 hexadecimal characters of the SHA-256 of its identity. A source folder
|
|
246
|
+
whose mirrored folder would take the name of a file already allocated (a
|
|
247
|
+
source directory `docs/Guide.md/` after a note `docs/Guide.md`, or `a/Data/`
|
|
248
|
+
after a wrapped file `a/data`) is qualified the same way, with the id of the
|
|
249
|
+
source folder: `docs/Guide.md (1a2b3c)/`. So is a repository folder whose name
|
|
250
|
+
a file held for an edit already spells another way in its folder (a layout 1
|
|
251
|
+
file under `notes/` and a repository named `Notes`).
|
|
252
|
+
|
|
253
|
+
The folders `notes` and `attachments`, where layout 1 kept every note and file,
|
|
254
|
+
count as taken in every vault: an upgrade retires their files but leaves the
|
|
255
|
+
folders, and on a case-insensitive file system a repository folder named
|
|
256
|
+
`Notes` would otherwise be merged into `notes`, spelled as the disk has it and
|
|
257
|
+
not as the manifest does. A repository whose folder differs from either only
|
|
258
|
+
in case or normalization gets ` (<id>)`; a repository named exactly `notes` or
|
|
259
|
+
`attachments` uses the folder as it is.
|
|
260
|
+
|
|
261
|
+
### File names
|
|
262
|
+
|
|
263
|
+
The name of a note is:
|
|
264
|
+
|
|
265
|
+
- for a Markdown source, its canonical title, the one the graph read: the
|
|
266
|
+
front-matter `title`, else the first H1, with the author's casing and
|
|
267
|
+
spacing. A title that ends in the source's own extension
|
|
268
|
+
(`07-tide-survey-brief.md`) loses that extension. When the graph's title
|
|
269
|
+
is exactly the title-cased file name, the graph's fallback, the title came
|
|
270
|
+
from the file name or equals it, and the file stem is used as written
|
|
271
|
+
(`getting-started`, not `Getting Started`). The rule reads the node record
|
|
272
|
+
only, so every allocation, and a selection resolved without the sources,
|
|
273
|
+
gives the same name. The record does not say where its title came from, so
|
|
274
|
+
an author heading that is exactly the title-cased file name (`# Getting
|
|
275
|
+
Started` in `getting-started.md`) names the note by its stem as well.
|
|
276
|
+
- for any other source, a wrapped file, the file name itself: the note is
|
|
277
|
+
`<file name>.md` (`depth-chart.pdf.md`) and the file keeps its own name
|
|
278
|
+
(`depth-chart.pdf`) in the same folder. The pairing is visible, a search
|
|
279
|
+
for the file finds both, and the file's own title (from its sidecar or
|
|
280
|
+
`<title>`) is the first heading of the note.
|
|
281
|
+
|
|
282
|
+
Every name and folder segment is made safe for macOS, Linux, Windows and
|
|
283
|
+
Obsidian links:
|
|
284
|
+
|
|
285
|
+
1. It is NFC-normalized.
|
|
286
|
+
2. Control characters (C0, C1, U+2028 and U+2029) and
|
|
287
|
+
`/ \ : * ? " < > | # ^ [ ]` become a space; bidirectional embedding,
|
|
288
|
+
override and isolate controls and U+FEFF are removed.
|
|
289
|
+
3. Runs of spaces become one space, and leading and trailing spaces and dots
|
|
290
|
+
are removed: a leading dot hides a file from the app, and Windows allows no
|
|
291
|
+
trailing dot or space.
|
|
292
|
+
4. It is cut to 150 bytes of UTF-8 (a folder segment to 120, a wrapped file's
|
|
293
|
+
stem so that its extension still fits), on a character boundary; a
|
|
294
|
+
combining character, zero-width joiner or variation selector left at the end by
|
|
295
|
+
the cut is removed.
|
|
296
|
+
5. A Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`,
|
|
297
|
+
`LPT0` to `LPT9`, `COM¹` to `COM³`, `LPT¹` to `LPT³`, `CONIN$`, `CONOUT$`),
|
|
298
|
+
compared case-insensitively on the part before the first dot, gets a `_`
|
|
299
|
+
in front.
|
|
300
|
+
6. A name left empty is the file stem, made safe the same way, else
|
|
301
|
+
`Untitled`; a folder segment left empty is `_`.
|
|
302
|
+
|
|
303
|
+
A path longer than the file system allows (255 bytes a name, and
|
|
304
|
+
`maxFullPathBytes`, 1024 by default, with the view's own vault root) is
|
|
305
|
+
shortened by cutting the title part further, never below 16 bytes. A folder is
|
|
306
|
+
kept only where a name of 16 bytes and its extensions still fits in it,
|
|
307
|
+
whatever the title of the note at hand, so a folder's spelling depends on its
|
|
308
|
+
source directory alone. A folder chain that leaves less room than that keeps
|
|
309
|
+
as many leading folders as fit, and the last folder kept, cut, carries
|
|
310
|
+
` (<id>)`, the first 6 hexadecimal characters of the SHA-256 of the
|
|
311
|
+
repository and the whole source directory: two deep directories under one
|
|
312
|
+
readable prefix stay two folders. When not even `<repository folder>/(<id>)`
|
|
313
|
+
leaves that room, the note sits directly in its repository's folder. Only a
|
|
314
|
+
note whose name does not fit even there, qualified if it collides, refuses
|
|
315
|
+
the view, with `path-too-long`: its repository's folder under the view's vault
|
|
316
|
+
root leaves no room for it.
|
|
317
|
+
|
|
318
|
+
### Collisions
|
|
319
|
+
|
|
320
|
+
Names are compared the way a case-insensitive, normalization-insensitive file
|
|
321
|
+
system compares them (`collisionKey`: NFKC, case-folded), across every file of
|
|
322
|
+
the vault: notes, wrapped files and embedded assets. When two would share a
|
|
323
|
+
name in one folder, the one allocated later is qualified:
|
|
324
|
+
|
|
325
|
+
1. `<name> (<source file stem>).md`, the stem made safe and cut to 60 bytes;
|
|
326
|
+
2. when that is taken too, `<name> (<stem>) (<id>).md`, where `<id>` is the
|
|
327
|
+
first 6 hexadecimal characters of the SHA-256 of the repository identity
|
|
328
|
+
and the node identity, lengthened two at a time, up to 16, until it is
|
|
329
|
+
free.
|
|
330
|
+
|
|
331
|
+
A wrapped file's name is already its file name, so it is qualified by the
|
|
332
|
+
identity alone, before its extension: `depth-chart (1a2b3c).pdf` and
|
|
333
|
+
`depth-chart (1a2b3c).pdf.md`. An embedded asset is qualified the same way.
|
|
334
|
+
A name that collides with nothing carries no qualifier and no hash.
|
|
335
|
+
|
|
336
|
+
Each view allocates among its own notes only, in canonical identity order
|
|
337
|
+
(repository, then node), then its embedded assets, and an allocation that its
|
|
338
|
+
prior generation published is never changed while the note is in the view: a
|
|
339
|
+
newcomer that collides takes the qualifier. The same notes and prior
|
|
340
|
+
generation give the same paths in whatever order the census lists them.
|
|
341
|
+
|
|
342
|
+
### Stability
|
|
343
|
+
|
|
344
|
+
A view's paths are seeded from what its prior generation published: a path,
|
|
345
|
+
once published, stays while its note is in the view. A retitled source keeps
|
|
346
|
+
its path: the note shows the new title in its heading and every relation row
|
|
347
|
+
that names it shows it too. Renaming on retitle arrives with the in-vault
|
|
348
|
+
plugin, which can use Obsidian's own rename so that the app's bookmarks, open
|
|
349
|
+
tabs, graph layout and links follow; a rename made by a program outside the
|
|
350
|
+
app would break all of them.
|
|
351
|
+
|
|
352
|
+
A note that leaves the view (deleted, withheld, no longer selected, or renamed
|
|
353
|
+
into a new identity, since a source without an identity of its own is
|
|
354
|
+
identified by its path) releases its path there, unless its file is held for
|
|
355
|
+
an edit: then the file stays in the vault and its name stays taken. A renamed
|
|
356
|
+
source therefore takes its name back instead of keeping a qualifier its old
|
|
357
|
+
name forced. A note that comes back is allocated again like a newcomer: it
|
|
358
|
+
gets its earlier path only when its title is unchanged and the names around it
|
|
359
|
+
are as they were, nothing having taken its name meanwhile and no note it
|
|
360
|
+
collided with having left. A published path that no longer fits the view's
|
|
361
|
+
vault root (a root that grew) is allocated again as well. One identity may
|
|
362
|
+
have different paths in different views: each view allocates among its own
|
|
363
|
+
notes, with its own vault root.
|
|
364
|
+
|
|
365
|
+
The persistent path registry keeps each view's last allocation in a section of
|
|
366
|
+
its own (`views.<scopeId>`); a selection resolved without the view's
|
|
367
|
+
generation reads it. The engine passes the views it maintains
|
|
368
|
+
(`viewScopeIds`), and the registry drops the section of any other view.
|
|
369
|
+
Preparation seeds from the prior generation itself, so a lost registry costs
|
|
370
|
+
no view its paths and is written again on the next preparation.
|
|
371
|
+
|
|
372
|
+
### Notes that were laid out anyway
|
|
373
|
+
|
|
374
|
+
What is worth a person's look, but no reason to refuse a view, is recorded as a
|
|
375
|
+
diagnostic that names the note and never a value: in the generation manifest
|
|
376
|
+
(`ext["mnstry.atelier.obsidian"].diagnostics`, layout 2), in the view's
|
|
377
|
+
freshness entry (the first 100), and in `atelier obsidian status`. The manifest
|
|
378
|
+
holds those of the preparation that first committed its generation: a
|
|
379
|
+
preparation that yields the same generation (the same files) does not commit
|
|
380
|
+
it again. The freshness entry, and so `status`, holds those of the latest
|
|
381
|
+
preparation. Each is
|
|
382
|
+
`{ code, rule?, repoId, nodeId, notePath }` (an embedded asset has `assetPath`
|
|
383
|
+
and `filePath` instead of `nodeId` and `notePath`):
|
|
384
|
+
|
|
385
|
+
- `folder-shortened`: the folder chain was cut to fit (see "File names");
|
|
386
|
+
- `folder-qualified`: a folder met a file of the same name (see "Folders");
|
|
387
|
+
- `author-identity-properties`: the author's own front matter holds one of the
|
|
388
|
+
identity keys, which Properties shows instead of the generated block (see
|
|
389
|
+
"Identity in the note");
|
|
390
|
+
- `bare-identity-in-generated-text` (rule `deny-list`): generated text of the
|
|
391
|
+
note holds, as a whole token, the bare-word identity or the root file name
|
|
392
|
+
(`README.md`) of a note the audience may not see (see "The redaction
|
|
393
|
+
guard");
|
|
394
|
+
- `unselected-identity-in-generated-text` (rule `deny-list`): generated text of
|
|
395
|
+
the note holds an identifier of a note or asset the audience may see but the
|
|
396
|
+
view does not hold.
|
|
397
|
+
|
|
398
|
+
A view refused by the redaction guard records `{ code: "redaction-failure",
|
|
399
|
+
rule, notePath | filePath }` in its freshness entry the same way.
|
|
400
|
+
|
|
401
|
+
### Identity in the note
|
|
402
|
+
|
|
403
|
+
Every note names its identity in three generated properties that Obsidian
|
|
404
|
+
shows (Properties, Bases, search) and any YAML reader or agent can read
|
|
405
|
+
without the manifest:
|
|
406
|
+
|
|
407
|
+
```yaml
|
|
408
|
+
atelier-id: "north-desk:harbor-plan"
|
|
409
|
+
atelier-repo: "north-desk"
|
|
410
|
+
atelier-source: "plans/harbor-plan.md"
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`atelier-id` is the canonical node identity, `atelier-repo` the repository
|
|
414
|
+
identity and `atelier-source` the repository-relative source path, each a
|
|
415
|
+
JSON string. They are the last lines of the note's front matter, directly
|
|
416
|
+
before its closing delimiter, when the source's front matter is a plain block
|
|
417
|
+
mapping at column 0 that does not already use one of the three keys. A source
|
|
418
|
+
without front matter gets a generated front matter holding only them (after a
|
|
419
|
+
byte order prefix, which the app does not read). Any other front matter (a flow
|
|
420
|
+
mapping, an indented or sequence root, a document marker, a line that is not a
|
|
421
|
+
key) could change meaning with a line added, and one that already uses one of
|
|
422
|
+
the three keys would hold it twice, so for those sources the lines go into a
|
|
423
|
+
generated `%%` comment block at the very end of the note instead, the last
|
|
424
|
+
generated region. Keys are not otherwise compared: a front matter that repeats
|
|
425
|
+
a key of its own still takes the lines. The line ending is the source's. When
|
|
426
|
+
the author's own front matter holds one of the three keys, it is what
|
|
427
|
+
Properties shows, not the generated block; the note is reported as
|
|
428
|
+
`author-identity-properties` (see "Notes that were laid out anyway").
|
|
429
|
+
|
|
430
|
+
The source digest is not written into the note. Once a person's edit is
|
|
431
|
+
applied, the source has a new digest, and the hold on the edited note lifts
|
|
432
|
+
only when the note prepared from the new source is byte for byte the note the
|
|
433
|
+
person edited; a digest in the note would keep it held forever. The digest of
|
|
434
|
+
every generation is in the manifest
|
|
435
|
+
(`notes[].ext["mnstry.atelier.obsidian"].source.rawDigest`).
|
|
436
|
+
|
|
437
|
+
The block is generated. The manifest records its bytes as `regions.identity`,
|
|
438
|
+
inverting a note removes them, and the edit lens never attributes them to the
|
|
439
|
+
author. An edit to them is a front-matter edit, which is never applied to a
|
|
440
|
+
source: it becomes a semantic proposal (see "Proposal adapter for structural
|
|
441
|
+
edits"). In the end-of-note placement it is a generated-region edit and
|
|
442
|
+
refuses.
|
|
443
|
+
|
|
444
|
+
### Links
|
|
445
|
+
|
|
446
|
+
A rewritten link or embed, a relation row and a wrapper's link to its file
|
|
447
|
+
name the target by its full vault path:
|
|
448
|
+
`[[north-desk/plans/Harbor plan.md|the plan]]`,
|
|
449
|
+
`[tide table](south-desk/tables/Tide%20table.md#spring)`,
|
|
450
|
+
`![[east-desk/pages/img/gauge.png|200]]`. A wikilink carries `.md`, so a note
|
|
451
|
+
named after a file never resolves to the file. Obsidian's resolver
|
|
452
|
+
(`MetadataCache.getLinkpathDest`, read in the 1.13.7 application bundle) looks
|
|
453
|
+
a path that contains a folder up as an exact vault path before it tries any
|
|
454
|
+
suffix or file-name match, so the target is exactly the allocated file,
|
|
455
|
+
whatever folder the linking note is in. A wikilink rewrite keeps the words the
|
|
456
|
+
author chose by appending `|<what the author wrote>`, as before.
|
|
457
|
+
|
|
458
|
+
### Upgrade from layout 1
|
|
459
|
+
|
|
460
|
+
A view whose prior generation is in layout 1 is laid out again once, in one
|
|
461
|
+
generation:
|
|
462
|
+
|
|
463
|
+
1. `prepareView` allocates layout 2 paths for every note and asset. The
|
|
464
|
+
layout 1 entries of the persistent registry are dropped, and the registry
|
|
465
|
+
records `layout: 2`. The generation is an
|
|
466
|
+
`atelier-obsidian-generation-manifest/v2`, and `changes.removed` names
|
|
467
|
+
every layout 1 path.
|
|
468
|
+
2. The publisher retires each layout 1 path with its ordinary remove unit. A
|
|
469
|
+
note or file that still holds its published bytes is moved to the recovery
|
|
470
|
+
area with a receipt; a note somebody edited is never removed: it stays
|
|
471
|
+
where it is and is surfaced as a retained edit. Nothing is deleted.
|
|
472
|
+
3. While a note of the view has an open edit, the view is not laid out again.
|
|
473
|
+
The engine passes as `heldNotePaths` the files of open edits, which stay in
|
|
474
|
+
the vault and keep their names taken in layout 2, and as
|
|
475
|
+
`layoutHeldNotePaths` those and the files whose edit closed on this tick.
|
|
476
|
+
`prepareView` prepares a view whose prior generation is in layout 1 and
|
|
477
|
+
holds one of the latter in layout 1, exactly as the earlier release did,
|
|
478
|
+
a new note included
|
|
479
|
+
(`notes/<title>--<suffix>.md`). A held note therefore stays under its
|
|
480
|
+
layout 1 path and is held, applied and withdrawn by the rules it was held
|
|
481
|
+
under. The engine lays a settled layout 1 view out again at the first tick
|
|
482
|
+
where no note holds it.
|
|
483
|
+
4. An edit made on a layout 1 note can be applied after the upgrade: apply
|
|
484
|
+
and the proposal adapter prepare the generation the edit was made on in
|
|
485
|
+
that generation's own layout (`layout`) to recover the note as it was
|
|
486
|
+
published.
|
|
487
|
+
|
|
488
|
+
The folders `notes/` and `attachments/` are left behind empty: the publisher
|
|
489
|
+
moves files, never folders, and a person may delete them. While a view waits
|
|
490
|
+
for a held note, a selection resolved from the registry names layout 2 paths
|
|
491
|
+
that the vault does not hold yet.
|
|
492
|
+
|
|
493
|
+
An edit that closes on a tick still holds the layout on that tick. In
|
|
494
|
+
automatic mode a policy applies an edit on the very tick the edit is observed:
|
|
495
|
+
the view is prepared in layout 1 once more, from the new source, so the note
|
|
496
|
+
the person edited becomes the published note, and the next tick lays the view
|
|
497
|
+
out again and retires that file to recovery like any other. It is never left
|
|
498
|
+
in the vault unobserved. In manual mode the hold lifts when the observed note
|
|
499
|
+
equals the published one, one tick in layout 1 follows the same way, and the
|
|
500
|
+
view is then laid out again.
|
|
74
501
|
|
|
75
502
|
## Graph behaviour changes since the shared link resolver
|
|
76
503
|
|
|
@@ -124,9 +551,11 @@ In a view, an asset is copied only when the embedding note is in the vault
|
|
|
124
551
|
set, the asset record carries `eligible: true` (`withEligibility` takes a
|
|
125
552
|
second predicate; without it every asset is withheld) and the profile enrols
|
|
126
553
|
its repository under the same audience rule as a node without an audience of
|
|
127
|
-
its own. It is copied once, byte for byte, to
|
|
128
|
-
|
|
129
|
-
|
|
554
|
+
its own. It is copied once, byte for byte, to its own path mirrored in the
|
|
555
|
+
vault (`<repository folder>/<source directory>/<file name>`, allocated like a
|
|
556
|
+
note; see "Vault layout"; in layout 1,
|
|
557
|
+
`attachments/<readable stem>--<identity suffix>.<ext>`), read through the
|
|
558
|
+
pinned snapshot (`source-not-in-snapshot`, `mixed-read`). The embed target is
|
|
130
559
|
rewritten through the link inversion machinery: a Markdown embed to the
|
|
131
560
|
percent-encoded vault-root path, a wikilink embed to the plain path with its
|
|
132
561
|
size or fragment left as authored and no alias added. The manifest lists the
|
|
@@ -185,13 +614,18 @@ seen by the graph, the snapshot or the view until observation hashes the file
|
|
|
185
614
|
again. The result reports `fileCensus: { reused, derived, read }`.
|
|
186
615
|
|
|
187
616
|
Preparation stage: `prepareView` takes `cache` (`createPreparationCache`).
|
|
188
|
-
Each note's bytes and manifest entry are a function of
|
|
189
|
-
|
|
190
|
-
and
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
617
|
+
Each note's bytes and manifest entry are a function of the vault layout and
|
|
618
|
+
emitter version, its pinned source digest, node record (repository, identity
|
|
619
|
+
and source path, which also make its identity block), allocated path (which
|
|
620
|
+
also names a wrapped file), generated rows (which carry the paths and titles
|
|
621
|
+
of their targets), outside-selection count and rewritten occurrences with
|
|
622
|
+
their emitted targets; those inputs form a dependency key, and a note whose
|
|
623
|
+
key equals the cached one reuses the cached bytes, manifest entry, attachment
|
|
624
|
+
record and inversions. Where the identity block goes and with which line
|
|
625
|
+
ending is decided from the source bytes, which the pinned digest covers.
|
|
626
|
+
Everything else (asset copies, links, settings, collision checks, the
|
|
627
|
+
manifest and the redaction guard over the whole result) runs as before. The
|
|
628
|
+
result reports
|
|
195
629
|
`preparation: { emitted, reused }`, and a preparation that emits exactly the
|
|
196
630
|
notes whose output changed is what the tests pin.
|
|
197
631
|
|
|
@@ -243,7 +677,7 @@ This is a layout inside the private store; the protocol is unchanged.
|
|
|
243
677
|
| --- | --- |
|
|
244
678
|
| Any candidate while it is written and fsynced (generated bytes only) | `staging/<journalId>/NNNNNN.candidate`, or `NNNNNN.late.candidate` for one that could not be known when the run began |
|
|
245
679
|
| Candidate of a file that is created (exclusive link, no exchange) | stays at that staging path until it is linked |
|
|
246
|
-
| Candidate of a file that is replaced (note, attachment, policy settings) | moved, complete, to `recovery/<journalId>/<unit>/exchange.candidate`, or `exchange.late.candidate` |
|
|
680
|
+
| Candidate of a file that is replaced (note, attachment, policy settings, Atelier's plugin files) | moved, complete, to `recovery/<journalId>/<unit>/exchange.candidate`, or `exchange.late.candidate` |
|
|
247
681
|
| Displaced bytes | the vault path, then that same candidate path, then `recovery/<journalId>/<unit>/displaced.bin` |
|
|
248
682
|
|
|
249
683
|
The journal names every exchange candidate, by path and SHA-256, before the
|
|
@@ -264,8 +698,10 @@ share nothing but the vault, so the vault lock lives under the vault's real
|
|
|
264
698
|
path in `.atelier-publication/`, a dot-directory that no note path can name
|
|
265
699
|
and that the app does not show. The second publisher refuses with
|
|
266
700
|
`publication-in-progress` and writes nothing. It is the only thing the
|
|
267
|
-
publisher writes in a vault besides notes, attachments
|
|
268
|
-
settings
|
|
701
|
+
publisher writes in a vault besides notes, attachments, the two policy
|
|
702
|
+
settings files (`core-plugins.json`, `community-plugins.json`) and Atelier's
|
|
703
|
+
own plugin files under `.obsidian/plugins/atelier-projection/` (see
|
|
704
|
+
[obsidian-plugin.md](obsidian-plugin.md)).
|
|
269
705
|
|
|
270
706
|
Release and recovery follow the view lock exactly. A finished publication
|
|
271
707
|
writes a release marker. A publisher that dies leaves a ticket naming its
|
|
@@ -322,6 +758,13 @@ A fifth run aborted because the harness could not open a note after the
|
|
|
322
758
|
second-window case; no bytes were involved. The receipts are maintainer-held
|
|
323
759
|
and are not part of this repository.
|
|
324
760
|
|
|
761
|
+
Every vault Atelier publishes now carries Atelier's own plugin
|
|
762
|
+
([obsidian-plugin.md](obsidian-plugin.md)), so a trusted vault runs one
|
|
763
|
+
community plugin. In phase 1 it registers no editor extension, no vault event
|
|
764
|
+
handler and nothing that writes; it only talks to the maintenance service over
|
|
765
|
+
loopback. The protocol cases above have not been re-run with it loaded; that
|
|
766
|
+
run is gate G20, before the plugin carries any part of publication.
|
|
767
|
+
|
|
325
768
|
### Receiving obligations for the production publisher
|
|
326
769
|
|
|
327
770
|
The publisher may not claim the protocol outside the proven boundary until
|
|
@@ -345,6 +788,193 @@ each open item is discharged with its own evidence.
|
|
|
345
788
|
| Link-then-rename | Rejected. It leaves a window in which a concurrent replacement is destroyed. |
|
|
346
789
|
| App-driven save after replacement | Rejected. Observed losing an outside writer's bytes; kept as a negative control. |
|
|
347
790
|
|
|
791
|
+
## Obsidian's vault list
|
|
792
|
+
|
|
793
|
+
Obsidian opens a vault from a link only when the folder is in its own vault
|
|
794
|
+
list. `atelier obsidian open` puts the view's vault there.
|
|
795
|
+
Outside its own storage (the data root, which holds the vaults and their
|
|
796
|
+
policy-owned `.obsidian/core-plugins.json`), the list is the only file of
|
|
797
|
+
another application that Atelier writes; to write it, Atelier writes nothing
|
|
798
|
+
else outside the app's user-data directory, and no vault note.
|
|
799
|
+
|
|
800
|
+
**The file.** `obsidian.json` in the app's user-data directory:
|
|
801
|
+
`$HOME/Library/Application Support/obsidian/` on macOS and
|
|
802
|
+
`$XDG_CONFIG_HOME/obsidian/` (else `$HOME/.config/obsidian/`) on Linux; no
|
|
803
|
+
location is known on other platforms. Its shape is
|
|
804
|
+
`{ "vaults": { "<16 hex>": { "path", "ts", "open"? } }, …other settings }`.
|
|
805
|
+
The app reads it when it starts and rewrites all of it, with
|
|
806
|
+
`JSON.stringify`, whenever its list changes; `open` is true while a window of
|
|
807
|
+
that vault is open and stays true for the vaults open when the app quit.
|
|
808
|
+
Modules: `src/projection/obsidian/publication/vault-list.mjs` (read only) and
|
|
809
|
+
`src/runtime/obsidian/app-registration.mjs` (the write).
|
|
810
|
+
|
|
811
|
+
**While Obsidian runs and answers its command line**, the file is the running
|
|
812
|
+
app's and is only read. `open` asks the app instead, through `obsidian-cli
|
|
813
|
+
eval` with constant scripts: `vault-list` (the app's own map) and `vault-open`
|
|
814
|
+
with the folder and `false`, which adds an existing folder to the list (the app
|
|
815
|
+
writes its own file) and opens it in a window; `true`, which would create a
|
|
816
|
+
folder, is never sent. The folder travels as a base64-encoded JSON payload, as
|
|
817
|
+
in the publication bridge, so a path never becomes code. The addition is
|
|
818
|
+
verified by reading the app's list again and finding the vault root's entry
|
|
819
|
+
(asked again, a bounded number of times, while the window the app just opened
|
|
820
|
+
is still loading and answers that a command does not exist yet); an addition
|
|
821
|
+
the app did not answer (a call that timed out) is looked up the same way, and
|
|
822
|
+
is `addition-not-answered` when the list does not show it. `open` then waits,
|
|
823
|
+
bounded, until the app answers for exactly that vault. Like Obsidian's own
|
|
824
|
+
"open folder as vault", `vault-open` also adds the folder to the operating
|
|
825
|
+
system's recently used documents (Recent Items on macOS).
|
|
826
|
+
|
|
827
|
+
An entry is the vault root's when its folder, as written, is the root as
|
|
828
|
+
given or its real path; a listed folder's own real path is read only when its
|
|
829
|
+
last component is the root's (the root reached through a linked parent), so
|
|
830
|
+
a vault on a mount that does not answer is never waited on, and a link to the
|
|
831
|
+
root under another name is not recognised.
|
|
832
|
+
|
|
833
|
+
**While Obsidian runs with no vault open**, its command line answers nothing
|
|
834
|
+
and the file is still the running app's: it is read, never written. A vault
|
|
835
|
+
it already lists is opened by path; one it does not is refused as
|
|
836
|
+
`no-vault-open`.
|
|
837
|
+
|
|
838
|
+
**Never inside another vault.** In every state, a vault inside a folder the
|
|
839
|
+
list has as a vault already (compared as written, against the vault root and
|
|
840
|
+
its real path) is not added, through the
|
|
841
|
+
app or in the file: that vault would show its notes too, and a call run in
|
|
842
|
+
its folder would reach that vault. `open` answers `launch-failed` with reason
|
|
843
|
+
`vault-inside-another-vault`.
|
|
844
|
+
|
|
845
|
+
**One folder open in several windows.** The list can name one folder more
|
|
846
|
+
than once (under another letter case, or through a link), and the app opens
|
|
847
|
+
one window per entry. When more than one of the entries that name the view's
|
|
848
|
+
folder is open, each window holds the vault and a call reaches one of them
|
|
849
|
+
only (`vaultRoute` answers `duplicated`): no publication call is made (the
|
|
850
|
+
publisher refuses `vault-open-in-several-windows`, a `publisher-conflict`),
|
|
851
|
+
and `open` answers `publisher-conflict` with that reason, names the entries
|
|
852
|
+
(`duplicates`) and launches nothing. The entries are read from the list's
|
|
853
|
+
`open` marks, and the app keeps the last window it closed marked open, so this
|
|
854
|
+
can hold with a single window showing; the next step is to remove the extra
|
|
855
|
+
entries from the list. While one entry has a window, a call
|
|
856
|
+
reaches only that entry, and `open` finds and launches it, so a closed entry of
|
|
857
|
+
the same folder is never opened beside it.
|
|
858
|
+
|
|
859
|
+
**A Flatpak or snap build** keeps its list inside its sandbox
|
|
860
|
+
(`~/.var/app/md.obsidian.Obsidian/config/obsidian/`,
|
|
861
|
+
`~/snap/obsidian/current/.config/obsidian/`) and never reads the file above.
|
|
862
|
+
On Linux the build in use is the one whose `obsidian.json` (native, Flatpak or
|
|
863
|
+
snap) was written last, since every build rewrites its own when a vault window
|
|
864
|
+
opens or closes; only when none was ever written, a Flatpak or snap
|
|
865
|
+
installation or leftover decides (`~/.var/app/md.obsidian.Obsidian`,
|
|
866
|
+
`~/.local/share/flatpak/app/md.obsidian.Obsidian`,
|
|
867
|
+
`/var/lib/flatpak/app/md.obsidian.Obsidian`, `~/snap/obsidian`,
|
|
868
|
+
`/snap/obsidian`, `/var/lib/snapd/snap/obsidian`). For a Flatpak or snap
|
|
869
|
+
build the file is neither read nor written, `open` answers
|
|
870
|
+
`obsidian-sandboxed`, and the vault is added through the running app only.
|
|
871
|
+
|
|
872
|
+
**While no Obsidian runs**, the vault is added to the file itself, and only
|
|
873
|
+
then:
|
|
874
|
+
|
|
875
|
+
1. The process table is read and must say, positively, `absent`. `running` or
|
|
876
|
+
`unknown` refuses (`app-may-be-running`).
|
|
877
|
+
2. The user-data directory and the file must exist, be this user's own, and be
|
|
878
|
+
a real directory and a regular file (neither a link), and the file must be
|
|
879
|
+
a JSON object whose `vaults`, when present, is an object, of at most 4 MiB.
|
|
880
|
+
A file with a second name (a hard link) is refused as
|
|
881
|
+
`obsidian-settings-unsafe`: the replacement would leave that name with the
|
|
882
|
+
old list. A missing
|
|
883
|
+
directory or file means Obsidian has not run on this account and is
|
|
884
|
+
refused (`obsidian-settings-missing`): the file is never created. The other
|
|
885
|
+
refusals are `obsidian-settings-unsafe`, `obsidian-settings-not-owned`,
|
|
886
|
+
`obsidian-settings-unreadable` and `obsidian-settings-not-object`.
|
|
887
|
+
3. A vault whose real path an entry already has is left as it is; nothing is
|
|
888
|
+
written. A vault inside a folder an entry has is refused
|
|
889
|
+
(`vault-inside-another-vault`).
|
|
890
|
+
4. The new document is the file's object with one entry added under a fresh
|
|
891
|
+
random 16-hex id that no entry has: `{ path: <the vault root's real path>,
|
|
892
|
+
ts: <now, ms>, open: true }`. Every other key and every other entry is kept
|
|
893
|
+
in its place, as values. A document that would be larger than 4 MiB is
|
|
894
|
+
refused (`obsidian-settings-too-large`).
|
|
895
|
+
5. A temporary file in the same directory is written with the file's mode and
|
|
896
|
+
fsynced; the bytes as they were are written, fsynced, to
|
|
897
|
+
`obsidian.json.atelier-backup-<UTC time>` beside it.
|
|
898
|
+
6. Immediately before the rename, the process table must still say `absent`
|
|
899
|
+
and the file must still hold the bytes that were read; otherwise both new
|
|
900
|
+
files are removed and the write is refused (`app-may-be-running`,
|
|
901
|
+
`obsidian-settings-changed`).
|
|
902
|
+
7. The temporary file is renamed over `obsidian.json` and the directory is
|
|
903
|
+
fsynced. Of the backups beside it, the first (the list as it was before
|
|
904
|
+
Atelier wrote it) and this one are kept, and any between them removed. The
|
|
905
|
+
process table is read once more: an app that appeared just then may have
|
|
906
|
+
read the list before the rename, so the addition is reported unconfirmed
|
|
907
|
+
and `open` answers `launch-failed` with reason
|
|
908
|
+
`app-started-during-registration` instead of launching; the next `open`
|
|
909
|
+
adds the vault through the app if it missed it. A file that cannot be read
|
|
910
|
+
back to find the new entry is reported unconfirmed as well, with reason
|
|
911
|
+
`registration-not-read-back`.
|
|
912
|
+
|
|
913
|
+
Every refusal writes nothing and leaves nothing behind: a temporary file or
|
|
914
|
+
backup created on the way is removed again whichever later step fails.
|
|
915
|
+
`open` then shows the vault by the steps of `launchPlan`
|
|
916
|
+
(`src/runtime/obsidian/launch-plan.mjs`, carried out by `runLaunchPlan`),
|
|
917
|
+
naming it `obsidian://open?vault=<id>` when the id reaches it first (a path is
|
|
918
|
+
matched against the list by string prefix, with no separator check), and by
|
|
919
|
+
its own listed path otherwise (another vault's folder named like the id, an
|
|
920
|
+
unusual id): a running app is handed the link through its command-line tool,
|
|
921
|
+
which takes a link even with its command line turned off, or through the
|
|
922
|
+
operating system when the tool did not take it; on macOS a quit app is started
|
|
923
|
+
plainly (`open -b md.obsidian`), which reopens every vault the list flags open
|
|
924
|
+
(the entry written above is flagged open), and handed the link through its
|
|
925
|
+
tool once the app itself answers the tool (`appAnswered`: a version or one of
|
|
926
|
+
the app's own lines; the tool's "unable to find Obsidian" while the app starts
|
|
927
|
+
does not count). A link is never handed to the operating system for an app
|
|
928
|
+
this launch started, since that would reach it while it starts. An app started with a link would delete the
|
|
929
|
+
reopen flag of every other vault without a window (1.13.7, checked on an
|
|
930
|
+
isolated instance). On Linux a quit app is started with the link, a known
|
|
931
|
+
limit. With its command line turned off the app answers every other command
|
|
932
|
+
with "Command line interface is not enabled"; that is read as
|
|
933
|
+
`app-cli-unavailable` / `cli-turned-off`, never as a version.
|
|
934
|
+
|
|
935
|
+
**Which window answers.** The command line (1.13.7) answers a call whose first
|
|
936
|
+
argument is `vault=<value>` in the window of the first listed vault whose id is
|
|
937
|
+
the value, or whose folder's name is the value in any letter case; any other
|
|
938
|
+
call in the window of the first listed vault whose folder is the tool's working
|
|
939
|
+
directory or contains it, the folders compared as written; and otherwise in the
|
|
940
|
+
vault window that had focus last. It opens a vault that takes a call when it is
|
|
941
|
+
closed. The first match in list order wins, not the deepest folder, so a vault
|
|
942
|
+
listed before the view's vault at a folder above it would take every call run
|
|
943
|
+
in the view's folder. Calls about no vault (the version, the vault list, the
|
|
944
|
+
addition) run in a directory that is no vault and name none. A call about the
|
|
945
|
+
view's vault goes where the list says only that vault takes it
|
|
946
|
+
(`vaultRoute` in `vault-list.mjs`): from a directory that is no vault with
|
|
947
|
+
`vault=<id>` first when that id names this vault first, which does not depend
|
|
948
|
+
on how a working directory is spelled; otherwise in its folder when the first
|
|
949
|
+
listed vault that is or contains that folder is this vault; when neither
|
|
950
|
+
holds, no call is made. The vault root is taken in the spelling the file
|
|
951
|
+
system stores (`realpathSync.native` on macOS and Linux), which is how the
|
|
952
|
+
operating system reports the tool's working directory: on a volume that folds
|
|
953
|
+
letter case, a data root given in another case neither hides a vault listed
|
|
954
|
+
above it nor routes a call there. `open`'s check
|
|
955
|
+
that the app answers for the vault is routed from the list it verified the
|
|
956
|
+
vault in. Publication calls are routed from the file, and only while it lists
|
|
957
|
+
the vault open; otherwise they run in a directory that is no vault and name
|
|
958
|
+
none, so maintenance never reopens a vault window that was closed and never
|
|
959
|
+
reaches another vault. The bridge still checks that the app answered for
|
|
960
|
+
exactly this vault, and `open` that the app answers for it, both folders taken
|
|
961
|
+
as the file system stores them: an app that holds the vault under another
|
|
962
|
+
letter case or through a link holds this vault, and a store written under
|
|
963
|
+
another spelling keeps its vault.
|
|
964
|
+
|
|
965
|
+
**Retries.** The maintenance service is told what the app looks like from
|
|
966
|
+
the process table and the file alone (whether an Obsidian process runs, and
|
|
967
|
+
which vaults the file shows open; the file is read, never written, by the
|
|
968
|
+
service): nothing runs in the app to find out. The app is asked for its
|
|
969
|
+
version only when a view is about to be published, and without blocking the
|
|
970
|
+
service. A tick requested over the service's listener for one view (`open`
|
|
971
|
+
names its own) prepares and publishes that view once more, with the app's
|
|
972
|
+
qualification asked again; the engine takes such requests only for views the
|
|
973
|
+
project declared at its last tick, and at most 64 before its first. A view
|
|
974
|
+
whose last publication did not settle is also tried again as soon as that
|
|
975
|
+
picture changes, and otherwise after a delay that starts at 30 seconds and
|
|
976
|
+
doubles per attempt, up to the full reconciliation interval.
|
|
977
|
+
|
|
348
978
|
Source application back into canonical files carries the same conditional-write
|
|
349
979
|
obligation against other source writers; its protocol follows.
|
|
350
980
|
|
|
@@ -556,7 +1186,11 @@ list`, `show`, `run` and `recover` answer such a record as a typed refusal.
|
|
|
556
1186
|
fragment of one left in the body, and text typed at the very end of the body
|
|
557
1187
|
together with a removed region all refuse `generated-region-edited`. Some of
|
|
558
1188
|
those refusals a person could have been spared; a generated byte in a source
|
|
559
|
-
is never accepted in exchange.
|
|
1189
|
+
is never accepted in exchange. The identity block in the front matter is
|
|
1190
|
+
generated as well: it is removed before the source bytes are made, and a
|
|
1191
|
+
note whose front matter changed, the identity lines included, is a
|
|
1192
|
+
front-matter edit that becomes a proposal. A byte order prefix the editor
|
|
1193
|
+
dropped is taken from the source, as before.
|
|
560
1194
|
- After an apply the next tick prepares the view again. When the prepared note
|
|
561
1195
|
is byte for byte the note the person edited (read from the vault at the
|
|
562
1196
|
moment of that decision, never taken from the engine's observation index; a
|