diff --git a/@webwriter/core/model/marshal/html.ts b/@webwriter/core/model/marshal/html.ts index 5516488..a2cf541 100644 --- a/@webwriter/core/model/marshal/html.ts +++ b/@webwriter/core/model/marshal/html.ts @@ -1,10 +1,6 @@ -import { readTextFile, removeFile, writeTextFile } from '@tauri-apps/api/fs' import {Node, DOMSerializer} from "prosemirror-model" -import { join, appDir } from '@tauri-apps/api/path' -import { EditorState } from 'prosemirror-state' import {Schema, DOMParser} from "prosemirror-model" -import { createElementWithAttributes, namedNodeMapToObject, unscopePackageName } from "../../utility" import { EditorStateWithHead, PackageStore, createEditorState, headSchema, headSerializer } from '..' import { Environment } from '../environment' import scopedCustomElementRegistry from "@webcomponents/scoped-custom-element-registry/src/scoped-custom-element-registry.js?raw" diff --git a/@webwriter/core/model/schemas/datatypes.ts b/@webwriter/core/model/schemas/datatypes.ts index b0e3df8..ce5e96a 100644 --- a/@webwriter/core/model/schemas/datatypes.ts +++ b/@webwriter/core/model/schemas/datatypes.ts @@ -112,7 +112,13 @@ export class SemVer extends NodeSemVer { return this.compare(other) === 0 } - toString = () => this.raw; toJSON = () => this.toString() + toString() { + const prerelease = this.prerelease.length? `-${this.prerelease.join(".")}`: "" + const build = this.build.length? `+${this.build.join(".")}`: "" + return `${this.major}.${this.minor}.${this.patch}${prerelease}${build}` + } + + toJSON = () => this.toString() } export class SemVerRange extends NodeSemVerRange { diff --git a/@webwriter/core/model/schemas/resourceschema/head.ts b/@webwriter/core/model/schemas/resourceschema/head.ts index 93332af..4d93188 100644 --- a/@webwriter/core/model/schemas/resourceschema/head.ts +++ b/@webwriter/core/model/schemas/resourceschema/head.ts @@ -239,7 +239,7 @@ export function deleteHeadElement(headState: EditorState, matcher: Matcher) { } }) const resolved = headState.doc.resolve(existingPos!) - const tr = headState.tr.delete(resolved.pos, resolved.pos + existingNode!?.nodeSize ?? 1) + const tr = headState.tr.delete(resolved.pos, resolved.pos + (existingNode!?.nodeSize ?? 1)) return headState.apply(tr) } diff --git a/@webwriter/core/model/schemas/valuedefinition/index.grammar b/@webwriter/core/model/schemas/valuedefinition/index.grammar index 803a8af..c280318 100644 --- a/@webwriter/core/model/schemas/valuedefinition/index.grammar +++ b/@webwriter/core/model/schemas/valuedefinition/index.grammar @@ -48,8 +48,8 @@ Some { ("{" Min { number } "}") | "{" Min { number } "," Max { number } "}" } OneOrMoreCommaSeparated { "#" (ZeroOrMore | OneOrMore | ZeroOrOne | Some)? } @tokens { - or { "|" } - andor { "||" } + // or { "|" } + // andor { "||" } Identifier { $[a-zA-Z0-9_\-]+ } literalString { "," | "." | "(" | ")" | "/" } diff --git a/@webwriter/core/model/stores/index.ts b/@webwriter/core/model/stores/index.ts index 6a4794d..086b7b9 100644 --- a/@webwriter/core/model/stores/index.ts +++ b/@webwriter/core/model/stores/index.ts @@ -68,16 +68,16 @@ export class RootStore { } async persist(schema: ZodSchema>, settingsPath?: string) { - const {appDir, join} = this.Path - const path = settingsPath ?? await join(await appDir(), "settings.json") try { const settings = schema.parse(this) const contents = JSON.stringify(settings, undefined, 2) - if(this.packages.apiBase) { - localStorage.setItem("webwriter_settings", contents) + if(WEBWRITER_ENVIRONMENT.backend === "tauri") { + const {appDir, join} = this.Path + const path = settingsPath ?? await join(await appDir(), "settings.json") + await this.FS.writeFile(path, contents) } else { - await this.FS.writeFile(path, contents) + localStorage.setItem("webwriter_settings", contents) } } catch(cause: any) { diff --git a/@webwriter/core/model/stores/packagestore.ts b/@webwriter/core/model/stores/packagestore.ts index 708fae4..8877e0a 100644 --- a/@webwriter/core/model/stores/packagestore.ts +++ b/@webwriter/core/model/stores/packagestore.ts @@ -22,6 +22,7 @@ type Options = { type PmQueueTask = { command: "install" | "add" | "remove" | "update", parameters: string[], + handle?: FileSystemDirectoryHandle, cwd?: string, name?: string } @@ -71,7 +72,8 @@ export class PackageStore { importMap: ImportMap set installedPackages(value: string[]) { - const valueUnique = Array.from(new Set(value)) + let valueUnique = Array.from(new Set(value)) + valueUnique = valueUnique.filter(a => !valueUnique.some(b => b !== a && b.startsWith("@" + a.split("@")[1]))) localStorage.setItem("webwriter_installedPackages", JSON.stringify(valueUnique)) } @@ -97,6 +99,8 @@ export class PackageStore { apiBase: string + private db = indexedDB.open("webwriter", 1) + private static async readFileIfExists(path: string, FS: Environment["FS"]): Promise { return await FS.exists(path)? await FS.readFile(path) as string: undefined } @@ -321,6 +325,26 @@ export class PackageStore { this.watching = options.watching ?? this.watching })() } + else { + this.db.addEventListener("upgradeneeded", () => { + this.db.result.createObjectStore("handles", {keyPath: "id"}) + }) + } + } + + async putLocalHandle(id: string, handle: FileSystemDirectoryHandle) { + const tx = this.db.result.transaction("handles", "readwrite") + const store = tx.objectStore("handles") + const done = new Promise(r => tx.addEventListener("complete", r)) + store.put({id, handle}) + return done + } + + async getLocalHandle(id: string) { + const tx = this.db.result.transaction("handles", "readwrite") + const store = tx.objectStore("handles") + const req = store.get(id) + return new Promise(r => req.addEventListener("success", () => r(req.result.handle))) } FS: Environment["FS"] @@ -378,8 +402,8 @@ export class PackageStore { this.issues[id] = [...this.getPackageIssues(id), ...issues] } - get importingName() { - return Object.keys(this.adding).find(name => this.adding[name] && !(name in this.packages)) + get importingId() { + return Object.keys(this.adding).find(id => this.adding[id] && !(id in this.packages)) } searchIndex = new MiniSearch({ @@ -393,11 +417,16 @@ export class PackageStore { } async updateImportMap(ids: string[]=this.installedPackages) { - if(!ids.length) { - return - } const url = new URL("_importmaps", this.apiBase) url.searchParams.append("pkg", "true") + /* + const nonlocalIds = ids.filter(id => { + const version = id.split("@")[2] + return !(new SemVer(version)).prerelease.includes("local") + }) + const localIds = ids.filter(id =>!nonlocalIds.includes(id)) + nonlocalIds.forEach(id => url.searchParams.append("id", id)) + */ ids.forEach(id => url.searchParams.append("id", id)) const map = ids.length? await (await fetch(url)).json(): undefined this.importMap = new ImportMap({map}) @@ -405,7 +434,8 @@ export class PackageStore { } pmQueue = cargoQueue(async (tasks: PmQueueTask[]) => { - const toAdd = tasks.filter(t => t.command === "add" && !t.name).flatMap(t => t.parameters) + const toAdd = tasks.filter(t => t.command === "add" && !t.name && !t.handle).flatMap(t => t.parameters) + const toAddLocal = tasks.filter(t => t.command === "add" && t.handle).flatMap(t => ({handle: t.handle!, name: t.name})) const toRemove = tasks.filter(t => t.command === "remove").flatMap(t => t.parameters) const toUpdate = tasks.filter(t => t.command === "update").flatMap(t => t.parameters) const toLink = tasks.filter(t => t.command === "add" && t.name) @@ -430,6 +460,27 @@ export class PackageStore { this.adding = {...this.adding, ...Object.fromEntries(toAdd.map(name => [names[name], false]))} } } + if(toAddLocal.length > 0) { + try { + const pkgs = await Promise.all(toAddLocal.map(async ({handle, name}) => { + const pkgHandle = await handle.getFileHandle("package.json") + const file = await pkgHandle.getFile() + const text = await file.text() + const pkg = new Package({...JSON.parse(text)}) + pkg.version.prerelease = [...pkg.version.prerelease, "local"] + await this.putLocalHandle(pkg.id, handle) + return pkg + })) + this.updateImportMap([...this.installedPackages, ...pkgs.map(pkg => pkg.id)]) + } + catch(err) { + console.error(err) + } + finally { + await this.load() + this.adding = {...this.adding, ...Object.fromEntries(toAddLocal.map(({name}) => [name, false]))} + } + } if(toLink.length > 0) { const pkg = (await this.readRootPackageJson())! pkg.localPaths = { @@ -576,9 +627,17 @@ export class PackageStore { return this.FS.writeFile(await this.rootPackageJsonPath, JSON.stringify(pkg, undefined, 2)) } - async add(url: string, name?: string) { - this.adding = {...this.adding, [name ?? url]: true} - return this.pmQueue.push({command: "add", parameters: [url], cwd: await this.appDir, name}) + async add(urlOrHandle: string | FileSystemDirectoryHandle, name?: string) { + if(typeof urlOrHandle === "string") { + const url = urlOrHandle + this.adding = {...this.adding, [name ?? url]: true} + return this.pmQueue.push({command: "add", parameters: [url], cwd: await this.appDir, name}) + } + else { + const handle = urlOrHandle + this.adding = {...this.adding, [name ?? handle.name]: true} + return this.pmQueue.push({command: "add", parameters: [], handle, cwd: await this.appDir, name}) + } } async remove(name: string) { @@ -589,7 +648,7 @@ export class PackageStore { async update(name?: string) { this.updating = name ? {...this.updating, [name]: true} - : Object.fromEntries(this.packagesList.filter(pkg => pkg.installed).map(pkg => [pkg.name, true])) + : Object.fromEntries(this.packagesList.filter(pkg => pkg.installed).map(pkg => [pkg.id, true])) return this.pmQueue.push({command: "update", parameters: name? [name, "--latest"]: ["--latest"], cwd: await this.appDir}) } @@ -676,22 +735,30 @@ export class PackageStore { } } for(const pkg of installed) { - const aPkg = available.find(a => a.name === pkg.name) + const aPkg = available.find(a => a.id === pkg.id) if(aPkg) { - available = available.filter(a => a.name !== pkg.name) + available = available.filter(a => a.name !== pkg.id) } const latest = aPkg?.version final.push(pkg.extend({latest, installed: true})) } final = final.concat(available) - this.packages = Object.fromEntries(final.map(pkg => [pkg.name, pkg])) + this.packages = Object.fromEntries(final.map(pkg => [pkg.id, pkg])) } else { - final = available.map(pkg => pkg.extend({installed: this.installedPackages.includes(pkg.name)})).sort((a, b) => Number(!!b.installed) - Number(!!a.installed)) + const localIds = this.installedPackages.filter(id => !available.some(pkg => pkg.id === id)) + const local = (await Promise.all(localIds.map(id => fetch(new URL(id + "/package.json", this.apiBase)).then(resp => resp.json())))) + .map(json => { + const version = new SemVer(json.version) + version.prerelease = [...version.prerelease, "local"] + return new Package({...json, installed: true, localPath: "", version}) + }) + console.log(local) + final = available.map(pkg => pkg.extend({installed: this.installedPackages.includes(pkg.id)})).sort((a, b) => Number(!!b.installed) - Number(!!a.installed)) await this.updateImportMap() this.bundleID = PackageStore.computeBundleID(this.installedPackages, false); (this.onBundleChange ?? (() => null))(final.filter(pkg => pkg.installed)) - this.packages = Object.fromEntries(final.map(pkg => [pkg.name, pkg])) + this.packages = Object.fromEntries(final.map(pkg => [pkg.id, pkg])) } this.searchIndex.removeAll() this.searchIndex.addAll(final) @@ -771,7 +838,7 @@ export class PackageStore { // this.appendPackageIssues(id, cause as Error) } const members = await this.readPackageMembers(pkgJson, pkgRootPath) - return new Package(pkgJson, {installed: true, watching: this.watching[pkgJson.name], localPath, members, lastLoaded: Date.now()}) + return new Package(pkgJson, {installed: true, watching: this.watching[pkgJson.id], localPath, members, lastLoaded: Date.now()}) } private async readPackageMembers(pkg: Package, path?: string) { @@ -860,8 +927,8 @@ export class PackageStore { - getPackageMembers(name: string, filter?: "widgets" | "snippets" | "themes") { - const pkg = this.packages[name] + getPackageMembers(id: string, filter?: "widgets" | "snippets" | "themes") { + const pkg = this.packages[id] const members = {} as any for(const [memberName, member] of Object.entries(pkg?.members ?? {})) { const is = { @@ -878,18 +945,18 @@ export class PackageStore { } get widgets() { - return Object.fromEntries(Object.keys(this.packages).map(name => [name, this.getPackageMembers(name, "widgets")])) - return Object.fromEntries(Object.entries(this.members).map(([k, v]) => [k, filterObject(v, vk => vk.startsWith("./widgets/"))])) + return Object.fromEntries(Object.keys(this.packages).map(id => [id, this.getPackageMembers(id, "widgets")])) + // return Object.fromEntries(Object.entries(this.members).map(([k, v]) => [k, filterObject(v, vk => vk.startsWith("./widgets/"))])) } get snippets() { - return Object.fromEntries(Object.keys(this.packages).map(name => [name, this.getPackageMembers(name, "snippets")])) - return Object.fromEntries(Object.entries(this.members).map(([k, v]) => [k, filterObject(v, vk => vk.startsWith("./snippets/"))])) + return Object.fromEntries(Object.keys(this.packages).map(id => [id, this.getPackageMembers(id, "snippets")])) + // return Object.fromEntries(Object.entries(this.members).map(([k, v]) => [k, filterObject(v, vk => vk.startsWith("./snippets/"))])) } get themes() { - return Object.fromEntries(Object.keys(this.packages).map(name => [name, this.getPackageMembers(name, "themes")])) - return Object.fromEntries(Object.entries(this.members).map(([k, v]) => [k, filterObject(v, vk => vk.startsWith("./themes/"))])) + return Object.fromEntries(Object.keys(this.packages).map(id => [id, this.getPackageMembers(id, "themes")])) + // return Object.fromEntries(Object.entries(this.members).map(([k, v]) => [k, filterObject(v, vk => vk.startsWith("./themes/"))])) } get allThemes() { @@ -903,7 +970,7 @@ export class PackageStore { } get installedWidgetUrls() { - return this.apiBase? this.installed.flatMap(pkg => Object.keys(pkg?.widgets ?? {}).flatMap(k => new URL(pkg.name + k.slice(1), this.apiBase).href)): [] + return this.apiBase? this.installed.flatMap(pkg => Object.keys(pkg?.widgets ?? {}).flatMap(k => new URL(pkg.id + k.slice(1), this.apiBase).href)): [] } get widgetTagNames() { @@ -959,16 +1026,30 @@ export class PackageStore { } /** Reads a local package directory, returning the package config. */ - async readLocal(path: string) { - const resolvedPath = await this.Path.resolve(path) - const pkgJsonPath = await this.Path.join(resolvedPath, "package.json") - const exists = await this.FS.exists(pkgJsonPath) - if(!exists) { - throw Error("No package found under " + pkgJsonPath) + async readLocal(pathOrHandle: string | FileSystemDirectoryHandle) { + let pkgString: string + if(typeof pathOrHandle === "string") { + const resolvedPath = await this.Path.resolve(pathOrHandle) + const pkgJsonPath = await this.Path.join(resolvedPath, "package.json") + const exists = await this.FS.exists(pkgJsonPath) + if(!exists) { + throw Error("No package found under " + pkgJsonPath) + } + pkgString = await this.FS.readFile(pkgJsonPath) as string + } + else { + let pkgJsonHandle + try { + pkgJsonHandle = await pathOrHandle.getFileHandle("package.json") + } + catch(err) { + throw Error("No package found under " + pathOrHandle.name) + } + const file = await pkgJsonHandle.getFile() + pkgString = await file.text() } - const pkgString = await this.FS.readFile(pkgJsonPath) as string try { - return new Package(JSON.parse(pkgString)) + return new Package(JSON.parse(pkgString!)) } catch(cause) { throw new PackageJsonIssue(`Error parsing package.json: ${cause}`, {cause}) diff --git a/@webwriter/core/view/editor/editor.ts b/@webwriter/core/view/editor/editor.ts index 5d942ef..6970661 100644 --- a/@webwriter/core/view/editor/editor.ts +++ b/@webwriter/core/view/editor/editor.ts @@ -72,16 +72,14 @@ export class ExplorableEditor extends LitElement { return range(n).map(k => `ww_${(floor + k).toString(36)}`) } - insertMember = async (pkgID: string, insertableName: string) => { + insertMember = async (id: string, insertableName: string) => { const state = this.pmEditor.state - const name = (pkgID.startsWith("@")? "@": "") + pkgID.split("@")[pkgID.startsWith("@")? 1: 0] - console.log(name) - const members = this.app.store.packages.getPackageMembers(name) + const members = this.app.store.packages.getPackageMembers(id) if(insertableName.startsWith("./snippets/")) { const source = members[insertableName].source let htmlStr = source if(!source) { - const url = this.app.store.packages.importMap.resolve("@" + pkgID.split("@").slice(0, 2).join("") + insertableName.slice(1) + ".html") + const url = this.app.store.packages.importMap.resolve("@" + id.split("@").slice(0, 2).join("") + insertableName.slice(1) + ".html") htmlStr = await (await fetch(url, {headers: {"Accept": "text/html"}})).text() } const tagNames = this.app.store.packages.widgetTagNames @@ -143,7 +141,7 @@ export class ExplorableEditor extends LitElement { } else if(insertableName.startsWith("./themes/")) { const old = this.app.store.document.themeName - const toInsert = pkgID + insertableName.slice(1) + const toInsert = id + insertableName.slice(1) const value = old === toInsert? "base": toInsert const allThemes = this.app.store.packages.allThemes as any this.app.store.document.setHead(upsertHeadElement( @@ -1298,7 +1296,9 @@ export class ExplorableEditor extends LitElement { @ww-watch-widget=${async (e: CustomEvent) => { const name = e.detail.name await this.app.store.packages.toggleWatch(name) - this.app.settings.setAndPersist("packages", "watching", this.app.store.packages.watching) + if(WEBWRITER_ENVIRONMENT.backend === "tauri") { + this.app.settings.setAndPersist("packages", "watching", this.app.store.packages.watching) + } }} .packages=${this.packages} tabindex="-1" diff --git a/@webwriter/core/view/editor/packageform.ts b/@webwriter/core/view/editor/packageform.ts index e9530bf..7c6043b 100644 --- a/@webwriter/core/view/editor/packageform.ts +++ b/@webwriter/core/view/editor/packageform.ts @@ -1,4 +1,4 @@ -import { LitElement, html, css } from "lit" +import { LitElement, html, css, PropertyValues } from "lit" import { customElement, property, query } from "lit/decorators.js" import { localized, msg, str } from "@lit/localize" import spdxLicenseList from "spdx-license-list" @@ -35,9 +35,9 @@ export class PackageForm extends LitElement { @property({type: String, attribute: true, reflect: true}) localPath: string = PackageForm.defaults.localPath - @property({type: Boolean, state: true, attribute: true, reflect: true}) + @property({type: Boolean, attribute: true, reflect: true}) get noPath() { - return !this.localPath + return !this.localPath && !this.directoryHandle } @property({attribute: false}) @@ -70,6 +70,9 @@ export class PackageForm extends LitElement { @property({type: Boolean, attribute: true}) loading = false + @property({type: String, attribute: true, converter: {toAttribute: (value: FileSystemDirectoryHandle) => value?.name}}) + directoryHandle?: FileSystemDirectoryHandle + @query("form") form: HTMLFormElement @@ -162,6 +165,7 @@ export class PackageForm extends LitElement { Object.keys(PackageForm.defaults).forEach(key => { (this as any)[key] = (toDefaults? PackageForm.defaults: this.defaultValue as any)[key] }) + this.directoryHandle = undefined this.changed = false this.requestUpdate() } @@ -204,13 +208,15 @@ export class PackageForm extends LitElement { } render() { + console.log(this.directoryHandle) return html`
{ - const {watching, name, version, installed, outdated, localPath, packageEditingSettings} = pkg + const {watching, id, name, version, installed, outdated, localPath, packageEditingSettings} = pkg const {packages} = this.app.store - const adding = !!packages.adding[name] - const removing = !!packages.removing[name] - const updating = !!packages.updating[name] + const adding = !!packages.adding[id] + const removing = !!packages.removing[id] + const updating = !!packages.updating[id] const changing = adding || removing || updating const found = name in this.searchResults const error = packages.getPackageIssues(pkg.id).length - const members = packages.getPackageMembers(pkg.name) + const members = packages.getPackageMembers(pkg.id) const insertables = members? Object.values(filterObject(members, (_, ms) => !(ms as any).uninsertable) as unknown as Record): [] const pkgEditingSettings = !packageEditingSettings? undefined: {name: undefined, label: undefined, ...packageEditingSettings} const {name: firstName, label: firstLabel} = pkgEditingSettings ?? insertables[0] ?? {} @@ -809,10 +809,10 @@ export class Palette extends LitElement {
${pkg.description || msg("No description provided")}
this.dropdownOpen = this.dropdownOpen? null: pkg.id} @mouseenter=${() => this.dropdownOpen = pkg.id}> @@ -947,7 +947,9 @@ export class Palette extends LitElement { submitLocalPackage = async (e: Event) => { const packageForm = e.target as PackageForm - const pkg = new Package(packageForm.value, packageForm.editingState) + const version = new SemVer(packageForm.value.version) + version.prerelease = ["local"] + const pkg = new Package({...packageForm.value, version}, packageForm.editingState) const options = this.packageFormMode === "edit" ? { mergePackage: true @@ -962,17 +964,29 @@ export class Palette extends LitElement { if(packageForm.changed) { await this.app.store.packages.writeLocal(pkg.localPath!, pkg, options) } - await this.app.store.packages.add(`file://${pkg.localPath!}`, pkg.name) + if(WEBWRITER_ENVIRONMENT.backend === "tauri") { + await this.app.store.packages.add(`file://${pkg.localPath!}`, pkg.name) + } + else { + await this.app.store.packages.add(packageForm.directoryHandle!, pkg.id) + } if(packageForm.editingState.watching) { - this.emitWatchWidget(pkg.name) + this.emitWatchWidget(pkg.id) } this.packageForm.reset() } async handlePackageFormPickPath(e: CustomEvent) { - let localPath = await this.app.store.Dialog.promptRead({directory: true}) as string - this.packageForm.localPath = localPath ?? this.packageForm.localPath - this.handlePackageFormChangeField(new CustomEvent("ww-change-field", {detail: {name: "localPath", valid: true}})) + if(WEBWRITER_ENVIRONMENT.backend === "tauri") { + let localPath = await this.app.store.Dialog.promptRead({directory: true}) as string + this.packageForm.localPath = localPath ?? this.packageForm.localPath + this.handlePackageFormChangeField(new CustomEvent("ww-change-field", {detail: {name: "localPath", valid: true}})) + } + else { + this.packageForm.directoryHandle = await (window as any).showDirectoryPicker({mode: "readwrite", startIn: "documents"}) + this.handlePackageFormChangeField(new CustomEvent("ww-change-field", {detail: {name: "localPath", valid: true}})) + this.packageForm.localPath = this.packageForm.directoryHandle!.name + } } async handlePackageFormChangeField(e: CustomEvent) { @@ -980,20 +994,25 @@ export class Palette extends LitElement { this.fillPackageFormWithLocal() } else if(e.detail.name === "localPath" && this.packageFormMode === "create" && e.detail.valid) { - const basename = await this.app.store.Path.basename(this.packageForm.localPath) - const dirname = await this.app.store.Path.basename(await this.app.store.Path.dirname(this.packageForm.localPath)) - const possibleName = `@${dirname.replace("@", "")}/${basename}` - this.packageForm.name = this.packageForm.name || possibleName + if(WEBWRITER_ENVIRONMENT.backend === "tauri") { + const basename = await this.app.store.Path.basename(this.packageForm.localPath) + const dirname = await this.app.store.Path.basename(await this.app.store.Path.dirname(this.packageForm.localPath)) + const possibleName = `@${dirname.replace("@", "")}/${basename}` + this.packageForm.name = this.packageForm.name || possibleName + } + else { + const possibleName = this.packageForm.directoryHandle!.name + this.packageForm.name = this.packageForm.name || possibleName + } } } async fillPackageFormWithLocal() { try { const pkgKeys = ["name", "license", "version", "author", "keywords"] as const - const localPath = this.packageForm.localPath let pkg: Package try { - pkg = await this.app.store.packages.readLocal(localPath) + pkg = await this.app.store.packages.readLocal(WEBWRITER_ENVIRONMENT.backend === "tauri"? this.packageForm.localPath: this.packageForm.directoryHandle!) } catch(err) { console.error(err) @@ -1039,7 +1058,7 @@ export class Palette extends LitElement { ErrorDialog() { const issues = this.errorPkg? this.app.store.packages.getPackageIssues(this.errorPkg.id): [] - return html` this.errorPkg = undefined} label=${msg("Error importing ") + this.errorPkg?.name ?? ""}> + return html` this.errorPkg = undefined} label=${msg("Error importing ") + (this.errorPkg?.name ?? "")}> ${issues.map(issue => html`
${issue.message} @@ -1073,7 +1092,7 @@ export class Palette extends LitElement { ${this.app.commands.groupedContainerCommands.map(this.Card)} ${this.ClipboardCard()} ${this.packagesInSearchOrder.map(this.Card)} - ${!this.app.store.packages.apiBase? this.AddLocalPackageButton(): null} + ${this.AddLocalPackageButton()} ${this.LocalPackageDialog()} ${this.ErrorDialog()} ` diff --git a/@webwriter/core/view/editor/prosemirroreditor.ts b/@webwriter/core/view/editor/prosemirroreditor.ts index 68dbca4..2f32a23 100644 --- a/@webwriter/core/view/editor/prosemirroreditor.ts +++ b/@webwriter/core/view/editor/prosemirroreditor.ts @@ -146,7 +146,6 @@ export class ProsemirrorEditor extends LitElement implements IProsemirrorEditor @property({attribute: false}) dispatchTransaction: IProsemirrorEditor["dispatchTransaction"] - @property({state: true, attribute: false}) private view: EditorView @property({type: String, attribute: false}) @@ -479,7 +478,6 @@ export class ProsemirrorEditor extends LitElement implements IProsemirrorEditor this.window.addEventListener("keydown", e => { const keyExpr = [e.ctrlKey? "ctrl": null, e.altKey? "alt": null, e.shiftKey? "shift": null, e.metaKey? "meta": null, e.key].filter(k => k).join("+") - console.log(keyExpr, this.preventedShortcuts) if(this.preventedShortcuts.includes(keyExpr)) { e.preventDefault() } @@ -490,8 +488,6 @@ export class ProsemirrorEditor extends LitElement implements IProsemirrorEditor sheet.replaceSync(`html { margin-left: var(--scrollbar-width) !important }`) this.document.adoptedStyleSheets = [...this.document.adoptedStyleSheets, sheet] } - - this.loaded = true } iframe: EditorIFrameElement @@ -561,7 +557,7 @@ export class ProsemirrorEditor extends LitElement implements IProsemirrorEditor } @property({attribute: true, type: Boolean, reflect: true}) - loaded: boolean = false + loaded: boolean = true render() { diff --git a/@webwriter/core/view/editor/toolbox.ts b/@webwriter/core/view/editor/toolbox.ts index 6a6a2fa..dea46ba 100644 --- a/@webwriter/core/view/editor/toolbox.ts +++ b/@webwriter/core/view/editor/toolbox.ts @@ -159,7 +159,7 @@ export class Toolbox extends LitElement { ) get isActiveElementContainer() { - return !this.activeElement?.classList?.contains("ww-widget") ?? false + return !this.activeElement?.classList?.contains("ww-widget") } get isActiveElementWidget() { diff --git a/@webwriter/core/view/elements/datainputs/pathinput.ts b/@webwriter/core/view/elements/datainputs/pathinput.ts index 253f8a0..1da49df 100644 --- a/@webwriter/core/view/elements/datainputs/pathinput.ts +++ b/@webwriter/core/view/elements/datainputs/pathinput.ts @@ -1,4 +1,4 @@ -import { html, css, render } from "lit"; +import { html, css, render, PropertyValues } from "lit"; import { DataInput } from "."; import { customElement, property } from "lit/decorators.js"; import { SlInput } from "@shoelace-style/shoelace"; @@ -25,11 +25,14 @@ type PickPathHandler = (options?: PickPathOptions) => Promise { @@ -62,6 +72,12 @@ export class PathInput extends SlInput implements DataInput { container && container.children.length === 0 && render(this.Suffix(), container as HTMLElement) } + protected updated(_changedProperties: PropertyValues): void { + if(_changedProperties.has("inputDisabled")) { + this.input.disabled = this.inputDisabled + } + } + Suffix() { return html`` } diff --git a/@webwriter/core/viewmodel/services/bundleservice.ts b/@webwriter/core/viewmodel/services/bundleservice.ts index e3f28ef..913b5eb 100644 --- a/@webwriter/core/viewmodel/services/bundleservice.ts +++ b/@webwriter/core/viewmodel/services/bundleservice.ts @@ -3,11 +3,12 @@ const worker = self as unknown as ServiceWorkerGlobalScope import * as esbuild from "esbuild-wasm" import PathBrowserifyEsm from "path-browserify-esm" // @ts-ignore import wasmURL from "esbuild-wasm/esbuild.wasm?url" -import {Generator} from "@jspm/generator" +import {Generator, Provider} from "@jspm/generator" import {ImportMap, IImportMap} from "@jspm/import-map" import {BuildOptions} from "esbuild-wasm" import { PackageConfig } from "@jspm/generator/lib/install/package" import {parseUrlPkg, pkgToUrl} from "@jspm/generator/lib/providers/jsdelivr" +import { SemVer } from "semver" const commaSeparatedArrays = [ 'conditions', @@ -387,9 +388,66 @@ const extensions: any = { const compiler = new Compiler({wasmURL}) const CDN_URL = "https://cdn.jsdelivr.net/npm/" const API_URL = "https://api.webwriter.app/ww/v1/" +const exactPkgRegEx = /^((?:@[^\/\\%@]+\/)?[^.\/\\%@][^\/\\%@]*)@([^\/]+)(\/.*)?$/ + +const filesystem: Provider = { + async pkgToUrl(pkg, layer) { + return `${API_URL}${pkg.name}@${pkg.version}/` + }, + parseUrlPkg(url) { + if(url.startsWith(API_URL)) { + const path = url.slice(API_URL.length) + const [_, name, version] = path.match(exactPkgRegEx) || [] + return {registry: "npm", name, version} + } + else { + return null + } + }, + async resolveLatestTarget({registry, name}) { + return {registry, name, version: "0.0.0-local"} + } +} + +async function getLocalHandle(id: string): Promise { + const db = indexedDB.open("webwriter", 1) + await new Promise(r => db.addEventListener("success", r)) + const tx = db.result.transaction("handles", "readwrite") + const store = tx.objectStore("handles") + const req = store.get(id) + return new Promise(r => req.addEventListener("success", () => { + db.result.close() + r(req.result.handle) + })) +} + async function getAsset(id: string) { - return fetch(new URL(id, CDN_URL)) + const scoped = id.startsWith("@") + const parts = id.split("/") + const version = parts[scoped? 1: 0].split("@").at(-1)! + const semver = new SemVer(version) + if(semver.prerelease.includes("local")) { + const pkgId = parts.slice(0, scoped? 2: 1).join("/") + const pathParts = parts.slice(scoped? 2: 1) + const handle = await getLocalHandle(pkgId) + let directory = handle + let file: File + for(const [i, part] of pathParts.entries()) { + if(i === pathParts.length - 1) { + const fileHandle = await directory.getFileHandle(part) + file = await fileHandle.getFile() + } + else { + directory = await directory.getDirectoryHandle(part) + } + } + console.log(file!) + return new Response(file!) + } + else { + return fetch(new URL(id, CDN_URL)) + } } async function getPackages(ids: string[]) { @@ -402,12 +460,22 @@ async function getPackages(ids: string[]) { } _ids = resp.objects.map(obj => obj.package).map(({name, version}) => `${name}@${version}/package.json`) } - const pkgs = (await Promise.allSettled(_ids.map(id => getAsset(id).then(resp => resp.json())))).filter(result => result.status === "fulfilled").map(result => result.value) + const pkgs = (await Promise.allSettled(_ids.map(async id => { + const resp = await getAsset(id) + const json = await resp.json() + const version = new SemVer(json.version) + version.prerelease = [...version.prerelease, "local"] + return {...json, version: String(version)} + }))) + .filter(result => result.status === "fulfilled") + .map(result => result.value) + return new Response(new Blob([JSON.stringify(pkgs)], {type: "application/json"})) } async function getImportmap(ids: string[] | Record[]) { let _ids = [] as string[]; let assets = {} as Record + let localIds const forPackage = typeof ids[0] === "object" if(!forPackage) { _ids = [...ids] as string[] @@ -417,20 +485,39 @@ async function getImportmap(ids: string[] | Record[]) { // const pkgs = await Promise.all(pkgIds.map(id => getAsset(`${id}/package.json`).then(resp => resp.json()))) as any[] const pkgs = ids as Record[] assets = Object.fromEntries(pkgs.flatMap(pkg => { + const version = new SemVer(pkg.version) + const isLocal = version.prerelease.includes("local") + const pkgId = `${pkg.name}@${pkg.version}` return Object.keys(pkg.exports) .filter(k => k.startsWith("./") && (k.endsWith(".html") || k.endsWith(".css") || k.endsWith(".*"))) .map(k => [ - pkg.name + (k.endsWith(".*")? k.slice(1, -2) + ".css": k.slice(1)), - new URL(pkg.name + (pkg.exports[k]?.default ?? pkg.exports[k]).slice(1), CDN_URL).href.replace(".*", ".css") + pkgId + (k.endsWith(".*")? k.slice(1, -2) + ".css": k.slice(1)), + new URL(pkgId + (pkg.exports[k]?.default ?? pkg.exports[k]).slice(1), isLocal? API_URL: CDN_URL).href.replace(".*", ".css") ]) })) - _ids = pkgs.flatMap(pkg => Object.keys(pkg.exports).filter(k => k.startsWith("./")).map(k => pkg.name + k.slice(1))).filter(id => id.endsWith(".*")).map(id => id.slice(0, -2) + ".js") + _ids = pkgs + .filter(pkg => !(new SemVer(pkg.version).prerelease.includes("local"))) + .flatMap(pkg => Object.keys(pkg.exports) + .filter(k => k.startsWith("./")) + .map(k => `${pkg.name}@${pkg.version}` + k.slice(1)) + ) + .filter(id => id.endsWith(".*")) + .map(id => id.slice(0, -2) + ".js") + localIds = pkgs + .filter(pkg => (new SemVer(pkg.version).prerelease.includes("local"))) + .flatMap(pkg => Object.keys(pkg.exports) + .filter(k => k.startsWith("./")) + .map(k => `${pkg.name}@${pkg.version}` + k.slice(1)) + ) + .filter(id => id.endsWith(".*")) + .map(id => id.slice(0, -2) + ".js") } const generator = new Generator({cache: false, defaultProvider: "jsdelivr"}) let allLinked = false do { try { - await generator.link(_ids) + console.log(_ids) + await generator.install(_ids) Object.entries(assets).forEach(([id, url]) => { generator.map.set(id, url) }) @@ -448,7 +535,33 @@ async function getImportmap(ids: string[] | Record[]) { } } } while(_ids.length && !allLinked) - return new Response(new Blob([JSON.stringify(generator.map.toJSON())], {type: "application/json"})) + let map = generator.map + if(localIds) { + const localGenerator = new Generator({cache: false, inputMap: map, customProviders: {filesystem}, defaultProvider: "filesystem"}) + let allLinkedLocal = false + do { + try { + await localGenerator.link(localIds) + Object.entries(assets).forEach(([id, url]) => { + localGenerator.map.set(id, url) + }) + allLinked = true + } + catch(err: any) { + const regexMatch = / imported from /g.exec(err.message) + if(err.code === "MODULE_NOT_FOUND" && regexMatch) { + console.warn(`Excluding faulty package ${regexMatch[1]}: ${err.message}`) + localIds = localIds.filter(id => id !== regexMatch[1]) + } + else { + console.error(err) + return new Response(null, {status: 500}) + } + } + } while(localIds.length && !allLinked) + map = localGenerator.map + } + return new Response(new Blob([JSON.stringify(map.toJSON())], {type: "application/json"})) } async function getBundle(ids: string[], importMap: ImportMap, options?: esbuild.BuildOptions) { @@ -542,7 +655,15 @@ async function respond(action: Action) { else { pkgs = await pkgsResponse.json() } - const versionedIds = action.ids.map((id, i) => id.replace(pkgs[i].name!, pkgs[i].name! + "@" + pkgs[i].version!)) + const versionedIds = action.ids.map((id, i) => { + const bare = !(id.startsWith("@")? id.slice(1).split("/")[1]: id.split("/")[0]).includes("@") + if(!bare) { + return id + } + else { + return id.replace(pkgs[i].name!, pkgs[i].name! + "@" + pkgs[i].version!) + } + }) const url = actionToUrl({...action, ids: versionedIds}) const cachedResponse = await caches.match(url) if(cachedResponse) {