Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 99 additions & 56 deletions src/components/molecules/TagListFragment.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { useMemo } from 'react'
import React, { useMemo, useCallback } from 'react'
import SideNavigatorItem from '../atoms/NavigatorItem'
import NavigatorButton from '../atoms/NavigatorButton'
import { NoteStorage } from '../../lib/db/types'
import { isTagNameValid, keys } from '../../lib/db/utils'
import { useGeneralStatus } from '../../lib/generalStatus'
import { getTagListItemId } from '../../lib/nav'
import { useRouter } from '../../lib/router'
import { usePathnameWithoutNoteId } from '../../lib/routeParams'
import { useDialog, DialogIconTypes } from '../../lib/dialog'
import { useDb } from '../../lib/db'
import { useTranslation } from 'react-i18next'
import { mdiPound, mdiTagMultiple } from '@mdi/js'
import { mdiPound, mdiTagMultiple, mdiDotsVertical } from '@mdi/js'
import { openContextMenu } from '../../lib/electronOnly'
import { useAnalytics, analyticsEvents } from '../../lib/analytics'

Expand All @@ -19,70 +21,22 @@ interface TagListFragmentProps {
const TagListFragment = ({ storage }: TagListFragmentProps) => {
const { toggleSideNavOpenedItem, sideNavOpenedItemSet } = useGeneralStatus()
const { id: storageId, tagMap } = storage
const { push } = useRouter()
const { messageBox } = useDialog()
const { removeTag } = useDb()
const { t } = useTranslation()
const currentPathname = usePathnameWithoutNoteId()
const { report } = useAnalytics()

const tagListNavItemId = getTagListItemId(storage.id)
const tagListIsFolded = !sideNavOpenedItemSet.has(tagListNavItemId)

const tagList = useMemo(() => {
return Object.keys(tagMap).map((tagName) => {
const tagPathname = `/app/storages/${storageId}/tags/${tagName}`
const tagIsActive = currentPathname === tagPathname
return keys(tagMap).map((tagName) => {
return (
<SideNavigatorItem
key={`storage:${storageId}/tags:${tagName}`}
depth={1}
iconPath={mdiPound}
label={tagName}
onClick={() => {
push(tagPathname)
}}
active={tagIsActive}
onContextMenu={(event) => {
event.preventDefault()
openContextMenu({
menuItems: [
{
type: 'normal',
label: t('tag.remove'),
click: () => {
messageBox({
title: `Remove "${tagName}" tag`,
message: t('tag.removeMessage'),
iconType: DialogIconTypes.Warning,
buttons: [t('tag.remove'), t('general.cancel')],
defaultButtonIndex: 0,
cancelButtonIndex: 1,
onClose: (value: number | null) => {
if (value === 0) {
removeTag(storageId, tagName)
report(analyticsEvents.removeTag)
}
},
})
},
},
],
})
}}
<TagListItem
key={`tag:${tagName}`}
storageId={storageId}
tagName={tagName}
/>
)
})
}, [
storageId,
tagMap,
push,
currentPathname,
messageBox,
removeTag,
t,
report,
])
}, [storageId, tagMap])

if (tagList.length === 0) {
return null
Expand Down Expand Up @@ -111,3 +65,92 @@ const TagListFragment = ({ storage }: TagListFragmentProps) => {
}

export default TagListFragment

interface TagListItemProps {
storageId: string
tagName: string
}

const TagListItem = ({ tagName, storageId }: TagListItemProps) => {
const { push } = useRouter()
const { prompt, messageBox } = useDialog()
const { removeTag, renameTag } = useDb()
const { t } = useTranslation()
const currentPathname = usePathnameWithoutNoteId()
const { report } = useAnalytics()
const openTagContextMenu = useCallback(
(event: React.MouseEvent<Element, MouseEvent>) => {
event.preventDefault()
openContextMenu({
menuItems: [
{
type: 'normal',
label: t('tag.rename'),
click: () => {
prompt({
title: `tag.rename`,
message: t('tag.renameMessage', { tagName }),
iconType: DialogIconTypes.Question,
defaultValue: tagName,
submitButtonLabel: t('tag.rename'),
onClose: (value: string | null) => {
if (
value == null ||
!isTagNameValid(value) ||
value == tagName
)
return
renameTag(storageId, tagName, value)
report(analyticsEvents.renameTag)
},
})
},
},
{ type: 'separator' },
{
type: 'normal',
label: t('tag.remove'),
click: () => {
messageBox({
title: `Remove "${tagName}" tag`,
message: t('tag.removeMessage'),
iconType: DialogIconTypes.Warning,
buttons: [t('tag.remove'), t('general.cancel')],
defaultButtonIndex: 0,
cancelButtonIndex: 1,
onClose: (value: number | null) => {
if (value === 0) {
removeTag(storageId, tagName)
report(analyticsEvents.removeTag)
}
},
})
},
},
],
})
},
[messageBox, prompt, removeTag, renameTag, report, storageId, t, tagName]
)
const tagPathname = `/app/storages/${storageId}/tags/${tagName}`
const tagIsActive = currentPathname === tagPathname
return (
<SideNavigatorItem
key={`storage:${storageId}/tags:${tagName}`}
depth={1}
iconPath={mdiPound}
label={tagName}
onClick={() => {
push(tagPathname)
}}
active={tagIsActive}
onContextMenu={openTagContextMenu}
control={
<NavigatorButton
iconPath={mdiDotsVertical}
onClick={openTagContextMenu}
/>
}
/>
)
}
1 change: 1 addition & 0 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,5 @@ export const analyticsEvents = {
removeNoteTag: 'notes.tags.destroy',
addTag: 'tags.create',
removeTag: 'tags.destroy',
renameTag: 'tags.rename',
}
18 changes: 18 additions & 0 deletions src/lib/db/FSNoteDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,24 @@ class FSNoteDb implements NoteDb {
await this.saveBoostNoteJSON()
}

async renameTag(currentTagName: string, newTagName: string): Promise<void> {
const notes = await this.loadAllNotes()
const notesWithTags = notes.filter((note) => {
return note.tags.indexOf(currentTagName) > -1
})
for (const note of notesWithTags) {
await this.updateNote(note._id, {
...note,
tags: note.tags.flatMap((tag) => tag === currentTagName ? [newTagName] : [tag]),
})
}

this.data!.tagMap[newTagName] = this.data?.tagMap[currentTagName]
delete this.data?.tagMap[currentTagName]

await this.saveBoostNoteJSON()
}

async renameFolder(pathname: string, newPathname: string) {
if (!isFolderPathnameValid(pathname)) {
throw createUnprocessableEntityError(
Expand Down
1 change: 1 addition & 0 deletions src/lib/db/NoteDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default interface NoteDb {
unbookmarkNote(noteId: string): Promise<NoteDoc>
purgeNote(noteId: string): Promise<void>
removeTag(tagName: string): Promise<void>
renameTag(currentTagName: string, newTagName: string): Promise<void>
removeFolder(folerPathname: string): Promise<void>
renameFolder(
pathname: string,
Expand Down
34 changes: 34 additions & 0 deletions src/lib/db/PouchNoteDb.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,40 @@ describe('PouchNoteDb', () => {
})
})

describe('renameTag', () => {
it('removes previous tag name and updates notes with the tag name', async () => {
// Given
const noteDb = await prepareNoteDb()
await noteDb.init()
const note1 = await noteDb.createNote({
title: 'test title1',
content: 'test content1',
folderPathname: '/',
tags: ['tag1'],
})
const note2 = await noteDb.createNote({
title: 'test title2',
content: 'test content2',
folderPathname: '/',
tags: ['tag1', 'tag2'],
})

// When
await noteDb.renameTag('tag1', 'tag3')

// Then
const storedNote1 = await noteDb.getNote(note1._id)
expect(storedNote1).toMatchObject({
tags: ['tag3'],
})

const storedNote2 = await noteDb.getNote(note2._id)
expect(storedNote2).toMatchObject({
tags: ['tag3', 'tag2'],
})
})
})

describe('removeFolder', () => {
it('removes a folder and its notes', async () => {
// Given
Expand Down
25 changes: 25 additions & 0 deletions src/lib/db/PouchNoteDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,31 @@ export default class PouchNoteDb implements NoteDb {
}
}

async renameTag(currentTagName: string, newTagName: string): Promise<void> {
const currentTag = await this.getTag(currentTagName)
if (currentTag == null)
return
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ return }


const renamedTagProps = {
data: currentTag.data,
createdAt: currentTag.createdAt,
updatedAt: getNow(),
}

await this.upsertTag(newTagName, renamedTagProps)

const notes = await this.findNotesByTag(currentTagName)
await Promise.all(
notes.map((note) => {
return this.updateNote(note._id, {
tags: note.tags.flatMap((tag) => tag === currentTagName ? [newTagName] : [tag]),
})
})
)

await this.pouchDb.remove(currentTag as any)
}

async removeFolder(folderPathname: string): Promise<void> {
const foldersToDelete = await this.getAllFolderUnderPathname(folderPathname)

Expand Down
59 changes: 59 additions & 0 deletions src/lib/db/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface DbStore {
): Promise<NoteDoc | undefined>
purgeNote(storageId: string, noteId: string): Promise<void>
removeTag(storageId: string, tag: string): Promise<void>
renameTag(storageId: string, currentTagName: string, newTagName: string): Promise<void>
moveNoteToOtherStorage(
originalStorageId: string,
noteId: string,
Expand Down Expand Up @@ -1215,6 +1216,63 @@ export function createDbStoreCreator(
]
)

const renameTag = useCallback(
async (storageId: string, currentTagName: string, newTagName: string) => {
const storage = storageMap[storageId]
if (storage == null) {
return
}

await storage.db.renameTag(currentTagName, newTagName)

router.replace(`/app/storages/${storageId}/tags/${newTagName}`)

const modifiedNotes: ObjectMap<NoteDoc> = Object.keys(
storageMap[storageId]!.noteMap
).reduce((acc, noteId) => {
if (storageMap[storageId]!.noteMap[noteId]!.tags.includes(currentTagName)) {
acc[noteId] = {
...storageMap[storageId]!.noteMap[noteId]!,
tags: storageMap[storageId]!.noteMap[noteId]!.tags.flatMap(
(tag) => tag === currentTagName ? [newTagName] : [tag]),
}
}
return acc
}, {})

const currentTagMap = storageMap[storageId]!.tagMap
const updatedTagMap = {
...currentTagMap,
[newTagName]: {
...currentTagMap[currentTagName],
_id: TAG_ID_PREFIX + newTagName,
name: newTagName,
} as PopulatedTagDoc,
}
delete updatedTagMap[currentTagName]

setStorageMap(
produce((draft: ObjectMap<NoteStorage>) => {
draft[storageId]!.noteMap = {
...draft[storageId]!.noteMap,
...modifiedNotes,
}
draft[storageId]!.tagMap = updatedTagMap
})
)
queueSyncingStorage(storageId, autoSyncDebounceWaitingTime)

return
},
[
storageMap,
currentPathnameWithoutNoteId,
setStorageMap,
queueSyncingStorage,
router,
]
)

const addAttachments = useCallback(
async (storageId: string, files: File[]): Promise<Attachment[]> => {
const storage = storageMapRef.current[storageId]
Expand Down Expand Up @@ -1337,6 +1395,7 @@ export function createDbStoreCreator(
purgeNote,
moveNoteToOtherStorage,
removeTag,
renameTag,
addAttachments,
removeAttachment,
bookmarkNote,
Expand Down
Loading