Skip to content

Commit

Permalink
fix(store): snapshot import and export (toeverything#3223)
Browse files Browse the repository at this point in the history
  • Loading branch information
Saul-Mirone authored Jun 27, 2023
1 parent 169e827 commit b712dd6
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 113 deletions.
45 changes: 36 additions & 9 deletions packages/playground/src/components/debug-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,40 @@ export class DebugMenu extends ShadowlessElement {
this.contentParser.exportPng();
}

private _exportYDoc() {
this.workspace.exportYDoc();
private _exportSnapshot() {
const json = this.workspace.exportPageSnapshot(this.page.id);
const data =
'data:text/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(json, null, 2));
const a = document.createElement('a');
a.setAttribute('href', data);
a.setAttribute('download', `${this.page.id}-snapshot.json`);
a.click();
a.remove();
}

private async _importYDoc() {
await this.workspace.importYDoc();
this.requestUpdate();
private _importSnapshot() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', '.json');
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) {
return;
}
try {
const json = await file.text();
await this.workspace.importPageSnapshot(JSON.parse(json), this.page.id);
this.requestUpdate();
} catch (e) {
console.error('Invalid snapshot.');
console.error(e);
} finally {
input.remove();
}
};
input.click();
}

private async _inspect() {
Expand Down Expand Up @@ -595,11 +622,11 @@ export class DebugMenu extends ShadowlessElement {
<sl-menu-item @click=${this._exportPng}>
Export PNG
</sl-menu-item>
<sl-menu-item @click=${this._exportYDoc}>
Export YDoc
<sl-menu-item @click=${this._exportSnapshot}>
Export Snapshot
</sl-menu-item>
<sl-menu-item @click=${this._importYDoc}>
Import YDoc
<sl-menu-item @click=${this._importSnapshot}>
Import Snapshot
</sl-menu-item>
<sl-menu-item @click=${this._shareUrl}> Share URL</sl-menu-item>
<sl-menu-item @click=${this._toggleStyleDebugMenu}>
Expand Down
27 changes: 25 additions & 2 deletions packages/playground/src/components/start-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,31 @@ export class StartPanel extends LitElement {
<sl-card
class="card"
@click=${() => {
window.workspace.importYDoc();
tryMigrate(window.workspace.doc);
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', '.json');
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) {
return;
}
try {
const json = await file.text();
await window.workspace.importPageSnapshot(
JSON.parse(json),
window.page.id
);
tryMigrate(window.workspace.doc);
this.requestUpdate();
} catch (e) {
console.error('Invalid snapshot.');
console.error(e);
} finally {
input.remove();
}
};
input.click();
}}
>
<div slot="header">Import YDoc</div>
Expand Down
4 changes: 4 additions & 0 deletions packages/store/src/space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export class Space<
this._loaded = false;
}

clear() {
this._yBlocks.clear();
}

