288 lines
No EOL
12 KiB
TypeScript
288 lines
No EOL
12 KiB
TypeScript
import { children, createEffect, createMemo, createResource, createSignal, onMount, ParentProps } from "solid-js";
|
|
import { MutarionKind, splitAt } from "~/utilities";
|
|
import { Sidebar } from "~/components/sidebar";
|
|
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree, FileEntry, Entry } from "~/components/filetree";
|
|
import { Menu } from "~/features/menu";
|
|
import { Grid, load, useFiles } from "~/features/file";
|
|
import { Command, Context, createCommand, Modifier, noop } from "~/features/command";
|
|
import { GridApi } from "~/features/file/grid";
|
|
import css from "./edit.module.css";
|
|
import { match } from "ts-pattern";
|
|
|
|
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ id: string, handle: FileSystemFileHandle, path: string[], lang: string, entries: Map<string, string> }, void, never> {
|
|
for await (const handle of directory.values()) {
|
|
if (handle.kind === 'directory') {
|
|
yield* walk(handle, [...path, handle.name]);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!handle.name.endsWith('.json')) {
|
|
continue;
|
|
}
|
|
|
|
const id = await handle.getUniqueId();
|
|
const file = await handle.getFile();
|
|
const lang = file.name.split('.').at(0)!;
|
|
const entries = await load(file);
|
|
|
|
if (entries !== undefined) {
|
|
yield { id, handle, path, lang, entries };
|
|
}
|
|
}
|
|
};
|
|
|
|
function* breadthFirstTraverse(subject: FolderEntry): Generator<{ path: string[] } & Entry, void, unknown> {
|
|
const queue: ({ path: string[] } & Entry)[] = subject.entries.map(e => ({ path: [], ...e }));
|
|
|
|
while (queue.length > 0) {
|
|
const entry = queue.shift()!;
|
|
|
|
yield entry;
|
|
|
|
if (entry.kind === 'folder') {
|
|
queue.push(...entry.entries.map(e => ({ path: [...entry.path, entry.name], ...e })));
|
|
}
|
|
}
|
|
}
|
|
|
|
const findFile = (folder: FolderEntry, id: string) => {
|
|
return breadthFirstTraverse(folder).find((entry): entry is { path: string[] } & FileEntry => entry.kind === 'file' && entry.id === id);
|
|
}
|
|
|
|
export default function Edit(props: ParentProps) {
|
|
const filesContext = useFiles();
|
|
const [root, { mutate, refetch }] = createResource(() => filesContext?.get('root'));
|
|
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
|
|
const [columns, setColumns] = createSignal<string[]>([]);
|
|
const [rows, setRows] = createSignal<Map<string, Record<string, string>>>(new Map);
|
|
const [entries, setEntries] = createSignal<Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>>(new Map);
|
|
const [api, setApi] = createSignal<GridApi>();
|
|
|
|
const mutatedFiles = createMemo(() => {
|
|
const mutations = api()?.mutations() ?? [];
|
|
const files = entries();
|
|
|
|
return new Set(mutations
|
|
.map(mutation => {
|
|
const [key, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
|
|
|
|
return files.get(key)?.[lang]?.id;
|
|
})
|
|
.filter(file => file !== undefined)
|
|
);
|
|
});
|
|
|
|
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
|
|
onMount(() => {
|
|
refetch();
|
|
});
|
|
|
|
createEffect(async () => {
|
|
const directory = root();
|
|
|
|
if (root.state === 'ready' && directory?.kind === 'directory') {
|
|
const contents = await Array.fromAsync(walk(directory));
|
|
const languages = new Set(contents.map(c => c.lang));
|
|
const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]);
|
|
|
|
const merged = contents.reduce((aggregate, { id, handle, path, lang, entries }) => {
|
|
for (const [key, value] of entries.entries()) {
|
|
const k = [...path, key].join('.');
|
|
|
|
if (!aggregate.has(k)) {
|
|
aggregate.set(k, Object.fromEntries(template));
|
|
}
|
|
|
|
aggregate.get(k)![lang] = { value, handle, id };
|
|
}
|
|
|
|
return aggregate;
|
|
}, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
|
|
|
|
setFiles({ name: '', id: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) });
|
|
setColumns(['key', ...languages]);
|
|
setEntries(merged);
|
|
setRows(new Map(merged.entries().map(([key, langs]) => [key, Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value]))] as const)));
|
|
}
|
|
});
|
|
|
|
const commands = {
|
|
open: createCommand('open', async () => {
|
|
const [fileHandle] = await window.showOpenFilePicker({
|
|
types: [
|
|
{
|
|
description: "JSON File(s)",
|
|
accept: {
|
|
"application/json": [".json", ".jsonp", ".jsonc"],
|
|
},
|
|
}
|
|
],
|
|
excludeAcceptAllOption: true,
|
|
multiple: true,
|
|
});
|
|
const file = await fileHandle.getFile();
|
|
const text = await file.text();
|
|
|
|
console.log(fileHandle, file, text);
|
|
}, { key: 'o', modifier: Modifier.Control }),
|
|
openFolder: createCommand('open folder', async () => {
|
|
const directory = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
|
|
filesContext.set('root', directory);
|
|
mutate(directory);
|
|
}),
|
|
save: createCommand('save', () => {
|
|
const mutations = api()?.mutations() ?? [];
|
|
|
|
if (mutations.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const rows = api()?.rows() ?? {};
|
|
const _entries = entries();
|
|
|
|
// Cases we can encounter:
|
|
// | | file extsis | no existing file |
|
|
// |---------|---------------------------|----------------------!
|
|
// | created | insert new key into file | create new file |
|
|
// | updated | update value | create new file (*1) |
|
|
// | deleted | remove key from file (*3) | no-op/skip (*2)(*3) |
|
|
//
|
|
// 1) This can happen if the key already exists in another language (so when adding a new language for example).
|
|
// 2) The same as with 1, when you delete a key, and there are not files for each language, then this is a valid case.
|
|
// 3) When a file has 0 keys, we can remove it.
|
|
|
|
for (const mutation of mutations) {
|
|
const [key, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
|
|
const entry = _entries.get(key);
|
|
const localEntry = entry?.[lang];
|
|
|
|
// TODO :: try to resolve to a file
|
|
//
|
|
// | | entry | localEntry | id | file |
|
|
// |---|-------!------------|----!------!
|
|
// | 1 | x | x | x | x |
|
|
// | 2 | x | x | x | |
|
|
// | 3 | x | x | | |
|
|
// | 4 | x | | | |
|
|
// | 5 | | | | |
|
|
//
|
|
// 1. happy path
|
|
// 2. weird edge case
|
|
// 3. this language never had a file before. peek at at another language and create a new file with a mathing path
|
|
// 4. error?
|
|
// 5. error?
|
|
|
|
if (!localEntry) {
|
|
throw new Error('invalid edge case???');
|
|
}
|
|
|
|
if (localEntry.id === undefined) {
|
|
const [, alternativeLocalEntry] = Object.entries(entry).find(([l, e]) => l !== lang && e.id !== undefined) ?? [];
|
|
|
|
if (alternativeLocalEntry === undefined) {
|
|
// unable to find alternative. show a picker instead?
|
|
return;
|
|
}
|
|
|
|
const file = findFile(tree(), alternativeLocalEntry.id);
|
|
|
|
console.log('alt', file);
|
|
}
|
|
|
|
const file = findFile(tree(), localEntry.id);
|
|
const fileExists = file !== undefined;
|
|
|
|
console.log(key, file?.path.join('.'));
|
|
|
|
const fileLocalKey = key.slice(file?.path.join('.'));
|
|
|
|
const result = match([fileExists, mutation.kind])
|
|
.with([true, MutarionKind.Create], () => ({ action: MutarionKind.Create, key, value: rows[key][lang], file: file?.meta }))
|
|
.with([false, MutarionKind.Create], () => '2')
|
|
.with([true, MutarionKind.Update], () => ({ action: MutarionKind.Update, key, value: rows[key][lang], file: file?.meta }))
|
|
.with([false, MutarionKind.Update], () => '4')
|
|
.with([true, MutarionKind.Delete], () => ({ action: MutarionKind.Delete, key, file: file?.meta }))
|
|
.with([false, MutarionKind.Delete], () => '6')
|
|
.exhaustive();
|
|
|
|
console.log(mutation, key, lang, entry, file, result);
|
|
}
|
|
|
|
// for (const fileId of files) {
|
|
// const { path, meta } = findFile(tree(), fileId) ?? {};
|
|
|
|
// console.log(fileId, path, meta, entries());
|
|
|
|
// // TODO
|
|
// // - find file handle
|
|
// // - prepare data
|
|
// // -- clone entries map (so that order is preserved)
|
|
// // -- apply mutations
|
|
// // -- convert key to file local (ergo, remove the directory path prefix)
|
|
// // - write data to file
|
|
// }
|
|
}, { key: 's', modifier: Modifier.Control }),
|
|
saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => {
|
|
console.log('save as ...', handle);
|
|
|
|
window.showSaveFilePicker({
|
|
startIn: root(),
|
|
excludeAcceptAllOption: true,
|
|
types: [
|
|
{ accept: { 'application/json': ['.json'] }, description: 'JSON' },
|
|
{ accept: { 'application/yaml': ['.yml', '.yaml'] }, description: 'YAML' },
|
|
{ accept: { 'application/csv': ['.csv'] }, description: 'CSV' },
|
|
]
|
|
});
|
|
|
|
}, { key: 's', modifier: Modifier.Control | Modifier.Shift }),
|
|
selectAll: createCommand('select all', () => {
|
|
api()?.selectAll();
|
|
}, { key: 'a', modifier: Modifier.Control }),
|
|
clearSelection: createCommand('clear selection', () => {
|
|
api()?.clear();
|
|
}),
|
|
} as const;
|
|
|
|
return <div class={css.root}>
|
|
<Context.Root commands={[commands.saveAs]}>
|
|
<Context.Menu>{
|
|
command => <Command command={command} />
|
|
}</Context.Menu>
|
|
|
|
<Menu.Root>
|
|
<Menu.Item label="file">
|
|
<Menu.Item command={commands.open} />
|
|
|
|
<Menu.Item command={commands.openFolder} />
|
|
|
|
<Menu.Item command={commands.save} />
|
|
</Menu.Item>
|
|
|
|
<Menu.Item command={noop.withLabel('edit')} />
|
|
|
|
<Menu.Item label="selection">
|
|
<Menu.Item command={commands.selectAll} />
|
|
|
|
<Menu.Item command={commands.clearSelection} />
|
|
</Menu.Item>
|
|
|
|
<Menu.Item command={noop.withLabel('view')} />
|
|
</Menu.Root>
|
|
|
|
<Sidebar as="aside" class={css.sidebar}>
|
|
<Tree entries={tree().entries}>{
|
|
file => {
|
|
const mutated = createMemo(() => mutatedFiles().has(file().id));
|
|
|
|
return <Context.Handle><span classList={{ [css.mutated]: mutated() }}>{file().name}</span></Context.Handle>;
|
|
}
|
|
}</Tree>
|
|
</Sidebar>
|
|
|
|
<Grid columns={columns()} rows={rows()} api={setApi} />
|
|
</Context.Root>
|
|
</div>
|
|
} |