Rework file access and link archiving

This commit is contained in:
Peaceultime 2024-12-01 23:25:33 +01:00
parent f7094f7ce1
commit 602b0af212
14 changed files with 186 additions and 151 deletions

View File

@ -1,5 +1,5 @@
<template>
<HoverCardRoot :open-delay="delay">
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
<HoverCardTrigger class="inline-block cursor-help outline-none">
<slot></slot>
</HoverCardTrigger>
@ -18,4 +18,6 @@ const { delay = 500, disabled = false, side = 'bottom' } = defineProps<{
disabled?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
}>();
const emits = defineEmits(['open'])
</script>

View File

@ -1,29 +1,31 @@
<template>
<NuxtLink class="text-accent-blue inline-flex items-center" v-if="data && data[0]"
:to="{ name: 'explore-path', params: { path: data[0].path }, hash: hash }" :class="class">
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': data[0].type === 'canvas'}">
<NuxtLink class="text-accent-blue inline-flex items-center" v-if="overview"
:to="{ name: 'explore-path', params: { path: overview.path }, hash: hash }" :class="class">
<HoverCard class="max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]" :class="{'overflow-hidden !p-0': overview.type === 'canvas'}" @open="load">
<template #content>
<template v-if="data[0].type === 'markdown'">
<template v-if="loading">
<Loading />
</template>
<template v-else-if="overview.type === 'markdown'">
<div class="px-10">
<Markdown :content="data[0].content" />
<Markdown :content="content!.content" />
</div>
</template>
<template v-else-if="data[0].type === 'canvas'">
<template v-else-if="overview.type === 'canvas'">
<div class="w-[600px] h-[600px] relative">
<Canvas :canvas="JSON.parse(data[0].content)" />
<Canvas :canvas="JSON.parse(content!.content)" />
</div>
</template>
</template>
<template #default>
<slot v-bind="$attrs"></slot>
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :icon="iconByType[data[0].type]" />
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :icon="iconByType[overview.type]" />
</template>
</HoverCard>
</NuxtLink>
<NuxtLink v-else-if="href" :to="href" :class="class" class="text-accent-blue inline-flex items-center">
<slot v-bind="$attrs"></slot>
<Icon class="w-4 h-4 inline-block" v-if="data && data[0] && data[0].type !== 'markdown'" :height="20" :width="20"
:icon="`icons/link-${data[0].type.toLowerCase()}`" />
<Icon class="w-4 h-4 inline-block" v-if="overview && overview.type !== 'markdown'" :height="20" :width="20" :icon="iconByType[overview.type]" />
</NuxtLink>
<slot :class="class" v-else v-bind="$attrs"></slot>
</template>
@ -39,17 +41,35 @@ const { href } = defineProps<{
}>();
const { hash, pathname, protocol } = parseURL(href);
const data = ref(), loading = ref(false);
const overview = ref<{
path: string;
owner: number;
title: string;
type: "file" | "folder" | "markdown" | "canvas";
navigable: boolean;
private: boolean;
order: number;
visit: number;
}>(), content = ref<{
content: string;
}>(), loading = ref(false), fetched = ref(false);
if(!!pathname && !protocol)
{
try {
overview.value = await $fetch(`/api/file/overview/${encodeURIComponent(pathname)}`);
} catch(e) {}
}
async function load()
{
if(fetched.value === true)
return;
fetched.value = true;
loading.value = true;
try {
data.value = await $fetch(`/api/file`, {
query: {
search: `%${pathname}`
},
});
content.value = await $fetch(`/api/file/content/${encodeURIComponent(pathname)}`);
} catch(e) { }
loading.value = false;
}

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -55,7 +55,7 @@
</div>
</div>
<div class="flex-1 xl:px-6 px-3 max-w-full max-h-full overflow-y-auto overflow-x-hidden">
<NuxtLink :href="{ name: 'explore' }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
<NuxtLink :href="{ name: 'explore-path', params: { path: 'index' } }" class="flex flex-1 font-bold text-lg items-center border-light-35 dark:border-dark-35 hover:border-accent-blue" active-class="text-accent-blue border-s-2 !border-accent-blue">
<div class="pl-3 py-1 flex-1 truncate">Projet</div>
</NuxtLink>
<Tree v-if="pages" v-model="pages" :getKey="(item) => item.path">