private _initSubDoc = () => {
const prefixedId = this.prefixedId;

Expand Down
31 changes: 20 additions & 11 deletions packages/store/src/utils/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,29 @@ export function serializeYDoc(doc: Y.Doc) {
return json;
}

function serializeY(value: unknown): unknown {
if (value instanceof Y.Doc) {
return serializeYDoc(value);
}
if (value instanceof Y.Map) {
return serializeYMap(value);
}
if (value instanceof Y.Text) {
return serializeYText(value);
}
if (value instanceof Y.Array) {
return value.toArray().map(x => serializeY(x));
}
if (value instanceof Y.AbstractType) {
return value.toJSON();
}
return value;
}

function serializeYMap(map: Y.Map<unknown>) {
const json: Record<string, unknown> = {};
map.forEach((value, key) => {
if (value instanceof Y.Map) {
json[key] = serializeYMap(value);
} else if (value instanceof Y.Text) {
json[key] = serializeYText(value);
} else if (value instanceof Y.Array) {
json[key] = value.toArray();
} else if (value instanceof Y.AbstractType) {
json[key] = value.toJSON();
} else {
json[key] = value;
}
json[key] = serializeY(value);
});
return json;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/workspace/indexer/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export class BlockIndexer {

private _getPage(pageId: PageId): Y.Doc | undefined {
if (pageId.startsWith('space:')) {
console.warn('Unexpected page prefix', pageId);
throw new Error(`Unexpected 'space:' prefix for: ${pageId}`);
}
pageId = `space:${pageId}`;
return this._doc.spaces.get(pageId) as Y.Doc | undefined;
Expand Down
15 changes: 4 additions & 11 deletions packages/store/src/workspace/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,6 @@ export class Page extends Space<FlatBlockMap> {
if (!flavour) {
throw new Error('Block props must contain flavour');
}
if (
!this.awarenessStore.getFlag('enable_database') &&
flavour === 'affine:database'
) {
throw new Error('database is not enabled');
}
const parentModel =
typeof parent === 'string' ? this.getBlockById(parent) : parent;

Expand All @@ -365,14 +359,13 @@ export class Page extends Space<FlatBlockMap> {
assertValidChildren(this._yBlocks, clonedProps);
const schema = this.getSchemaByFlavour(flavour);
assertExists(schema);
initInternalProps(yBlock, clonedProps);

initInternalProps(yBlock, clonedProps);
syncBlockProps(schema, yBlock, clonedProps, this._ignoredKeys);

const parentModel =
typeof parent === 'string' ? this._blockMap.get(parent) : parent;

const parentId = parentModel?.id ?? this._root?.id;
const parentId =
parentModel?.id ??
(schema.model.role === 'root' ? undefined : this._root?.id);

if (parentId) {
const yParent = this._yBlocks.get(parentId) as YBlock;
Expand Down
99 changes: 38 additions & 61 deletions packages/store/src/workspace/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,62 +260,26 @@ export class Workspace {
return this.indexer.search.search(query);
}

/**
* @internal Only for testing
*/
exportYDoc() {
const binary = Y.encodeStateAsUpdate(this.doc);
const file = new Blob([binary], { type: 'application/octet-stream' });
const fileUrl = URL.createObjectURL(file);

const link = document.createElement('a');
link.href = fileUrl;
link.download = 'workspace.ydoc';
link.click();

URL.revokeObjectURL(fileUrl);
}

/** @internal Only for testing */
async importYDoc() {
return new Promise<void>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.ydoc';
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) {
return reject();
}
const buffer = await file.arrayBuffer();
Y.applyUpdate(this.doc, new Uint8Array(buffer));
resolve();
};
input.onerror = reject;
input.click();
});
}

/**
* @internal
* Import an object expression of a page.
* Specify the page you want to update by passing the `pageId` parameter and it will
* create a new page if it does not exist.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async importPageSnapshot(json: any, pageId: string) {
async importPageSnapshot(json: unknown, pageId: string) {
const unprefix = (str: string) =>
str.replace('sys:', '').replace('prop:', '').replace('space:', '');
const visited = new Set();

let page = this.getPage(pageId);
if (!page) {
if (page) {
await page.waitForLoaded();
page.clear();
} else {
page = this.createPage({ id: pageId });
await page.waitForLoaded();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sanitize = async (props: any) => {
const sanitize = async (props: Record<string, never>) => {
const result: Record<string, unknown> = {};

// setup embed source
Expand All @@ -340,49 +304,62 @@ export class Workspace {
assertExists(page);
const storage = page.blobs;
assertExists(storage);
const id = await storage.set(imgBlob);
props['prop:sourceId'] = id;
props['prop:sourceId'] = (await storage.set(imgBlob)) as never;
}

for (const key of Object.keys(props)) {
Object.keys(props).forEach(key => {
if (key === 'sys:children' || key === 'sys:flavour') {
continue;
return;
}

result[unprefix(key)] = props[key];

// delta array to Y.Text
if (key === 'prop:text' || key === 'prop:title') {
const yText = new Y.Text();
yText.applyDelta(props[key]);
result[unprefix(key)] = new Text(yText);
}
}
});
return result;
};

const { blocks } = json as Record<string, never>;
assertExists(blocks, 'Snapshot structure is invalid');

const addBlockByProps = async (
page: Page,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: any,
parent: string | null
parent?: string
) => {
if (visited.has(props['sys:id'])) return;
const id = props['sys:id'] as string;
if (visited.has(id)) return;
const sanitizedProps = await sanitize(props);
page.addBlock(props['sys:flavour'], sanitizedProps, parent);
for (const id of props['sys:children']) {
addBlockByProps(page, json[id], props['sys:id']);
visited.add(id);
}
await props['sys:children'].reduce(
async (prev: Promise<unknown>, childId: string) => {
await prev;
await addBlockByProps(page, blocks[childId], id);
visited.add(childId);
},
Promise.resolve()
);
};

for (const block of Object.values(json)) {
assertExists(json);
await addBlockByProps(page, block, null);
}
const root = Object.values(blocks).find(block => {
const _block = block as Record<string, unknown>;
const flavour = _block['sys:flavour'] as string;
const schema = this.schema.flavourSchemaMap.get(flavour);
return schema?.model?.role === 'root';
});
await addBlockByProps(page, root);
}

exportPageSnapshot(pageId: string) {
const page = this.getPage(pageId);
assertExists(page, `page ${pageId} not found`);
return serializeYDoc(page.spaceDoc);
}

/** @internal Only for testing */
exportSnapshot() {
return serializeYDoc(this.doc);
}
Expand Down
24 changes: 6 additions & 18 deletions tests/selection/block.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,6 @@ test('click bottom of page and if the last is embed block, editor should insert
page,
}) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
await initImageState(page);

await page.evaluate(async () => {
Expand All @@ -1351,27 +1350,16 @@ test('click bottom of page and if the last is embed block, editor should insert
prop:hidden={false}
prop:index="a0"
>
<affine:image
prop:caption=""
prop:height={0}
prop:sourceId="ejImogf-Tb7AuKY-v94uz1zuOJbClqK-tWBxVr_ksGA="
prop:width={0}
/>
<affine:paragraph
prop:type="text"
/>
</affine:note>
<affine:page>
<affine:note
prop:background="--affine-background-secondary-color"
prop:hidden={false}
prop:index="a0"
>
<affine:image
prop:caption=""
prop:height={0}
prop:sourceId="ejImogf-Tb7AuKY-v94uz1zuOJbClqK-tWBxVr_ksGA="
prop:width={0}
/>
<affine:paragraph
prop:type="text"
/>
</affine:note>
</affine:page>
</affine:page>`
);
});
Expand Down

0 comments on commit b712dd6

Please sign in to comment.