#113 - Implemented local database for media

This commit is contained in:
Elio Struyf
2021-09-27 16:22:23 +02:00
parent c206817431
commit 4e7488414d
15 changed files with 357 additions and 24 deletions
+32
View File
@@ -359,6 +359,15 @@
"integrity": "sha512-Agl6xbYP6FOMDeAsr3QVZ+g7Yzg0uhPHWx0j5g4LFdUBHVtqtU+gH660k/lCEe506jJLOGbEzsnqPDTZGJQLag==",
"dev": true
},
"@types/node-persist": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@types/node-persist/-/node-persist-3.1.2.tgz",
"integrity": "sha512-aLFUB1951wOfR+tZ4f3TudLPblo9+PfnduMh6feuOTijD0Q6YkMdqPXSgQIjI23FGmCuoZaIZ9x6cvEG/TdiSg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -4106,6 +4115,23 @@
"lodash": "^4.17.21"
}
},
"node-json-db": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/node-json-db/-/node-json-db-1.3.0.tgz",
"integrity": "sha512-3IK9KuqfKdK12zFKtzmnD6Y5J9qL0TY0gnnZ5XKpzdhCP019zOxPxGCaH6cmIqiho2ymFgcTMKQeJvYkbBFraQ==",
"dev": true,
"requires": {
"mkdirp": "~1.0.4"
},
"dependencies": {
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"dev": true
}
}
},
"node-libs-browser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
@@ -4145,6 +4171,12 @@
}
}
},
"node-persist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/node-persist/-/node-persist-3.1.0.tgz",
"integrity": "sha512-/j+fd/u71wNgKf3V2bx4tnDm+3GvLnlCuvf2MXbJ3wern+67IAb6zN9Leu1tCWPlPNZ+v1hLSibVukkPK2HqJw==",
"dev": true
},
"node-releases": {
"version": "1.1.75",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.75.tgz",
+3
View File
@@ -626,6 +626,7 @@
"@types/lodash.uniqby": "4.7.6",
"@types/mocha": "^5.2.6",
"@types/node": "10.17.48",
"@types/node-persist": "^3.1.2",
"@types/react": "17.0.0",
"@types/react-datepicker": "^4.1.7",
"@types/react-dom": "17.0.0",
@@ -642,6 +643,8 @@
"html-webpack-plugin": "4.5.0",
"lodash.uniqby": "4.7.0",
"mdast-util-from-markdown": "1.0.0",
"node-json-db": "^1.3.0",
"node-persist": "^3.1.0",
"postcss": "^8.3.6",
"postcss-loader": "4.3.0",
"react": "17.0.1",
+27 -4
View File
@@ -23,6 +23,7 @@ import { decodeBase64Image } from '../helpers/decodeBase64Image';
import { DefaultFields } from '../constants';
import { DashboardData } from '../models/DashboardData';
import { ExplorerView } from '../explorerView/ExplorerView';
import { MediaLibrary } from '../helpers/MediaLibrary';
export class Dashboard {
@@ -31,6 +32,7 @@ export class Dashboard {
private static media: MediaInfo[] = [];
private static timers: { [folder: string]: any } = {};
private static _viewData: DashboardData | undefined;
private static mediaLib: MediaLibrary;
public static get viewData(): DashboardData | undefined {
return Dashboard._viewData;
@@ -40,6 +42,8 @@ export class Dashboard {
* Init the dashboard
*/
public static async init() {
this.mediaLib = MediaLibrary.getInstance();
const openOnStartup = SettingsHelper.get(SETTINGS_DASHBOARD_OPENONSTART);
if (openOnStartup) {
Dashboard.open();
@@ -160,7 +164,7 @@ export class Dashboard {
env.clipboard.writeText(msg.data);
break;
case DashboardMessage.refreshMedia:
Dashboard.media = [];
Dashboard.resetMedia();
Dashboard.getMedia(0, msg?.data?.folder);
break;
case DashboardMessage.uploadMedia:
@@ -172,9 +176,16 @@ export class Dashboard {
case DashboardMessage.insertPreviewImage:
Dashboard.insertImage(msg?.data);
break;
case DashboardMessage.updateMediaMetadata:
Dashboard.updateMediaMetadata(msg?.data);
break;
}
});
}
public static resetMedia() {
Dashboard.media = [];
}
/**
* Insert an image into the front matter or contents
@@ -197,7 +208,7 @@ export class Dashboard {
const line = data.position.line;
const character = data.position.character;
if (line) {
await editor?.edit(builder => builder.insert(new Position(line, character), data.snippet || `![](${data.image})`));
await editor?.edit(builder => builder.insert(new Position(line, character), data.snippet || `![${data.alt || data.description || ""}](${data.image})`));
}
panel.getMediaSelection();
} else {
@@ -295,14 +306,18 @@ export class Dashboard {
files = files.slice(page * 16, ((page + 1) * 16));
files = files.map((file) => {
try {
const metadata = Dashboard.mediaLib.get(file.fsPath);
return {
...file,
stats: statSync(file.fsPath)
stats: statSync(file.fsPath),
...metadata
};
} catch (e) {
return {...file, stats: undefined};
}
}).filter(f => f.stats !== undefined);
});
files = files.filter(f => f.stats !== undefined);
const folders = [...new Set(Dashboard.media.map((file) => {
let relFolderPath = wsFolder ? file.fsPath.substring(wsFolder.fsPath.length + 1) : file.fsPath;
@@ -475,6 +490,14 @@ export class Dashboard {
}
}
/**
* Update the metadata of the selected file
*/
private static async updateMediaMetadata({ file, page, folder, description = null, alt = null }: { file:string; page: number; folder: string | null; description: string | null; alt: string | null; }) {
Dashboard.mediaLib.set(file, description, alt);
Dashboard.getMedia(page || 0, folder || "");
}
/**
* Post data to the dashboard
* @param msg
-1
View File
@@ -5,7 +5,6 @@ import { ContentFolder, FileInfo, FolderInfo } from "../models";
import uniqBy = require("lodash.uniqby");
import { Template } from "./Template";
import { Notifications } from "../helpers/Notifications";
import { CONTEXT } from "../constants/context";
import { Settings } from "../helpers";
export const WORKSPACE_PLACEHOLDER = `[[workspace]]`;
+1
View File
@@ -14,4 +14,5 @@ export enum DashboardMessage {
uploadMedia = 'uploadMedia',
deleteMedia = 'deleteMedia',
insertPreviewImage = 'insertPreviewImage',
updateMediaMetadata = 'updateMediaMetadata',
}
@@ -5,7 +5,7 @@ import useDarkMode from '../../hooks/useDarkMode';
import usePages from '../hooks/usePages';
import { WelcomeScreen } from './WelcomeScreen';
import { useRecoilValue } from 'recoil';
import { DashboardViewSelector, ViewDataAtom } from '../state';
import { DashboardViewSelector } from '../state';
import { Contents } from './Contents/Contents';
import { Media } from './Media/Media';
+113 -9
View File
@@ -1,12 +1,14 @@
import { Messenger } from '@estruyf/vscode/dist/client';
import { CheckCircleIcon, ClipboardCopyIcon, CodeIcon, PhotographIcon, TrashIcon } from '@heroicons/react/outline';
import { CheckCircleIcon, ClipboardCopyIcon, CodeIcon, PencilIcon, PhotographIcon, TrashIcon } from '@heroicons/react/outline';
import { basename, dirname } from 'path';
import * as React from 'react';
import { useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { MediaInfo } from '../../../models/MediaPaths';
import { DashboardMessage } from '../../DashboardMessage';
import { LightboxAtom, SelectedMediaFolderSelector, SettingsSelector, ViewDataSelector } from '../../state';
import { LightboxAtom, PageSelector, SelectedMediaFolderSelector, SettingsSelector, ViewDataSelector } from '../../state';
import { Alert } from '../Modals/Alert';
import { Metadata } from '../Modals/Metadata';
export interface IItemProps {
media: MediaInfo;
@@ -17,7 +19,11 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
const selectedFolder = useRecoilValue(SelectedMediaFolderSelector);
const [ , setLightbox ] = useRecoilState(LightboxAtom);
const [ showAlert, setShowAlert ] = React.useState(false);
const [ showForm, setShowForm ] = React.useState(false);
const viewData = useRecoilValue(ViewDataSelector);
const [ description, setDescription ] = React.useState(media.description);
const [ alt, setAlt ] = React.useState(media.alt);
const page = useRecoilValue(PageSelector);
const parseWinPath = (path: string | undefined) => {
return path?.split(`\\`).join(`/`);
@@ -59,18 +65,25 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
image: parseWinPath(relPath) || "",
file: viewData?.data?.filePath,
fieldName: viewData?.data?.fieldName,
position: viewData?.data?.position || null
position: viewData?.data?.position || null,
alt: alt || "",
description: description || ""
});
};
const insertSnippet = () => {
const relPath = getRelPath();
let snippet = settings?.mediaSnippet.join("\n");
snippet = snippet?.replace("{mediaUrl}", parseWinPath(relPath) || "");
snippet = snippet?.replace("{alt}", alt || "");
snippet = snippet?.replace("{description}", description || "");
Messenger.send(DashboardMessage.insertPreviewImage, {
image: parseWinPath(relPath) || "",
file: viewData?.data?.filePath,
fieldName: viewData?.data?.fieldName,
position: viewData?.data?.position || null,
snippet: settings?.mediaSnippet.join("\n").replace("{mediaUrl}", parseWinPath(relPath) || "")
snippet
});
};
@@ -100,6 +113,33 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
setLightbox(media.vsPath || "");
};
const updateMetadata = () => {
setShowForm(true);
};
const submitMetadata = () => {
Messenger.send(DashboardMessage.updateMediaMetadata, {
file: media.fsPath,
description,
alt,
folder: selectedFolder,
page
});
setShowForm(false);
};
useEffect(() => {
if (media.alt !== description) {
setAlt(media.alt);
}
}, [media.alt]);
useEffect(() => {
if (media.description !== description) {
setDescription(media.description);
}
}, [media.description]);
return (
<>
<li className="group relative bg-gray-50 dark:bg-vulcan-200 hover:shadow-xl dark:hover:bg-vulcan-100">
@@ -112,7 +152,7 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
</div>
</button>
<div className={`relative py-4 pl-4 pr-10`}>
<div className={`absolute top-4 right-4 flex flex-col space-y-2`}>
<div className={`absolute top-4 right-4 flex flex-col space-y-4`}>
{
viewData?.data?.filePath ? (
<>
@@ -137,6 +177,12 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
</>
) : (
<>
<button title={`Edit metadata`}
className={`hover:text-teal-900 focus:outline-none`}
onClick={updateMetadata}>
<PencilIcon className={`h-5 w-5`} />
<span className={`sr-only`}>Edit metadata</span>
</button>
<button title={`Copy media path`}
className={`hover:text-teal-900 focus:outline-none`}
onClick={copyToClipboard}>
@@ -156,19 +202,77 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
<p className="text-sm dark:text-whisper-900 font-bold pointer-events-none flex items-center">
{basename(parseWinPath(media.fsPath) || "")}
</p>
<p className="mt-2 text-sm dark:text-whisper-900 font-medium pointer-events-none flex items-center">
<b className={`mr-2`}>Folder:</b> {getFolder()}
{
media.description && (
<p className="mt-2 text-xs dark:text-whisper-900 font-medium pointer-events-none flex flex-col items-start">
<b className={`mr-2`}>Description:</b>
<span className={`block mt-1 dark:text-whisper-500 text-xs`}>{media.description}</span>
</p>
)
}
{
media.alt && (
<p className="mt-2 text-xs dark:text-whisper-900 font-medium pointer-events-none flex flex-col items-start">
<b className={`mr-2`}>Alt:</b>
<span className={`block mt-1 dark:text-whisper-500 text-xs`}>{media.alt}</span>
</p>
)
}
<p className="mt-2 text-xs dark:text-whisper-900 font-medium pointer-events-none flex flex-col items-start">
<b className={`mr-2`}>Folder:</b>
<span className={`block mt-1 dark:text-whisper-500 text-xs`}>{getFolder()}</span>
</p>
{
media?.stats?.size && (
<p className="mt-2 text-sm dark:text-whisper-900 font-medium pointer-events-none flex items-center">
<b className={`mr-1`}>Size:</b> {calculateSize()}
<p className="mt-2 text-xs dark:text-whisper-900 font-medium pointer-events-none flex flex-col items-start">
<b className={`mr-1`}>Size:</b>
<span className={`block mt-1 dark:text-whisper-500 text-xs`}>{calculateSize()}</span>
</p>
)
}
</div>
</li>
{
showForm && (
<Metadata
title={`Set metadata for: ${basename(parseWinPath(media.fsPath) || "")}`}
description={`Please specify the metadata you want to set for the file.`}
okBtnText={`Save`}
cancelBtnText={`Cancel`}
dismiss={() => setShowForm(false)}
trigger={submitMetadata}>
<div className="flex flex-col space-y-2">
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700 dark:text-whisper-900">
Description
</label>
<div className="mt-1">
<textarea
rows={3}
className="py-1 px-2 sm:text-sm bg-white dark:bg-vulcan-300 border border-gray-300 dark:border-vulcan-100 text-vulcan-500 dark:text-whisper-500 placeholder-gray-400 dark:placeholder-whisper-800 focus:outline-none w-full"
value={description || ''}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700 dark:text-whisper-900">
Alt tag value
</label>
<div className="mt-1">
<input
className="py-1 px-2 sm:text-sm bg-white dark:bg-vulcan-300 border border-gray-300 dark:border-vulcan-100 text-vulcan-500 dark:text-whisper-500 placeholder-gray-400 dark:placeholder-whisper-800 focus:outline-none w-full"
value={alt || ''}
onChange={(e) => setAlt(e.target.value)}
/>
</div>
</div>
</div>
</Metadata>
)
}
{
showAlert && (
<Alert
@@ -3,7 +3,7 @@ import { RefreshIcon } from '@heroicons/react/outline';
import * as React from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { DashboardMessage } from '../../DashboardMessage';
import { LoadingAtom, MediaTotalSelector, SelectedMediaFolderSelector } from '../../state';
import { LoadingAtom, MediaTotalSelector, PageAtom, SelectedMediaFolderSelector } from '../../state';
import { FolderSelection } from './FolderSelection';
import { LIMIT } from './Media';
import { PaginationButton } from './PaginationButton';
@@ -14,7 +14,7 @@ export const Pagination: React.FunctionComponent<IPaginationProps> = ({}: React.
const selectedFolder = useRecoilValue(SelectedMediaFolderSelector);
const totalMedia = useRecoilValue(MediaTotalSelector);
const [ , setLoading ] = useRecoilState(LoadingAtom);
const [ page, setPage ] = React.useState<number>(0);
const [ page, setPage ] = useRecoilState(PageAtom);
const totalPages = Math.ceil(totalMedia / LIMIT) - 1;
@@ -0,0 +1,90 @@
import { Dialog, Transition } from '@headlessui/react';
import * as React from 'react';
import { Fragment, useRef } from 'react';
export interface IMetadataProps {
title: string;
description: string;
okBtnText: string;
cancelBtnText: string;
dismiss: () => void;
trigger: () => void;
}
export const Metadata: React.FunctionComponent<IMetadataProps> = ({title, description, cancelBtnText, okBtnText, dismiss, trigger, children}: React.PropsWithChildren<IMetadataProps>) => {
const cancelButtonRef = useRef(null);
return (
<Transition.Root show={true} as={Fragment}>
<Dialog className="fixed z-10 inset-0 overflow-y-auto" initialFocus={cancelButtonRef} onClose={() => dismiss()}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-vulcan-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom bg-white dark:bg-vulcan-500 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6 border-2 border-whisper-900">
<div>
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-vulcan-300 dark:text-whisper-900">
{title}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-vulcan-500 dark:text-whisper-500">
{description}
</p>
</div>
<div className="mt-4">
{children}
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-teal-600 text-base font-medium text-white hover:bg-teal-700 dark:hover:bg-teal-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => trigger()}
>
{okBtnText}
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-200 focus:outline-none sm:mt-0 sm:w-auto sm:text-sm"
onClick={() => dismiss()}
ref={cancelButtonRef}
>
{cancelBtnText}
</button>
</div>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
@@ -0,0 +1,9 @@
import { selector } from 'recoil';
import { PageAtom } from '..';
export const PageSelector = selector({
key: 'PageSelector',
get: ({get}) => {
return get(PageAtom);
}
});
@@ -5,6 +5,7 @@ export * from './GroupingSelector';
export * from './LoadingSelector';
export * from './MediaFoldersSelector';
export * from './MediaTotalSelector';
export * from './PageSelector';
export * from './SearchSelector';
export * from './SelectedMediaFolderSelector';
export * from './SettingsSelector';
-6
View File
@@ -167,12 +167,6 @@ export async function activate(context: vscode.ExtensionContext) {
// Inserting an image in Markdown
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.insertImage, Article.insertImage));
vscode.workspace.onDidRenameFiles(async (e) => {
if (e.files.length > 0) {
console.log('Renamed files: ', e.files);
}
});
// Subscribe all commands
subscriptions.push(
insertTags,
-1
View File
@@ -1,5 +1,4 @@
import * as vscode from 'vscode';
import { EXTENSION_NAME } from '../constants';
import { Notifications } from './Notifications';
export class FilesHelper {
+76
View File
@@ -0,0 +1,76 @@
import { Dashboard } from '../commands/Dashboard';
import { workspace } from 'vscode';
import { JsonDB } from 'node-json-db/dist/JsonDB';
import { join } from 'path';
import { Folders, WORKSPACE_PLACEHOLDER } from '../commands/Folders';
interface MediaRecord {
description: string;
alt: string;
}
export class MediaLibrary {
private db: JsonDB;
private static instance: MediaLibrary;
private constructor() {
const wsFolder = Folders.getWorkspaceFolder();
this.db = new JsonDB(join(wsFolder?.fsPath || "", '.frontmatter/mediaDb.json'), true, false, '/');
workspace.onDidRenameFiles(e => {
e.files.forEach(f => {
// Check if file is an image
if (f.oldUri.path.endsWith('.jpeg') ||
f.oldUri.path.endsWith('.jpg') ||
f.oldUri.path.endsWith('.png') ||
f.oldUri.path.endsWith('.gif')) {
this.rename(f.oldUri.fsPath, f.newUri.fsPath);
Dashboard.resetMedia();
}
});
});
}
public static getInstance(): MediaLibrary {
if (!MediaLibrary.instance) {
MediaLibrary.instance = new MediaLibrary();
}
return MediaLibrary.instance;
}
public get(id: string): MediaRecord | undefined {
try {
const fileId = this.parsePath(id);
if (this.db.exists(fileId)) {
return this.db.getData(fileId);
}
return undefined;
} catch {
return undefined;
}
}
public set(id: string, description: string | null = null, alt: string | null = null): void {
const fileId = this.parsePath(id);
this.db.push(fileId, { description, alt }, true);
}
public rename(oldId: string, newId: string): void {
const fileId = this.parsePath(oldId);
const newFileId = this.parsePath(newId);
const data = this.db.getData(fileId);
if (data) {
this.db.delete(fileId);
this.db.push(newFileId, data, true);
}
}
private parsePath(path: string) {
const wsFolder = Folders.getWorkspaceFolder();
const isWindows = process.platform === 'win32';
let absPath = path.replace(wsFolder?.fsPath || "", WORKSPACE_PLACEHOLDER);
absPath = isWindows ? absPath.split('\\').join('/') : absPath;
return absPath.toLowerCase();
}
}
+2
View File
@@ -10,4 +10,6 @@ export interface MediaInfo {
fsPath: string;
vsPath: string | undefined;
stats: Stats | undefined;
description?: string | undefined;
alt?: string | undefined;
}