View File

@ -1,35 +1,46 @@
<template>
<div v-if="status === 'pending'" class="flex">
<div v-if="overviewStatus === 'pending'" class="flex">
<Head>
<Title>d[any] - Chargement</Title>
</Head>
<Loading />
</div>
<div class="flex flex-1 justify-start items-start" v-else-if="page">
<div class="flex flex-1 justify-start items-start" v-else-if="overview">
<Head>
<Title>d[any] - {{ page.title }}</Title>
<Title>d[any] - {{ overview.title }}</Title>
</Head>
<template v-if="page.type === 'markdown'">
<template v-if="overview.type === 'markdown'">
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
<div class="flex flex-1 flex-row justify-between items-center">
<ProseH1>{{ page.title }}</ProseH1>
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }"><Button v-if="isOwner">Modifier</Button></NuxtLink>
<ProseH1>{{ overview.title }}</ProseH1>
<div class="flex gap-4">
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: path } }" v-if="isOwner"><Button>Modifier</Button></NuxtLink>
<NuxtLink :href="{ name: 'explore-edit' }" v-if="isOwner && path === 'index'"><Button>Configurer le projet</Button></NuxtLink>
</div>
<Markdown :content="page.content" />
</div>
<Markdown v-if="content" :content="content.content" />
<Loading v-else />
</div>
</template>
<template v-else-if="page.type === 'canvas'">
<Canvas :canvas="JSON.parse(page.content)" />
<template v-else-if="overview.type === 'canvas'">
<Canvas v-if="content" :canvas="JSON.parse(content.content)" />
<Loading v-else />
</template>
<template v-else>
<ProseH2 class="flex-1 text-center">Impossible d'afficher le contenu demandé</ProseH2>
</template>
</div>
<div v-else-if="status === 'error'">
<div v-else-if="overviewStatus === 'error'">
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<span>{{ error?.message }}</span>
<span>{{ overviewError?.message }}</span>
</div>
<div v-else-if="contentStatus === 'error'">
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<span>{{ contentError?.message }}</span>
</div>
<div v-else>
<Head>
@ -43,15 +54,9 @@
const route = useRouter().currentRoute;
const path = computed(() => Array.isArray(route.value.params.path) ? route.value.params.path[0] : route.value.params.path);
watch(path, () => {
if(path.value === 'index')
{
useRouter().replace({ name: 'explore' });
}
}, { immediate: true });
const { user } = useUserSession();
const { loggedIn, user } = useUserSession();
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [route, path], });
const isOwner = computed(() => user.value?.id === page.value?.owner);
const { data: overview, status: overviewStatus, error: overviewError } = await useFetch(`/api/file/overview/${encodeURIComponent(path.value)}`, { watch: [route, path], });
const { data: content, status: contentStatus, error: contentError } = await useFetch(`/api/file/content/${encodeURIComponent(path.value)}`, { watch: [route, path], });
const isOwner = computed(() => user.value?.id === overview.value?.owner);
</script>

View File

