mirror of
https://github.com/pawelmalak/snippet-box.git
synced 2025-12-21 13:23:05 +01:00
Merge pull request #13 from pawelmalak/snippet-tags
Added support for tags
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
### v1.2 (2021-09-28)
|
||||
- Added support for tags ([#10](https://github.com/pawelmalak/snippet-box/issues/10))
|
||||
|
||||
### v1.1 (2021-09-24)
|
||||
- Added pin icon directly to snippet card ([#4](https://github.com/pawelmalak/snippet-box/issues/4))
|
||||
- Fixed issue with copying snippets ([#6](https://github.com/pawelmalak/snippet-box/issues/6))
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -10,14 +10,8 @@ COPY . .
|
||||
|
||||
RUN mkdir -p ./public ./data
|
||||
|
||||
# Build server code
|
||||
RUN npm run build
|
||||
|
||||
# Build client code
|
||||
RUN cd ./client \
|
||||
&& npm install \
|
||||
&& npm run build \
|
||||
&& cd .. \
|
||||
# Build
|
||||
RUN npm run build \
|
||||
&& mv ./client/build/* ./public
|
||||
|
||||
# Clean up src files
|
||||
|
||||
@@ -12,14 +12,8 @@ COPY . .
|
||||
|
||||
RUN mkdir -p ./public ./data
|
||||
|
||||
# Build server code
|
||||
RUN npm run build
|
||||
|
||||
# Build client code
|
||||
RUN cd ./client \
|
||||
&& npm install \
|
||||
&& npm run build \
|
||||
&& cd .. \
|
||||
# Build
|
||||
RUN npm run build \
|
||||
&& mv ./client/build/* ./public
|
||||
|
||||
# Clean up src files
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
|
||||
import { SnippetsContext } from '../../store';
|
||||
import { Snippet } from '../../typescript/interfaces';
|
||||
import { dateParser } from '../../utils';
|
||||
import { Button, Card } from '../UI';
|
||||
import { Badge, Button, Card } from '../UI';
|
||||
import copy from 'clipboard-copy';
|
||||
import { SnippetPin } from './SnippetPin';
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SnippetDetails = (props: Props): JSX.Element => {
|
||||
const {
|
||||
title,
|
||||
language,
|
||||
tags,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
description,
|
||||
@@ -61,6 +62,16 @@ export const SnippetDetails = (props: Props): JSX.Element => {
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{/* TAGS */}
|
||||
<div>
|
||||
{tags.map((tag, idx) => (
|
||||
<span className='me-2' key={idx}>
|
||||
<Badge text={tag} color='dark' />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{/* ACTIONS */}
|
||||
<div className='d-grid g-2' style={{ rowGap: '10px' }}>
|
||||
<Button
|
||||
|
||||
@@ -25,7 +25,8 @@ export const SnippetForm = (props: Props): JSX.Element => {
|
||||
language: '',
|
||||
code: '',
|
||||
docs: '',
|
||||
isPinned: false
|
||||
isPinned: false,
|
||||
tags: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,6 +46,18 @@ export const SnippetForm = (props: Props): JSX.Element => {
|
||||
});
|
||||
};
|
||||
|
||||
const stringToTags = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const tags = e.target.value.split(',');
|
||||
setFormData({
|
||||
...formData,
|
||||
tags
|
||||
});
|
||||
};
|
||||
|
||||
const tagsToString = (): string => {
|
||||
return formData.tags.join(',');
|
||||
};
|
||||
|
||||
const formHandler = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -109,11 +122,31 @@ export const SnippetForm = (props: Props): JSX.Element => {
|
||||
id='language'
|
||||
name='language'
|
||||
value={formData.language}
|
||||
placeholder='bash'
|
||||
placeholder='python'
|
||||
required
|
||||
onChange={e => inputHandler(e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TAGS */}
|
||||
<div className='mb-3'>
|
||||
<label htmlFor='tags' className='form-label'>
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='form-control'
|
||||
id='tags'
|
||||
name='tags'
|
||||
value={tagsToString()}
|
||||
placeholder='automation, files, loop'
|
||||
onChange={e => stringToTags(e)}
|
||||
/>
|
||||
<div className='form-text'>
|
||||
Tags should be separated with a comma. Language tag will be
|
||||
added automatically
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{/* CODE SECTION */}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Card, EmptyState, Layout } from '../components/UI';
|
||||
import { Snippet } from '../typescript/interfaces';
|
||||
|
||||
export const Snippets = (): JSX.Element => {
|
||||
const { snippets, languageCount, getSnippets, countSnippets } =
|
||||
const { snippets, tagCount, getSnippets, countTags } =
|
||||
useContext(SnippetsContext);
|
||||
|
||||
const [filter, setFilter] = useState<string | null>(null);
|
||||
@@ -13,16 +13,16 @@ export const Snippets = (): JSX.Element => {
|
||||
|
||||
useEffect(() => {
|
||||
getSnippets();
|
||||
countSnippets();
|
||||
countTags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSnippets([...snippets]);
|
||||
}, [snippets]);
|
||||
|
||||
const filterHandler = (language: string) => {
|
||||
setFilter(language);
|
||||
const filteredSnippets = snippets.filter(s => s.language === language);
|
||||
const filterHandler = (tag: string) => {
|
||||
setFilter(tag);
|
||||
const filteredSnippets = snippets.filter(s => s.tags.includes(tag));
|
||||
setLocalSnippets(filteredSnippets);
|
||||
};
|
||||
|
||||
@@ -44,21 +44,21 @@ export const Snippets = (): JSX.Element => {
|
||||
<span>Total</span>
|
||||
<span>{snippets.length}</span>
|
||||
</div>
|
||||
<h5 className='card-title'>Filter by language</h5>
|
||||
<h5 className='card-title'>Filter by tags</h5>
|
||||
<Fragment>
|
||||
{languageCount.map((el, idx) => {
|
||||
const isActiveFilter = filter === el.language;
|
||||
{tagCount.map((tag, idx) => {
|
||||
const isActiveFilter = filter === tag.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`d-flex justify-content-between cursor-pointer ${
|
||||
isActiveFilter && 'text-dark fw-bold'
|
||||
}`}
|
||||
key={idx}
|
||||
onClick={() => filterHandler(el.language)}
|
||||
onClick={() => filterHandler(tag.name)}
|
||||
>
|
||||
<span>{el.language}</span>
|
||||
<span>{el.count}</span>
|
||||
<span>{tag.name}</span>
|
||||
<span>{tag.count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,14 +5,14 @@ import {
|
||||
Context,
|
||||
Snippet,
|
||||
Response,
|
||||
LanguageCount,
|
||||
TagCount,
|
||||
NewSnippet
|
||||
} from '../typescript/interfaces';
|
||||
|
||||
export const SnippetsContext = createContext<Context>({
|
||||
snippets: [],
|
||||
currentSnippet: null,
|
||||
languageCount: [],
|
||||
tagCount: [],
|
||||
getSnippets: () => {},
|
||||
getSnippetById: (id: number) => {},
|
||||
setSnippet: (id: number) => {},
|
||||
@@ -20,7 +20,7 @@ export const SnippetsContext = createContext<Context>({
|
||||
updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => {},
|
||||
deleteSnippet: (id: number) => {},
|
||||
toggleSnippetPin: (id: number) => {},
|
||||
countSnippets: () => {}
|
||||
countTags: () => {}
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -30,7 +30,7 @@ interface Props {
|
||||
export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [currentSnippet, setCurrentSnippet] = useState<Snippet | null>(null);
|
||||
const [languageCount, setLanguageCount] = useState<LanguageCount[]>([]);
|
||||
const [tagCount, setTagCount] = useState<TagCount[]>([]);
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
@@ -53,13 +53,13 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
||||
};
|
||||
|
||||
const setSnippet = (id: number): void => {
|
||||
getSnippetById(id);
|
||||
|
||||
if (id < 0) {
|
||||
setCurrentSnippet(null);
|
||||
return;
|
||||
}
|
||||
|
||||
getSnippetById(id);
|
||||
|
||||
const snippet = snippets.find(s => s.id === id);
|
||||
|
||||
if (snippet) {
|
||||
@@ -132,17 +132,17 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
const countSnippets = (): void => {
|
||||
const countTags = (): void => {
|
||||
axios
|
||||
.get<Response<LanguageCount[]>>('/api/snippets/statistics/count')
|
||||
.then(res => setLanguageCount(res.data.data))
|
||||
.get<Response<TagCount[]>>('/api/snippets/statistics/count')
|
||||
.then(res => setTagCount(res.data.data))
|
||||
.catch(err => redirectOnError());
|
||||
};
|
||||
|
||||
const context = {
|
||||
snippets,
|
||||
currentSnippet,
|
||||
languageCount,
|
||||
tagCount,
|
||||
getSnippets,
|
||||
getSnippetById,
|
||||
setSnippet,
|
||||
@@ -150,7 +150,7 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
toggleSnippetPin,
|
||||
countSnippets
|
||||
countTags
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LanguageCount, NewSnippet, Snippet } from '.';
|
||||
import { TagCount, NewSnippet, Snippet } from '.';
|
||||
|
||||
export interface Context {
|
||||
snippets: Snippet[];
|
||||
currentSnippet: Snippet | null;
|
||||
languageCount: LanguageCount[];
|
||||
tagCount: TagCount[];
|
||||
getSnippets: () => void;
|
||||
getSnippetById: (id: number) => void;
|
||||
setSnippet: (id: number) => void;
|
||||
@@ -11,5 +11,5 @@ export interface Context {
|
||||
updateSnippet: (snippet: NewSnippet, id: number, isLocal?: boolean) => void;
|
||||
deleteSnippet: (id: number) => void;
|
||||
toggleSnippetPin: (id: number) => void;
|
||||
countSnippets: () => void;
|
||||
countTags: () => void;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface NewSnippet {
|
||||
code: string;
|
||||
docs?: string;
|
||||
isPinned: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Snippet extends Model, NewSnippet {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface LanguageCount {
|
||||
export interface TagCount {
|
||||
count: number;
|
||||
language: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dev:client": "npm start --prefix=client",
|
||||
"dev:server": "nodemon",
|
||||
"dev": "npm-run-all -n --parallel dev:**",
|
||||
"build:client": "npm run build --prefix=client",
|
||||
"build:clear": "rm -rf build",
|
||||
"build:tsc": "tsc",
|
||||
"build": "npm-run-all -n build:**"
|
||||
|
||||
@@ -2,8 +2,17 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import { sequelize } from '../db';
|
||||
import { asyncWrapper } from '../middleware';
|
||||
import { SnippetModel } from '../models';
|
||||
import { ErrorResponse } from '../utils';
|
||||
import { SnippetModel, Snippet_TagModel } from '../models';
|
||||
import {
|
||||
ErrorResponse,
|
||||
getTags,
|
||||
tagParser,
|
||||
Logger,
|
||||
createTags
|
||||
} from '../utils';
|
||||
import { Body } from '../typescript/interfaces';
|
||||
|
||||
const logger = new Logger('snippets-controller');
|
||||
|
||||
/**
|
||||
* @description Create new snippet
|
||||
@@ -12,10 +21,30 @@ import { ErrorResponse } from '../utils';
|
||||
*/
|
||||
export const createSnippet = asyncWrapper(
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const snippet = await SnippetModel.create(req.body);
|
||||
// Get tags from request body
|
||||
const { language, tags: requestTags } = <Body>req.body;
|
||||
const parsedRequestTags = tagParser([
|
||||
...requestTags,
|
||||
language.toLowerCase()
|
||||
]);
|
||||
|
||||
// Create snippet
|
||||
const snippet = await SnippetModel.create({
|
||||
...req.body,
|
||||
tags: [...parsedRequestTags].join(',')
|
||||
});
|
||||
|
||||
// Create tags
|
||||
await createTags(parsedRequestTags, snippet.id);
|
||||
|
||||
// Get raw snippet values
|
||||
const rawSnippet = snippet.get({ plain: true });
|
||||
|
||||
res.status(201).json({
|
||||
data: snippet
|
||||
data: {
|
||||
...rawSnippet,
|
||||
tags: [...parsedRequestTags]
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -27,7 +56,22 @@ export const createSnippet = asyncWrapper(
|
||||
*/
|
||||
export const getAllSnippets = asyncWrapper(
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const snippets = await SnippetModel.findAll();
|
||||
const snippets = await SnippetModel.findAll({
|
||||
raw: true
|
||||
});
|
||||
|
||||
await new Promise<void>(async resolve => {
|
||||
try {
|
||||
for await (let snippet of snippets) {
|
||||
const tags = await getTags(+snippet.id);
|
||||
snippet.tags = tags;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('Error while fetching tags', 'ERROR');
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
data: snippets
|
||||
@@ -36,14 +80,15 @@ export const getAllSnippets = asyncWrapper(
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Get single sinppet by id
|
||||
* @description Get single snippet by id
|
||||
* @route /api/snippets/:id
|
||||
* @request GET
|
||||
*/
|
||||
export const getSnippet = asyncWrapper(
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const snippet = await SnippetModel.findOne({
|
||||
where: { id: req.params.id }
|
||||
where: { id: req.params.id },
|
||||
raw: true
|
||||
});
|
||||
|
||||
if (!snippet) {
|
||||
@@ -55,6 +100,9 @@ export const getSnippet = asyncWrapper(
|
||||
);
|
||||
}
|
||||
|
||||
const tags = await getTags(+req.params.id);
|
||||
snippet.tags = tags;
|
||||
|
||||
res.status(200).json({
|
||||
data: snippet
|
||||
});
|
||||
@@ -81,10 +129,28 @@ export const updateSnippet = asyncWrapper(
|
||||
);
|
||||
}
|
||||
|
||||
snippet = await snippet.update(req.body);
|
||||
// Get tags from request body
|
||||
const { language, tags: requestTags } = <Body>req.body;
|
||||
let parsedRequestTags = tagParser([...requestTags, language.toLowerCase()]);
|
||||
|
||||
// Update snippet
|
||||
snippet = await snippet.update({
|
||||
...req.body,
|
||||
tags: [...parsedRequestTags].join(',')
|
||||
});
|
||||
|
||||
// Delete old tags and create new ones
|
||||
await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } });
|
||||
await createTags(parsedRequestTags, snippet.id);
|
||||
|
||||
// Get raw snippet values
|
||||
const rawSnippet = snippet.get({ plain: true });
|
||||
|
||||
res.status(200).json({
|
||||
data: snippet
|
||||
data: {
|
||||
...rawSnippet,
|
||||
tags: [...parsedRequestTags]
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -109,6 +175,7 @@ export const deleteSnippet = asyncWrapper(
|
||||
);
|
||||
}
|
||||
|
||||
await Snippet_TagModel.destroy({ where: { snippet_id: req.params.id } });
|
||||
await snippet.destroy();
|
||||
|
||||
res.status(200).json({
|
||||
@@ -118,19 +185,20 @@ export const deleteSnippet = asyncWrapper(
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Count snippets by language
|
||||
* @description Count tags
|
||||
* @route /api/snippets/statistics/count
|
||||
* @request GET
|
||||
*/
|
||||
export const countSnippets = asyncWrapper(
|
||||
export const countTags = asyncWrapper(
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const result = await sequelize.query(
|
||||
`SELECT
|
||||
COUNT(language) AS count,
|
||||
language
|
||||
FROM snippets
|
||||
GROUP BY language
|
||||
ORDER BY language ASC`,
|
||||
COUNT(tags.name) as count,
|
||||
tags.name
|
||||
FROM snippets_tags
|
||||
INNER JOIN tags ON snippets_tags.tag_id = tags.id
|
||||
GROUP BY tags.name
|
||||
ORDER BY name ASC`,
|
||||
{
|
||||
type: QueryTypes.SELECT
|
||||
}
|
||||
|
||||
92
src/db/migrations/02_tags.ts
Normal file
92
src/db/migrations/02_tags.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Logger } from '../../utils';
|
||||
import { DataTypes, QueryInterface } from 'sequelize';
|
||||
import {
|
||||
SnippetModel,
|
||||
Snippet_TagModel,
|
||||
TagInstance,
|
||||
TagModel
|
||||
} from '../../models';
|
||||
|
||||
const { STRING, INTEGER } = DataTypes;
|
||||
const logger = new Logger('migration[02]');
|
||||
|
||||
export const up = async (queryInterface: QueryInterface): Promise<void> => {
|
||||
await queryInterface.createTable('tags', {
|
||||
id: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
}
|
||||
});
|
||||
|
||||
await queryInterface.createTable('snippets_tags', {
|
||||
id: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
snippet_id: {
|
||||
type: INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
tag_id: {
|
||||
type: INTEGER,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
// Create new tags from language column
|
||||
const snippets = await SnippetModel.findAll();
|
||||
const languages = snippets.map(snippet => snippet.language);
|
||||
const uniqueLanguages = [...new Set(languages)];
|
||||
const tags: TagInstance[] = [];
|
||||
|
||||
if (snippets.length > 0) {
|
||||
await new Promise<void>(resolve => {
|
||||
uniqueLanguages.forEach(async language => {
|
||||
try {
|
||||
const tag = await TagModel.create({ name: language });
|
||||
tags.push(tag);
|
||||
} catch (err) {
|
||||
logger.log('Error while creating new tags');
|
||||
} finally {
|
||||
if (uniqueLanguages.length == tags.length) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Assign tag to snippet
|
||||
await new Promise<void>(resolve => {
|
||||
snippets.forEach(async snippet => {
|
||||
try {
|
||||
const tag = tags.find(tag => tag.name == snippet.language);
|
||||
|
||||
if (tag) {
|
||||
await Snippet_TagModel.create({
|
||||
snippet_id: snippet.id,
|
||||
tag_id: tag.id
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.log('Error while assigning tags to snippets');
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const down = async (queryInterface: QueryInterface): Promise<void> => {
|
||||
await queryInterface.dropTable('tags');
|
||||
await queryInterface.dropTable('snippets_tags');
|
||||
};
|
||||
@@ -4,47 +4,53 @@ import { Snippet, SnippetCreationAttributes } from '../typescript/interfaces';
|
||||
|
||||
const { INTEGER, STRING, DATE, TEXT } = DataTypes;
|
||||
|
||||
interface SnippetInstance
|
||||
export interface SnippetInstance
|
||||
extends Model<Snippet, SnippetCreationAttributes>,
|
||||
Snippet {}
|
||||
|
||||
export const SnippetModel = sequelize.define<SnippetInstance>('Snippet', {
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
export const SnippetModel = sequelize.define<SnippetInstance>(
|
||||
'Snippet',
|
||||
{
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
title: {
|
||||
type: STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
language: {
|
||||
type: STRING,
|
||||
allowNull: false
|
||||
},
|
||||
code: {
|
||||
type: TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
docs: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
isPinned: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE
|
||||
},
|
||||
updatedAt: {
|
||||
type: DATE
|
||||
}
|
||||
},
|
||||
title: {
|
||||
type: STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
language: {
|
||||
type: STRING,
|
||||
allowNull: false
|
||||
},
|
||||
code: {
|
||||
type: TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
docs: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
isPinned: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE
|
||||
},
|
||||
updatedAt: {
|
||||
type: DATE
|
||||
{
|
||||
tableName: 'snippets'
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
35
src/models/Snippet_Tag.ts
Normal file
35
src/models/Snippet_Tag.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../db';
|
||||
import {
|
||||
Snippet_Tag,
|
||||
Snippet_TagCreationAttributes
|
||||
} from '../typescript/interfaces';
|
||||
|
||||
const { INTEGER } = DataTypes;
|
||||
|
||||
export interface Snippet_TagInstance
|
||||
extends Model<Snippet_Tag, Snippet_TagCreationAttributes>,
|
||||
Snippet_Tag {}
|
||||
|
||||
export const Snippet_TagModel = sequelize.define<Snippet_TagInstance>(
|
||||
'Snippet_Tag',
|
||||
{
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
snippet_id: {
|
||||
type: INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
tag_id: {
|
||||
type: INTEGER,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
tableName: 'snippets_tags'
|
||||
}
|
||||
);
|
||||
27
src/models/Tag.ts
Normal file
27
src/models/Tag.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../db';
|
||||
import { Tag, TagCreationAttributes } from '../typescript/interfaces';
|
||||
|
||||
const { INTEGER, STRING } = DataTypes;
|
||||
|
||||
export interface TagInstance extends Model<Tag, TagCreationAttributes>, Tag {}
|
||||
|
||||
export const TagModel = sequelize.define<TagInstance>(
|
||||
'Tag',
|
||||
{
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: false,
|
||||
tableName: 'tags'
|
||||
}
|
||||
);
|
||||
@@ -1 +1,3 @@
|
||||
export * from './Snippet';
|
||||
export * from './Tag';
|
||||
export * from './Snippet_Tag';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
countSnippets,
|
||||
countTags,
|
||||
createSnippet,
|
||||
deleteSnippet,
|
||||
getAllSnippets,
|
||||
@@ -22,4 +22,4 @@ snippetRouter
|
||||
.put(updateSnippet)
|
||||
.delete(deleteSnippet);
|
||||
|
||||
snippetRouter.route('/statistics/count').get(countSnippets);
|
||||
snippetRouter.route('/statistics/count').get(countTags);
|
||||
|
||||
9
src/typescript/interfaces/Body.ts
Normal file
9
src/typescript/interfaces/Body.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Body {
|
||||
title: string;
|
||||
description?: string;
|
||||
language: string;
|
||||
code: string;
|
||||
docs?: string;
|
||||
isPinned: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface Snippet extends Model {
|
||||
code: string;
|
||||
docs: string;
|
||||
isPinned: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SnippetCreationAttributes
|
||||
|
||||
10
src/typescript/interfaces/Snippet_Tag.ts
Normal file
10
src/typescript/interfaces/Snippet_Tag.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Optional } from 'sequelize';
|
||||
|
||||
export interface Snippet_Tag {
|
||||
id: number;
|
||||
snippet_id: number;
|
||||
tag_id: number;
|
||||
}
|
||||
|
||||
export interface Snippet_TagCreationAttributes
|
||||
extends Optional<Snippet_Tag, 'id'> {}
|
||||
8
src/typescript/interfaces/Tag.ts
Normal file
8
src/typescript/interfaces/Tag.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Optional } from 'sequelize';
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TagCreationAttributes extends Optional<Tag, 'id'> {}
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './Model';
|
||||
export * from './Snippet';
|
||||
export * from './Tag';
|
||||
export * from './Snippet_Tag';
|
||||
export * from './Body';
|
||||
|
||||
39
src/utils/createTags.ts
Normal file
39
src/utils/createTags.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { sequelize } from '../db';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import { TagModel, Snippet_TagModel } from '../models';
|
||||
|
||||
export const createTags = async (
|
||||
parsedTags: Set<string>,
|
||||
snippetId: number
|
||||
): Promise<void> => {
|
||||
// Get all tags
|
||||
const rawAllTags = await sequelize.query<{ id: number; name: string }>(
|
||||
`SELECT * FROM tags`,
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
const parsedAllTags = rawAllTags.map(tag => tag.name);
|
||||
|
||||
// Create array of new tags
|
||||
const newTags = [...parsedTags].filter(tag => !parsedAllTags.includes(tag));
|
||||
|
||||
// Create new tags
|
||||
if (newTags.length > 0) {
|
||||
for (const tag of newTags) {
|
||||
const { id, name } = await TagModel.create({ name: tag });
|
||||
rawAllTags.push({ id, name });
|
||||
}
|
||||
}
|
||||
|
||||
// Associate tags with snippet
|
||||
for (const tag of parsedTags) {
|
||||
const tagObj = rawAllTags.find(t => t.name == tag);
|
||||
|
||||
if (tagObj) {
|
||||
await Snippet_TagModel.create({
|
||||
snippet_id: snippetId,
|
||||
tag_id: tagObj.id
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
18
src/utils/getTags.ts
Normal file
18
src/utils/getTags.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { sequelize } from '../db';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
|
||||
export const getTags = async (snippetId: number): Promise<string[]> => {
|
||||
const tags = await sequelize.query<{ name: string }>(
|
||||
`SELECT tags.name
|
||||
FROM tags
|
||||
INNER JOIN
|
||||
snippets_tags ON tags.id = snippets_tags.tag_id
|
||||
INNER JOIN
|
||||
snippets ON snippets.id = snippets_tags.snippet_id
|
||||
WHERE
|
||||
snippets_tags.snippet_id = ${snippetId};`,
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
return tags.map(tag => tag.name);
|
||||
};
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './Logger';
|
||||
export * from './ErrorResponse';
|
||||
export * from './tagParser';
|
||||
export * from './getTags';
|
||||
export * from './createTags';
|
||||
|
||||
6
src/utils/tagParser.ts
Normal file
6
src/utils/tagParser.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const tagParser = (tags: string[]): Set<string> => {
|
||||
const parsedTags = tags.map(tag => tag.trim().toLowerCase()).filter(String);
|
||||
const uniqueTags = new Set([...parsedTags]);
|
||||
|
||||
return uniqueTags;
|
||||
};
|
||||
Reference in New Issue
Block a user