diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 9e94c86..8fc766a 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -49,6 +49,7 @@ jobs: - name: Build container images run: | + echo 'SESSION_SECRET=${{ secrets.SESSION_PASSWORD }}' > .env docker build . --file Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/$IMAGE_NAME:${{needs.versionize.outputs.semver}} docker build . --file Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/$IMAGE_NAME:latest diff --git a/Dockerfile b/Dockerfile index dbc4245..1395753 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production FROM base AS prerelease COPY --from=install /temp/dev/node_modules node_modules COPY . . -RUN echo "SESSION_SECRET=$(head -c 64 /dev/random | base64)" > .env +# RUN echo "SESSION_SECRET=$(head -c 64 /dev/random | base64)" > .env ENV NODE_ENV=production ENV SERVER_PRESET=bun diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 5cde80e..81a2507 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,4 +1,5 @@ -import { createEffect, createSignal, on, onMount } from 'solid-js'; +import { Component, createEffect, createMemo, createSignal, For, on, onMount, untrack } from 'solid-js'; +import { debounce } from '@solid-primitives/scheduled'; import { createSelection, getTextNodes } from '@solid-primitives/selection'; import { createSource } from '~/features/source'; import css from './textarea.module.css'; @@ -18,8 +19,25 @@ interface TextareaProps { export function Textarea(props: TextareaProps) { const [selection, setSelection] = createSelection(); const [editorRef, setEditorRef] = createSignal(); + let mounted = false; - const source = createSource(() => props.value); + const source = createSource(props.value); + + createEffect(on(() => [props.oninput, source.in] as const, ([oninput, text]) => { + if (!mounted) { + return; + } + + oninput?.(text); + })); + + onMount((() => { + mounted = true; + })); + + createEffect(() => { + source.in = props.value; + }); const mutate = debounce(() => { const [, start, end] = selection(); @@ -59,18 +77,116 @@ export function Textarea(props: TextareaProps) { createHighlights(ref, 'search-results', errors); })); - return
e.stopPropagation()} - on:pointerdown={e => e.stopPropagation()} - />; + return <> + + source.query = e.target.value} /> +
e.stopPropagation()} + on:pointerdown={e => e.stopPropagation()} + /> + ; +} + +const Suggestions: Component = () => { + const [selection] = createSelection(); + const [suggestionRef, setSuggestionRef] = createSignal(); + const [suggestions, setSuggestions] = createSignal([]); + + const marker = createMemo(() => { + if (isServer) { + return; + } + + const [n] = selection(); + const s = window.getSelection(); + + if (n === null || s === null || s.rangeCount < 1) { + return; + } + + return (findMarkerNode(s.getRangeAt(0)?.commonAncestorContainer) ?? undefined) as HTMLElement | undefined; + }); + + createEffect((prev) => { + if (prev) { + prev.style.setProperty('anchor-name', null); + } + + const m = marker(); + const ref = untrack(() => suggestionRef()!); + + if (m === undefined) { + if (ref.matches(':popover-open')) { + ref.hidePopover(); + } + + return; + } + + m.style.setProperty('anchor-name', '--suggestions'); + + if (ref.matches(':not(:popover-open)')) { + ref.showPopover(); + } + + ref.focus() + + return m; + }); + + createEffect(() => { + marker(); + + setSuggestions(Array(Math.ceil(Math.random() * 5)).fill('').map((_, i) => `suggestion ${i}`)); + }); + + const onPointerDown = (e: PointerEvent) => { + marker()?.replaceWith(document.createTextNode(e.target.textContent)); + }; + + const onKeyDown = (e: KeyboardEvent) => { + console.log(e); + } + + return + { + suggestion =>
  • {suggestion}
  • + }
    +
    ; +}; + +const findMarkerNode = (node: Node | null) => { + while (node !== null) { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).hasAttribute('data-marker')) { + break; + } + + node = node.parentNode; + } + + return node; +}; + +const spellChecker = checker(/\w+/gi); +const grammarChecker = checker(/\w+\s+\w+/gi); + +function checker(regex: RegExp) { + return (subject: string, lang: string): [number, number][] => { + // return []; + + const threshold = .75//.99; + + return Array.from(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => { + return [index, index + match.length] as const; + }); + } } const createHighlights = (node: Node, type: string, ranges: [number, number][]) => { diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index 42f0c4a..c825e53 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -28,7 +28,7 @@ const groupBy = (rows: DataSetRowNode[]) => { : ({ kind: 'group', key, groupedBy: 'key', nodes: group(nodes.map(n => ({ ...n, _key: n._key.slice(key.length + 1) }))) }) ); - return group(rows.map(r => ({ ...r, _key: r.value.key }))) as any; + return group(rows.filter(r => r.value.key).map(r => ({ ...r, _key: r.value.key }))) as any; } export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any, children?: (key: string) => JSX.Element }) { @@ -64,19 +64,18 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[], const [api, setApi] = createSignal>(); // Normalize dataset in order to make sure all the files have the correct structure - createEffect(() => { - // For tracking - props.rows; - // const value = untrack(() => rows.value); + // createEffect(() => { + // // For tracking + // props.rows; - rows.mutateEach(({ key, ...locales }) => ({ key, ...Object.fromEntries(Object.entries(locales).map(([locale, value]) => [locale, value ?? ''])) })) - }); + // rows.mutateEach(({ key, ...locales }) => ({ key, ...Object.fromEntries(Object.entries(locales).map(([locale, value]) => [locale, value ?? ''])) })) + // }); - createEffect(() => { - const l = addedLocales(); + // createEffect(() => { + // const l = addedLocales(); - rows.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) })); - }); + // rows.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) })); + // }); createEffect(() => { props.api?.({ diff --git a/src/features/file/helpers.ts b/src/features/file/helpers.ts index 544bbd2..a4b68bd 100644 --- a/src/features/file/helpers.ts +++ b/src/features/file/helpers.ts @@ -1,6 +1,11 @@ import { Accessor, createEffect, from, createSignal } from "solid-js"; import { json } from "./parser"; import { filter } from "~/utilities"; +import { isServer } from "solid-js/web"; +import { installIntoGlobal } from 'iterator-helpers-polyfill'; +import { debounce } from "@solid-primitives/scheduled"; + +installIntoGlobal(); interface Files extends Record { } interface Contents extends Map> { } @@ -13,8 +18,16 @@ export const read = (file: File): Promise | undefined> => { } }; -export const readFiles = (directory: Accessor): Accessor => { - return createPolled(directory, async (directory, prev) => { +export const readFiles = (directory: Accessor): Accessor => { + return (!isServer && 'FileSystemObserver' in window) ? readFiles__observer(directory) : readFiles__polled(directory) +}; + +const readFiles__polled = (directory: Accessor): Accessor => { + return createPolled(directory, async (directory, prev) => { + if (!directory) { + return prev; + } + const next: Files = Object.fromEntries(await Array.fromAsync( filter(directory.values(), (handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')), async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] @@ -39,9 +52,130 @@ export const readFiles = (directory: Accessor): Acces }, { interval: 1000, initialValue: {} }); }; +const readFiles__observer = (directory: Accessor): Accessor => { + const [files, setFiles] = createSignal({}); + + const observer = new FileSystemObserver(debounce(async records => { + for (const record of records) { + switch (record.type) { + case 'modified': { + if (record.changedHandle.kind === 'file') { + const handle = record.changedHandle as FileSystemFileHandle; + const id = await handle.getUniqueId(); + const file = await handle.getFile(); + + setFiles(prev => ({ ...prev, [id]: { file, handle } })); + } + + break; + } + + default: { + console.log(record); + + break; + } + } + } + }, 10)); + + createEffect((last = undefined) => { + if (last) { + observer.unobserve(last); + } + + const dir = directory(); + + if (!dir) { + return; + } + + observer.observe(dir); + + (async () => { + setFiles(Object.fromEntries( + await dir.values() + .filter((handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')) + .map(async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] as const) + .toArray() + )); + })(); + + return dir; + }); + + return files; +}; + +const HANDLE = Symbol('handle'); const LAST_MODIFIED = Symbol('lastModified'); -export const contentsOf = (directory: Accessor): Accessor => { - return createPolled(directory, async (directory, prev) => { +export const contentsOf = (directory: Accessor): Accessor => { + return (!isServer && 'FileSystemObserver' in window) ? contentsOf__observer(directory) : contentsOf__polled(directory) +}; + +const contentsOf__observer = (directory: Accessor): Accessor => { + const [contents, setContents] = createSignal(new Map); + + const observer = new FileSystemObserver(debounce(async records => { + for (const record of records) { + switch (record.type) { + case 'modified': { + if (record.changedHandle.kind === 'file') { + const handle = record.changedHandle as FileSystemFileHandle; + const id = await handle.getUniqueId(); + const file = await handle.getFile(); + const entries = (await read(file))!; + entries[LAST_MODIFIED] = file.lastModified; + + setContents(prev => new Map([...prev, [id, entries]])); + } + + break; + } + + default: { + console.log(record); + + break; + } + } + } + }, 10)); + + createEffect((last = undefined) => { + if (last) { + observer.unobserve(last); + } + + const dir = directory(); + + if (!dir) { + return; + } + + observer.observe(dir); + + (async () => { + setContents(new Map(await walk(dir).map(async ({ id, file }) => { + const entries = (await read(file))!; + entries[LAST_MODIFIED] = file.lastModified; + + return [id, entries] as const; + }).toArray())); + })(); + + return dir; + }); + + return contents; +}; + +const contentsOf__polled = (directory: Accessor): Accessor => { + return createPolled(directory, async (directory, prev) => { + if (!directory) { + return prev; + } + const files = await Array.fromAsync(walk(directory)); const next = async () => new Map(await Promise.all(files.map(async ({ id, file }) => { @@ -116,6 +250,41 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): declare global { interface Map { + [HANDLE]: FileSystemFileHandle; [LAST_MODIFIED]: number; } + + type FileSystemObserverCallback = ( + records: FileSystemChangeRecord[], + observer: FileSystemObserver + ) => void; + + interface FileSystemObserverObserveOptions { + recursive?: boolean; + } + + type FileSystemChangeType = 'appeared' | 'disappeared' | 'modified' | 'moved' | 'unknown' | 'errored'; + + interface FileSystemChangeRecord { + readonly changedHandle: FileSystemHandle; + readonly relativePathComponents: ReadonlyArray; + readonly type: FileSystemChangeType; + readonly relativePathMovedFrom?: ReadonlyArray; + } + + interface FileSystemObserver { + observe( + handle: FileSystemHandle, + options?: FileSystemObserverObserveOptions + ): Promise; + unobserve(handle: FileSystemHandle): void; + disconnect(): void; + } + + interface FileSystemObserverConstructor { + new(callback: FileSystemObserverCallback): FileSystemObserver; + readonly prototype: FileSystemObserver; + } + + var FileSystemObserver: FileSystemObserverConstructor; } \ No newline at end of file diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index 95604ae..0eed71e 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -13,9 +13,8 @@ import { useI18n } from "~/features/i18n"; import { makePersisted } from "@solid-primitives/storage"; import { writeClipboard } from "@solid-primitives/clipboard"; import { destructure } from "@solid-primitives/destructure"; -import css from "./edit.module.css"; import { contentsOf } from "~/features/file/helpers"; -import { createHtmlParser, createMarkdownParser, createSource } from "~/features/source"; +import css from "./edit.module.css"; const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; @@ -346,6 +345,7 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter(); const files = readFiles(() => props.directory); + // const __contents = contentsOf(() => props.directory); const [contents] = createResource(files, (files) => Promise.all(Object.entries(files).map(async ([id, { file, handle }]) => ({ id, handle, lang: file.name.split('.').at(0)!, entries: (await read(file))! }))), { initialValue: [] }); const [entries, rows] = destructure(() => { diff --git a/src/routes/(editor)/experimental.tsx b/src/routes/(editor)/experimental.tsx index 171a55a..76f374a 100644 --- a/src/routes/(editor)/experimental.tsx +++ b/src/routes/(editor)/experimental.tsx @@ -19,6 +19,7 @@ export default function Experimental(props: ParentProps) { + }> diff --git a/src/routes/(editor)/experimental/file-system-observer.tsx b/src/routes/(editor)/experimental/file-system-observer.tsx new file mode 100644 index 0000000..44f4eb9 --- /dev/null +++ b/src/routes/(editor)/experimental/file-system-observer.tsx @@ -0,0 +1,32 @@ +import { createEffect, createSignal, on } from "solid-js"; +import { readFiles } from "~/features/file"; +import { contentsOf } from "~/features/file/helpers"; + +export default function FileObserver(props: {}) { + const [dir, setDir] = createSignal(); + + const files = readFiles(dir); + const contents = contentsOf(dir); + + const open = async () => { + const handle = await window.showDirectoryPicker(); + + setDir(handle) + }; + + createEffect(() => { + console.log('dir', dir()); + }); + + createEffect(() => { + console.log('files', files()); + }); + + createEffect(() => { + console.log('contents', contents()); + }); + + return
    + +
    ; +} \ No newline at end of file