@ -1,14 +1,14 @@
<template>
<div v-if="page" class="xl:p-12 lg:p-8 py-4 flex flex-1 flex-col items-start justify-start max-h-full">
<div v-if="overview" class="xl:p-12 lg:p-8 py-4 flex flex-1 flex-col items-start justify-start max-h-full">
<Head>
<Title>d[any] - Modification de {{ page.title }}</Title>
<Title>d[any] - Modification de {{ overview.title }}</Title>
</Head>
<div class="flex flex-col xl:flex-row xl:justify-between justify-center items-center w-full px-4 pb-4 border-b border-light-35 dark:border-dark-35 bg-light-0 dark:bg-dark-0">
<input type="text" v-model="page.title" placeholder="Titre" class="flex-1 mx-4 h-16 w-full caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none px-3 py-1 text-5xl font-thin bg-transparent" />
<input type="text" v-model="overview.title" placeholder="Titre" class="flex-1 mx-4 h-16 w-full caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 appearance-none outline-none px-3 py-1 text-5xl font-thin bg-transparent" />
<div class="flex gap-4 self-end xl:self-auto flex-wrap">
<div class="flex gap-4">
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="page.private" /></Tooltip>
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="page.navigable" /></Tooltip>
<Tooltip message="Consultable uniquement par le propriétaire" side="bottom"><Switch label="Privé" v-model="overview.private" /></Tooltip>
<Tooltip message="Afficher dans le menu de navigation" side="bottom"><Switch label="Navigable" v-model="overview.navigable" /></Tooltip>
</div>
<div class="flex gap-4">
<Tooltip message="Ctrl+S" side="bottom"><Button @click="() => save(true)" :loading="saveStatus === 'pending'" class="border-light-blue dark:border-dark-blue hover:border-light-blue dark:hover:border-dark-blue focus:shadow-light-blue dark:focus:shadow-dark-blue">Enregistrer</Button></Tooltip>
@ -17,10 +17,12 @@
</div>
</div>
<div class="my-4 flex-1 w-full max-h-full flex">
<template v-if="page.type === 'markdown'">
<SplitterGroup direction="horizontal" class="flex-1 w-full flex" >
<template v-if="overview.type === 'markdown'">
<Loading v-if="contentStatus === 'pending'" />
<span v-else-if="contentError">{{ contentError.message }}</span>
<SplitterGroup direction="horizontal" class="flex-1 w-full flex" v-else >
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }" :defaultSize="50">
<Editor v-model="page.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
<Editor v-model="page!.content" class="flex-1 bg-transparent appearance-none outline-none max-h-full resize-none !overflow-y-auto" :class="{ 'hidden': isCollapsed }" />
</SplitterPanel>
<SplitterResizeHandle class="bg-light-35 dark:bg-dark-35 w-px xl!mx-4 mx-2" />
<SplitterPanel asChild collapsible :collapsedSize="0" :minSize="20" v-slot="{ isCollapsed }">
@ -28,21 +30,21 @@
</SplitterPanel>
</SplitterGroup>
</template>
<template v-else-if="page.type === 'canvas'">
<template v-else-if="overview.type === 'canvas'">
<span class="flex-1 items-center"><ProseH1>Editeur de graphe en cours de développement</ProseH1></span>
</template>
<template v-else-if="page.type === 'file'">
<template v-else-if="overview.type === 'file'">
<span>Modifier le contenu :</span><input type="file" @change="(e) => console.log(e)" />
</template>
</div>
</div>
<div v-else-if="status === 'pending'" class="flex">
<div v-else-if="overviewStatus === 'pending'" class="flex">
<Head>
<Title>d[any] - Chargement</Title>
</Head>
<Loading />
</div>
<div v-else-if="status === 'error'">{{ error?.message }}</div>
<div v-else-if="overviewStatus === 'error'">{{ overviewError?.message }}</div>
</template>
<script setup lang="ts">
@ -59,7 +61,8 @@ const toaster = useToast();
const saveStatus = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
const sessionContent = useSessionStorage<string | undefined>(path.value, undefined);
const { data: page, status, error } = await useFetch(`/api/file/${encodeURIComponent(path.value)}`, { watch: [ route, path ], transform: (value) => {
const { data: overview, status: overviewStatus, error: overviewError } = await useFetch(`/api/file/overview/${encodeURIComponent(path.value)}`, { watch: [ route, path ] });
const { data: page, status: contentStatus, error: contentError } = await useFetch(`/api/file/content/${encodeURIComponent(path.value)}`, { watch: [ route, path ], transform: (value) => {
if(value && sessionContent.value !== undefined)
{
value.content = sessionContent.value;
@ -79,7 +82,7 @@ const { data: page, status, error } = await useFetch(`/api/file/${encodeURICompo
const content = computed(() => page.value?.content);
const debounced = useDebounce(content, 250);
if(!loggedIn || (page.value && page.value.owner !== user.value?.id))
if(!loggedIn || (overview.value && overview.value.owner !== user.value?.id))
{
router.replace({ name: 'explore-path', params: { path: path.value } });
}
@ -99,7 +102,7 @@ async function save(redirect: boolean): Promise<void>
try {
await $fetch(`/api/file`, {
method: 'post',
body: page.value,
body: { ...page.value, ...overview.value },
});
saveStatus.value = 'success';
sessionContent.value = undefined;

View File

@ -1,42 +0,0 @@
<template>
<div v-if="status === 'pending'" class="flex">
<Head>
<Title>d[any] - Chargement</Title>
</Head>
<Loading />
</div>
<div class="flex flex-1 justify-start items-start" v-else-if="page">
<Head>
<Title>d[any] - Accueil</Title>
</Head>
<div class="flex flex-1 justify-start items-start flex-col xl:px-24 md:px-8 px-4 py-6">
<div class="flex flex-1 flex-row justify-between items-center">
<ProseH1>{{ page.title }}</ProseH1>
<div class="flex gap-4">
<NuxtLink :href="{ name: 'explore-edit-path', params: { path: 'index' } }"><Button v-if="isOwner">Modifier la page</Button></NuxtLink>
<NuxtLink :href="{ name: 'explore-edit' }"><Button v-if="isOwner">Configurer le projet</Button></NuxtLink>
</div>
</div>
<Markdown :content="page.content" />
</div>
</div>
<div v-else-if="status === 'error'">
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<span>{{ error?.message }}</span>
</div>
<div v-else>
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<div><ProseH2>Impossible d'afficher le contenu demandé</ProseH2></div>
</div>
</template>
<script setup lang="ts">
const { loggedIn, user } = useUserSession();
const { data: page, status, error } = await useFetch(`/api/file/index`);
const isOwner = computed(() => user.value?.id === page.value?.owner);
</script>

View File

@ -1,47 +0,0 @@
import { and, eq, like, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const query = getQuery(e);
const where = [];
if(query && query.path !== undefined)
{
where.push(eq(explorerContentTable.path, sql.placeholder('path')));
}
if(query && query.title !== undefined)
{
where.push(eq(explorerContentTable.title, sql.placeholder('title')));
}
if(query && query.type !== undefined)
{
where.push(eq(explorerContentTable.type, sql.placeholder('type')));
}
if (query && query.search !== undefined)
{
where.push(like(explorerContentTable.path, sql.placeholder('search')));
}
if(where.length > 0)
{
const db = useDatabase();
const content = db.select({
'path': explorerContentTable.path,
'owner': explorerContentTable.owner,
'title': explorerContentTable.title,
'type': explorerContentTable.type,
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'navigable': explorerContentTable.navigable,
'private': explorerContentTable.private,
}).from(explorerContentTable).where(and(...where)).prepare().all(query);
if(content.length > 0)
{
return content;
}
}
setResponseStatus(e, 404);
});

View File

@ -1,6 +1,7 @@
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
import { schema } from '~/schemas/file';
import { parsePath } from '~/shared/general.utils';
export default defineEventHandler(async (e) => {
const body = await readValidatedBody(e, schema.safeParse);
@ -10,9 +11,10 @@ export default defineEventHandler(async (e) => {
throw body.error;
}
const buffer = Buffer.from(body.data.content, 'utf-8');
const db = useDatabase();
const buffer = Buffer.from(convertToStorableLinks(body.data.content, db.select({ path: explorerContentTable.path }).from(explorerContentTable).all().map(e => e.path)), 'utf-8');
const content = db.insert(explorerContentTable).values({ ...body.data, content: buffer }).onConflictDoUpdate({ target: explorerContentTable.path, set: { ...body.data, content: buffer, timestamp: new Date() } });
if(content !== undefined)
@ -23,3 +25,13 @@ export default defineEventHandler(async (e) => {
setResponseStatus(e, 404);
return;
});
export function convertToStorableLinks(content: string, path: string[]): string
{
return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
const replacer = path.find(e => e.endsWith(parsed));
const value = `[[${replacer ?? ''}${a2 ?? ''}${(!a3 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
return value;
});
}

View File

@ -0,0 +1,62 @@
import { eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { explorerContentTable } from '~/db/schema';
export default defineEventHandler(async (e) => {
const path = decodeURIComponent(getRouterParam(e, "path") ?? '');
const query = getQuery(e);
if(!path)
{
setResponseStatus(e, 404);
return;
}
const db = useDatabase();
const content = db.select({
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'private': explorerContentTable.private,
'owner': explorerContentTable.owner,
'visit': explorerContentTable.visit,
}).from(explorerContentTable).where(eq(explorerContentTable.path, sql.placeholder('path'))).prepare().get({ path });
if(content !== undefined)
{
const session = await getUserSession(e);
if(content.private && (!session || !session.user))
{
setResponseStatus(e, 404);
return;
}
if(session && session.user && content.private && session.user.id !== content.owner)
{
setResponseStatus(e, 404);
return;
}
if(query.type === 'view')
{
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, path)).run();
}
if(query.type === 'editing')
{
content.content = convertFromStorableLinks(content.content);
}
return { content: content.content };
}
setResponseStatus(e, 404);
return;
});
export function convertFromStorableLinks(content: string): string
{
/*return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
const parsed = parsePath(a1 ?? '%%%%----%%%%----%%%%');
const replacer = path.find(e => e.endsWith(parsed)) ?? parsed;
const value = `[[${replacer}${a2 ?? ''}${(!a3 && replacer !== parsed ? '|' + a1 : a3) ?? ''}]]`;
return value;
});*/
return content;
}

View File

@ -18,7 +18,6 @@ export default defineEventHandler(async (e) => {
'owner': explorerContentTable.owner,
'title': explorerContentTable.title,
'type': explorerContentTable.type,
'content': sql<string>`cast(${explorerContentTable.content} as TEXT)`.as('content'),
'navigable': explorerContentTable.navigable,
'private': explorerContentTable.private,
'order': explorerContentTable.order,
@ -27,7 +26,18 @@ export default defineEventHandler(async (e) => {
if(content !== undefined)
{
db.update(explorerContentTable).set({ visit: content.visit + 1 }).where(eq(explorerContentTable.path, content.path)).run();
const session = await getUserSession(e);
if(content.private && (!session || !session.user))
{
setResponseStatus(e, 404);
return;
}
if(session && session.user && content.private && session.user.id !== content.owner)
{
setResponseStatus(e, 404);
return;
}
return content;
}

View File

@ -3,6 +3,7 @@ import { extname, basename } from 'node:path';
import type { FileType } from '~/types/api';
import type { CanvasColor, CanvasContent } from "~/types/canvas";
import { explorerContentTable } from "~/db/schema";
import { convertToStorableLinks } from "../api/file.post";
const typeMapping: Record<string, FileType> = {
".md": "markdown",
@ -27,7 +28,7 @@ export default defineTask({
}
}) as { tree: any[] } & Record<string, any>;
const files: typeof explorerContentTable.$inferInsert = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
const files: typeof explorerContentTable.$inferInsert[] = await Promise.all(tree.tree.filter((e: any) => !e.path.startsWith(".")).map(async (e, i) => {
if(e.type === 'tree')
{
const title = basename(e.path);
@ -63,12 +64,18 @@ export default defineTask({
}
}));
const pathList = files.map(e => e.path);
files.forEach(e => {
if(e.type !== 'folder' && e.content)
{
e.content = Buffer.from(convertToStorableLinks(e.content.toString('utf-8'), files.map(e => e.path)), 'utf-8');
}
})
const db = useDatabase();
db.delete(explorerContentTable).run();
db.insert(explorerContentTable).values(files).run();
useStorage('cache').clear();
return { result: true };
}
catch(e)
@ -85,6 +92,9 @@ function reshapeContent(content: string, type: FileType): string | null
switch(type)
{
case "markdown":
return content.replaceAll(/!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/g, (e: string, a1?: string, a2?: string , a3?: string) => {
return `[[${a1?.split('/').map(f => { const check = /(\d+)\. ?(.+)/gsmi.exec(f); return check && check[2] ? check[2] : f }).join('/') ?? ''}${a2 ?? ''}${a3 ?? ''}]]`;
});
case "file":
return content;
case "canvas":