Fix sync inconsistencies across devices
Changed APIs to upload assets, reworked saving logic so that files are synced across devices when changed locally.
This commit is contained in:
parent
e165c69664
commit
4555ec275f
5 changed files with 104 additions and 60 deletions
|
@ -2,37 +2,51 @@ import {Dialog, getFrontend, ITabModel, openTab, Plugin} from "siyuan"
|
||||||
import Editor, {BaseWidget, EditorEventType} from "js-draw";
|
import Editor, {BaseWidget, EditorEventType} from "js-draw";
|
||||||
import { MaterialIconProvider } from '@js-draw/material-icons';
|
import { MaterialIconProvider } from '@js-draw/material-icons';
|
||||||
import 'js-draw/styles';
|
import 'js-draw/styles';
|
||||||
import {getFile, saveFile} from "@/file";
|
import {getFile, saveFile, uploadAsset} from "@/file";
|
||||||
import {DATA_PATH, JSON_MIME, SVG_MIME, TOOLBAR_PATH} from "@/const";
|
import {DATA_PATH, JSON_MIME, SVG_MIME, TOOLBAR_PATH} from "@/const";
|
||||||
import {replaceAntiCacheID} from "@/protyle";
|
import {replaceSyncID} from "@/protyle";
|
||||||
import {idToPath} from "@/helper";
|
import {IDsToAssetPath} from "@/helper";
|
||||||
|
import {removeFile} from "@/api";
|
||||||
|
|
||||||
export function openEditorTab(p: Plugin, path: string) {
|
export function openEditorTab(p: Plugin, fileID: string, initialSyncID: string) {
|
||||||
if(getFrontend() == "mobile") {
|
if(getFrontend() == "mobile") {
|
||||||
const dialog = new Dialog({
|
const dialog = new Dialog({
|
||||||
width: "100vw",
|
width: "100vw",
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
content: `<div id="DrawingPanel" style="width:100%; height: 100%;"></div>`,
|
content: `<div id="DrawingPanel" style="width:100%; height: 100%;"></div>`,
|
||||||
});
|
});
|
||||||
createEditor(dialog.element.querySelector("#DrawingPanel"), path);
|
createEditor(dialog.element.querySelector("#DrawingPanel"), fileID, initialSyncID);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for(const tab of p.getOpenedTab()["whiteboard"]) {
|
||||||
|
if(tab.data.fileID == fileID) {
|
||||||
|
alert("File is already open in another editor tab!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
openTab({
|
openTab({
|
||||||
app: p.app,
|
app: p.app,
|
||||||
custom: {
|
custom: {
|
||||||
title: 'Drawing',
|
title: 'Drawing',
|
||||||
icon: 'iconDraw',
|
icon: 'iconDraw',
|
||||||
id: "siyuan-jsdraw-pluginwhiteboard",
|
id: "siyuan-jsdraw-pluginwhiteboard",
|
||||||
data: { path: path }
|
data: {
|
||||||
|
fileID: fileID,
|
||||||
|
initialSyncID: initialSyncID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCallback(editor: Editor, path: string, saveButton: BaseWidget) {
|
async function saveCallback(editor: Editor, fileID: string, oldSyncID: string, saveButton: BaseWidget): Promise<string> {
|
||||||
|
|
||||||
const svgElem = editor.toSVG();
|
const svgElem = editor.toSVG();
|
||||||
|
let newSyncID;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saveFile(DATA_PATH + path, SVG_MIME, svgElem.outerHTML);
|
newSyncID = (await uploadAsset(fileID, SVG_MIME, svgElem.outerHTML)).syncID;
|
||||||
await replaceAntiCacheID(path);
|
await replaceSyncID(fileID, oldSyncID, newSyncID);
|
||||||
|
await removeFile(DATA_PATH + IDsToAssetPath(fileID, oldSyncID));
|
||||||
saveButton.setDisabled(true);
|
saveButton.setDisabled(true);
|
||||||
setTimeout(() => { // @todo improve save button feedback
|
setTimeout(() => { // @todo improve save button feedback
|
||||||
saveButton.setDisabled(false);
|
saveButton.setDisabled(false);
|
||||||
|
@ -41,11 +55,14 @@ async function saveCallback(editor: Editor, path: string, saveButton: BaseWidget
|
||||||
alert("Error saving drawing! Enter developer mode to find the error, and a copy of the current status.");
|
alert("Error saving drawing! Enter developer mode to find the error, and a copy of the current status.");
|
||||||
console.error(error);
|
console.error(error);
|
||||||
console.log("Couldn't save SVG: ", svgElem.outerHTML)
|
console.log("Couldn't save SVG: ", svgElem.outerHTML)
|
||||||
|
return oldSyncID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return newSyncID
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEditor(element: HTMLElement, path: string) {
|
export function createEditor(element: HTMLElement, fileID: string, initialSyncID: string) {
|
||||||
|
|
||||||
const editor = new Editor(element, {
|
const editor = new Editor(element, {
|
||||||
iconProvider: new MaterialIconProvider(),
|
iconProvider: new MaterialIconProvider(),
|
||||||
|
@ -60,14 +77,21 @@ export function createEditor(element: HTMLElement, path: string) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// restore drawing
|
// restore drawing
|
||||||
getFile(DATA_PATH + path).then(svg => {
|
getFile(DATA_PATH +IDsToAssetPath(fileID, initialSyncID)).then(svg => {
|
||||||
if(svg != null) {
|
if(svg != null) {
|
||||||
editor.loadFromSVG(svg);
|
editor.loadFromSVG(svg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let syncID = initialSyncID;
|
||||||
// save logic
|
// save logic
|
||||||
const saveButton = toolbar.addSaveButton(() => saveCallback(editor, path, saveButton));
|
const saveButton = toolbar.addSaveButton(() => {
|
||||||
|
saveCallback(editor, fileID, syncID, saveButton).then(
|
||||||
|
newSyncID => {
|
||||||
|
syncID = newSyncID
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
// save toolbar config on tool change (toolbar state is not saved in SVGs!)
|
// save toolbar config on tool change (toolbar state is not saved in SVGs!)
|
||||||
editor.notifier.on(EditorEventType.ToolUpdated, () => {
|
editor.notifier.on(EditorEventType.ToolUpdated, () => {
|
||||||
|
@ -81,15 +105,12 @@ export function createEditor(element: HTMLElement, path: string) {
|
||||||
|
|
||||||
export function editorTabInit(tab: ITabModel) {
|
export function editorTabInit(tab: ITabModel) {
|
||||||
|
|
||||||
let path = tab.data.path;
|
const fileID = tab.data.fileID;
|
||||||
if(path == null) {
|
const initialSyncID = tab.data.initialSyncID;
|
||||||
const fileID = tab.data.id; // legacy compatibility
|
if (fileID == null || initialSyncID == null) {
|
||||||
if (fileID == null) {
|
alert("File or Sync ID and path missing - couldn't open file.")
|
||||||
alert("File ID and path missing - couldn't open file.")
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
path = idToPath(fileID);
|
createEditor(tab.element, fileID, initialSyncID);
|
||||||
}
|
|
||||||
createEditor(tab.element, path);
|
|
||||||
|
|
||||||
}
|
}
|
17
src/file.ts
17
src/file.ts
|
@ -1,10 +1,25 @@
|
||||||
import {getFileBlob, putFile} from "@/api";
|
import {getFileBlob, putFile, upload} from "@/api";
|
||||||
|
import {ASSETS_PATH} from "@/const";
|
||||||
|
import {assetPathToIDs} from "@/helper";
|
||||||
|
|
||||||
function toFile(title: string, content: string, mimeType: string){
|
function toFile(title: string, content: string, mimeType: string){
|
||||||
const blob = new Blob([content], { type: mimeType });
|
const blob = new Blob([content], { type: mimeType });
|
||||||
return new File([blob], title, { type: mimeType });
|
return new File([blob], title, { type: mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upload asset to the assets folder, return fileID and syncID
|
||||||
|
export async function uploadAsset(fileID: string, mimeType: string, content: string) {
|
||||||
|
|
||||||
|
const file = toFile(fileID + ".svg", content, mimeType);
|
||||||
|
|
||||||
|
let r = await upload('/' + ASSETS_PATH, [file]);
|
||||||
|
if(r.errFiles) {
|
||||||
|
throw new Error("Failed to upload file");
|
||||||
|
}
|
||||||
|
return assetPathToIDs(r.succMap[file.name]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export function saveFile(path: string, mimeType: string, content: string) {
|
export function saveFile(path: string, mimeType: string, content: string) {
|
||||||
|
|
||||||
const file = toFile(path.split('/').pop(), content, mimeType);
|
const file = toFile(path.split('/').pop(), content, mimeType);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Plugin } from 'siyuan';
|
import { Plugin } from 'siyuan';
|
||||||
import {DATA_PATH, EMBED_PATH} from "@/const";
|
import {ASSETS_PATH} from "@/const";
|
||||||
|
|
||||||
const drawIcon: string = `
|
const drawIcon: string = `
|
||||||
<symbol id="iconDraw" viewBox="0 0 28 28">
|
<symbol id="iconDraw" viewBox="0 0 28 28">
|
||||||
|
@ -23,7 +23,7 @@ export function getMenuHTML(icon: string, text: string): string {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSiyuanId() {
|
export function generateTimeString() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const year = now.getFullYear().toString();
|
const year = now.getFullYear().toString();
|
||||||
|
@ -33,26 +33,45 @@ export function generateSiyuanId() {
|
||||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||||
|
|
||||||
const timestamp = `${year}${month}${day}${hours}${minutes}${seconds}`;
|
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomString() {
|
||||||
|
|
||||||
const characters = 'abcdefghijklmnopqrstuvwxyz';
|
const characters = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
let random = '';
|
let random = '';
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
random += characters.charAt(Math.floor(Math.random() * characters.length));
|
random += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
}
|
}
|
||||||
|
return random;
|
||||||
|
|
||||||
return `${timestamp}-${random}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function idToPath(id: string) {
|
export function IDsToAssetPath(fileID: string, syncID: string) {
|
||||||
return DATA_PATH + id + '.svg';
|
return `${ASSETS_PATH}${fileID}-${syncID}.svg`
|
||||||
|
}
|
||||||
|
export function assetPathToIDs(assetPath: string): { fileID: string; syncID: string } | null {
|
||||||
|
|
||||||
|
const filename = assetPath.split('/').pop() || '';
|
||||||
|
if (!filename.endsWith('.svg')) return null;
|
||||||
|
|
||||||
|
// Split into [basename, extension] and check format
|
||||||
|
const [basename] = filename.split('.');
|
||||||
|
const parts = basename.split('-');
|
||||||
|
|
||||||
|
// Must contain exactly 2 hyphens separating 3 non-empty parts
|
||||||
|
if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileID: parts[0],
|
||||||
|
syncID: parts[1] + '-' + parts[2]
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Edit](siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data={"id":"${id}"})
|
export function getMarkdownBlock(fileID: string, syncID: string): string {
|
||||||
// 
|
|
||||||
export function getPreviewHTML(path: string): string {
|
|
||||||
return `
|
return `
|
||||||
<iframe src="${EMBED_PATH + path}&antiCache=0"></iframe>
|
})
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,20 +93,13 @@ export function findImgSrc(element: HTMLElement): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function imgSrcToPath(imgSrc: string | null): string | null {
|
export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: string } | null {
|
||||||
|
|
||||||
if (!imgSrc) return null;
|
if (!imgSrc) return null;
|
||||||
|
|
||||||
const url = new URL(imgSrc);
|
const url = new URL(imgSrc);
|
||||||
imgSrc = decodeURIComponent(url.pathname);
|
imgSrc = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
if(imgSrc.startsWith('/assets/')) {
|
return assetPathToIDs(imgSrc);
|
||||||
return imgSrc.substring(1);
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to safely escape regex special characters
|
|
||||||
export function escapeRegExp(string: string) {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
22
src/index.ts
22
src/index.ts
|
@ -1,16 +1,15 @@
|
||||||
import {Plugin, Protyle} from 'siyuan';
|
import {Plugin, Protyle} from 'siyuan';
|
||||||
import {
|
import {
|
||||||
getPreviewHTML,
|
getMarkdownBlock,
|
||||||
loadIcons,
|
loadIcons,
|
||||||
getMenuHTML,
|
getMenuHTML,
|
||||||
generateSiyuanId,
|
|
||||||
findImgSrc,
|
findImgSrc,
|
||||||
imgSrcToPath
|
imgSrcToIDs, generateTimeString, generateRandomString
|
||||||
} from "@/helper";
|
} from "@/helper";
|
||||||
import {editorTabInit, openEditorTab} from "@/editorTab";
|
import {editorTabInit, openEditorTab} from "@/editorTab";
|
||||||
import {ASSETS_PATH} from "@/const";
|
|
||||||
|
|
||||||
export default class DrawJSPlugin extends Plugin {
|
export default class DrawJSPlugin extends Plugin {
|
||||||
|
|
||||||
onload() {
|
onload() {
|
||||||
|
|
||||||
loadIcons(this);
|
loadIcons(this);
|
||||||
|
@ -24,22 +23,21 @@ export default class DrawJSPlugin extends Plugin {
|
||||||
filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"],
|
filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"],
|
||||||
html: getMenuHTML("iconDraw", this.i18n.insertDrawing),
|
html: getMenuHTML("iconDraw", this.i18n.insertDrawing),
|
||||||
callback: (protyle: Protyle) => {
|
callback: (protyle: Protyle) => {
|
||||||
const path = ASSETS_PATH + generateSiyuanId() + ".svg";
|
const fileID = generateRandomString();
|
||||||
protyle.insert(getPreviewHTML(path), true, false);
|
const syncID = generateTimeString() + '-' + generateRandomString();
|
||||||
openEditorTab(this, path);
|
protyle.insert(getMarkdownBlock(fileID, syncID), true, false);
|
||||||
|
openEditorTab(this, fileID, syncID);
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
this.eventBus.on("open-menu-image", (e: any) => {
|
this.eventBus.on("open-menu-image", (e: any) => {
|
||||||
const path = imgSrcToPath(findImgSrc(e.detail.element));
|
const ids = imgSrcToIDs(findImgSrc(e.detail.element));
|
||||||
if(path === null) {
|
if(ids === null) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.detail.menu.addItem({
|
e.detail.menu.addItem({
|
||||||
icon: "iconDraw",
|
icon: "iconDraw",
|
||||||
label: "Edit with js-draw",
|
label: "Edit with js-draw",
|
||||||
click: () => {
|
click: () => {
|
||||||
openEditorTab(this, path);
|
openEditorTab(this, ids.fileID, ids.syncID);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {getBlockByID, sql, updateBlock} from "@/api";
|
import {getBlockByID, sql, updateBlock} from "@/api";
|
||||||
import {DUMMY_HOST} from "@/const";
|
import {IDsToAssetPath} from "@/helper";
|
||||||
|
|
||||||
export async function findImageBlocks(src: string) {
|
export async function findImageBlocks(src: string) {
|
||||||
|
|
||||||
|
@ -45,9 +45,9 @@ export async function replaceBlockContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceAntiCacheID(src: string) {
|
export async function replaceSyncID(fileID: string, oldSyncID: string, newSyncID: string) {
|
||||||
|
|
||||||
const search = encodeURI(src); // the API uses URI-encoded
|
const search = encodeURI(IDsToAssetPath(fileID, oldSyncID)); // the API uses URI-encoded
|
||||||
// find blocks containing that image
|
// find blocks containing that image
|
||||||
const blocks = await findImageBlocks(search);
|
const blocks = await findImageBlocks(search);
|
||||||
|
|
||||||
|
@ -61,9 +61,7 @@ export async function replaceAntiCacheID(src: string) {
|
||||||
.filter(source => source.startsWith(search)) // discard other images
|
.filter(source => source.startsWith(search)) // discard other images
|
||||||
|
|
||||||
for(const source of sources) {
|
for(const source of sources) {
|
||||||
const url = new URL(source, DUMMY_HOST);
|
const newSource = IDsToAssetPath(fileID, newSyncID);
|
||||||
url.searchParams.set('antiCache', Date.now().toString()); // set or replace antiCache
|
|
||||||
const newSource = url.href.replace(DUMMY_HOST, '');
|
|
||||||
await replaceBlockContent(block.id, source, newSource);
|
await replaceBlockContent(block.id, source, newSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue