Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69891e395c | ||
|
|
a4ae22c445 |
@@ -143,6 +143,15 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
|
|||||||
return toNodeDTOs(list), nil
|
return toNodeDTOs(list), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTodayView returns nodes created or updated today — a dynamic day view.
|
||||||
|
func (a *App) ListTodayView() ([]NodeDTO, error) {
|
||||||
|
list, err := a.nodes.ListTodayNodes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toNodeDTOs(list), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||||
list, err := a.nodes.ListChildren(parentID, false)
|
list, err := a.nodes.ListChildren(parentID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,6 +170,9 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
|
||||||
|
if section == "today" || section == "inbox" {
|
||||||
|
return nil, fmt.Errorf("cannot create node with section %q", section)
|
||||||
|
}
|
||||||
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
n, err := a.nodes.Create(parentID, nodeType, title, section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -315,7 +327,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||||||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
return a.nodes.UpdateTitle(nodeID, newTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ValidateName(name string) error {
|
||||||
|
return files.ValidateName(name)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
|
// Check for name conflict at destination
|
||||||
|
destChildren, err := a.nodes.ListChildren(newParentID, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
node, err := a.nodes.GetActive(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range destChildren {
|
||||||
|
if destChildren[i].Title == node.Title {
|
||||||
|
// Conflict: auto-rename
|
||||||
|
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
|
||||||
|
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return a.nodes.Move(nodeID, newParentID, 0)
|
return a.nodes.Move(nodeID, newParentID, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
|||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
<script type="module" crossorigin src="/assets/main-BY9JF_6I.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-Bo58X7Pc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+149
-22
@@ -72,6 +72,11 @@
|
|||||||
let confirmAction = null
|
let confirmAction = null
|
||||||
let cancelAction = null
|
let cancelAction = null
|
||||||
|
|
||||||
|
let showRename = false
|
||||||
|
let renameId = ''
|
||||||
|
let renameValue = ''
|
||||||
|
let renameError = ''
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: 'Обзор' },
|
{ id: 'overview', label: 'Обзор' },
|
||||||
{ id: 'notes', label: 'Заметки' },
|
{ id: 'notes', label: 'Заметки' },
|
||||||
@@ -130,7 +135,11 @@
|
|||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
nodes = await wailsCall('ListNodesBySection', id) || []
|
if (id === 'today') {
|
||||||
|
nodes = await wailsCall('ListTodayView') || []
|
||||||
|
} else {
|
||||||
|
nodes = await wailsCall('ListNodesBySection', id) || []
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e)
|
error = String(e)
|
||||||
nodes = []
|
nodes = []
|
||||||
@@ -282,19 +291,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renameItem(id) {
|
function renameItem(id) {
|
||||||
const name = prompt('Новое имя:')
|
const item = fileItems.find(x => x.id === id)
|
||||||
if (!name || !name.trim()) return
|
if (item) openRename(item.id, item.name)
|
||||||
renameSubmit(id, name.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameSubmit(id, newName) {
|
|
||||||
try {
|
|
||||||
await wailsCall('RenameNode', id, newName)
|
|
||||||
const parentId = currentFolderId || selectedNode.id
|
|
||||||
await loadFolder(parentId)
|
|
||||||
} catch (e) {
|
|
||||||
error = String(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cutItem(id) {
|
function cutItem(id) {
|
||||||
@@ -343,6 +341,20 @@
|
|||||||
selectedIds = fileItems.map(x => x.id)
|
selectedIds = fileItems.map(x => x.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangeSelect(id) {
|
||||||
|
if (fileItems.length === 0) return
|
||||||
|
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||||
|
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||||
|
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||||
|
if (lastIdx === -1 || curIdx === -1) return
|
||||||
|
const start = Math.min(lastIdx, curIdx)
|
||||||
|
const end = Math.max(lastIdx, curIdx)
|
||||||
|
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||||
|
const set = new Set(selectedIds)
|
||||||
|
range.forEach(x => set.add(x))
|
||||||
|
selectedIds = [...set]
|
||||||
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedIds = []
|
selectedIds = []
|
||||||
}
|
}
|
||||||
@@ -421,15 +433,106 @@
|
|||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||||
|
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
openSelected()
|
||||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||||
|
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||||
|
// Backspace without selection → navigate up
|
||||||
|
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
if (previewItem) { closePreview(); return }
|
||||||
|
if (selectedIds.length > 0) { clearSelection(); return }
|
||||||
|
} else if (e.key === 'F2') {
|
||||||
|
e.preventDefault()
|
||||||
|
openRenameForSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSelected() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item) {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
navigateToFolder(item.id)
|
||||||
|
} else {
|
||||||
|
openPreview(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelectedExternal() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item && item.fileId) {
|
||||||
|
wailsCall('OpenFile', item.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Rename modal =====
|
||||||
|
|
||||||
|
function openRename(id, currentName) {
|
||||||
|
renameId = id
|
||||||
|
renameValue = currentName
|
||||||
|
renameError = ''
|
||||||
|
showRename = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenameForSelection() {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||||
|
if (item) {
|
||||||
|
openRename(item.id, item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRename() {
|
||||||
|
const name = renameValue.trim()
|
||||||
|
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||||
|
// Validate name via backend
|
||||||
|
try {
|
||||||
|
await wailsCall('ValidateName', name)
|
||||||
|
} catch (e) {
|
||||||
|
renameError = 'Недопустимое имя'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showRename = false
|
||||||
|
renameId = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('RenameNode', renameId, name)
|
||||||
|
const parentId = currentFolderId || selectedNode.id
|
||||||
|
await loadFolder(parentId)
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
showRename = false
|
||||||
|
renameId = ''
|
||||||
|
renameValue = ''
|
||||||
|
renameError = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenameKeydown(e) {
|
||||||
|
if (e.key === 'Enter') submitRename()
|
||||||
|
else renameError = ''
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Confirm modal =====
|
// ===== Confirm modal =====
|
||||||
|
|
||||||
function openConfirm(opts) {
|
function openConfirm(opts) {
|
||||||
@@ -832,7 +935,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if folderStack.length > 0}
|
{#if folderStack.length > 0}
|
||||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||||
const i = e.detail
|
const i = e.detail
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
folderStack = []
|
folderStack = []
|
||||||
@@ -847,7 +950,7 @@
|
|||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fileItems.length === 0}
|
{#if fileItems.length === 0}
|
||||||
@@ -877,6 +980,7 @@
|
|||||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||||
on:preview={(e) => openPreview(e.detail)}
|
on:preview={(e) => openPreview(e.detail)}
|
||||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||||
|
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||||
on:delete={(e) => deleteFile(e.detail)}
|
on:delete={(e) => deleteFile(e.detail)}
|
||||||
on:rename={(e) => renameItem(e.detail.id)}
|
on:rename={(e) => renameItem(e.detail.id)}
|
||||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||||
@@ -884,6 +988,7 @@
|
|||||||
on:copy={(e) => copyItem(e.detail)}
|
on:copy={(e) => copyItem(e.detail)}
|
||||||
on:selectOne={(e) => selectOne(e.detail)}
|
on:selectOne={(e) => selectOne(e.detail)}
|
||||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||||
|
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -954,7 +1059,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !noteEditor && !selectedNode}
|
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox'}
|
||||||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1007,6 +1112,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showRename}
|
||||||
|
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Переименовать</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Новое имя</label>
|
||||||
|
<input type="text" bind:value={renameValue}
|
||||||
|
on:keydown={onRenameKeydown} />
|
||||||
|
</div>
|
||||||
|
{#if renameError}
|
||||||
|
<div class="rename-error">{renameError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||||
|
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showConfirm}
|
{#if showConfirm}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title={confirmTitle}
|
title={confirmTitle}
|
||||||
@@ -1159,4 +1284,6 @@
|
|||||||
.import-summary { margin-bottom: 16px; }
|
.import-summary { margin-bottom: 16px; }
|
||||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||||
|
|
||||||
|
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
import FileIcon from './lib/FileIcon.svelte'
|
import FileIcon from './lib/FileIcon.svelte'
|
||||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||||
|
|
||||||
export let item
|
export let item
|
||||||
export let selected = false
|
export let selected = false
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
const kind = getFileKind(item)
|
const kind = getFileKind(item)
|
||||||
const isFolder = item.type === 'folder'
|
const isFolder = item.type === 'folder'
|
||||||
|
const fileType = formatFileType(item)
|
||||||
let menuOpen = false
|
let menuOpen = false
|
||||||
let clickTimer = null
|
let clickTimer = null
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
if (clickTimer) {
|
if (clickTimer) {
|
||||||
clearTimeout(clickTimer)
|
clearTimeout(clickTimer)
|
||||||
clickTimer = null
|
clickTimer = null
|
||||||
// Double click
|
// Double click → открыть
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
dispatch('navigate', item.id)
|
dispatch('navigate', item.id)
|
||||||
} else {
|
} else {
|
||||||
@@ -34,37 +35,23 @@
|
|||||||
} else {
|
} else {
|
||||||
clickTimer = setTimeout(() => {
|
clickTimer = setTimeout(() => {
|
||||||
clickTimer = null
|
clickTimer = null
|
||||||
// Single click: select
|
// Single click → выделить
|
||||||
if (selected) {
|
dispatch('selectOne', item.id)
|
||||||
// Already selected: navigate/preview
|
|
||||||
if (isFolder) {
|
|
||||||
dispatch('navigate', item.id)
|
|
||||||
} else {
|
|
||||||
dispatch('preview', item)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dispatch('selectOne', item.id)
|
|
||||||
}
|
|
||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (isFolder) {
|
|
||||||
dispatch('navigate', item.id)
|
|
||||||
} else {
|
|
||||||
dispatch('preview', item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenExternal() {
|
function handleOpenExternal() {
|
||||||
dispatch('openExternal', item.fileId)
|
dispatch('openExternal', item.fileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleShowInFolder() {
|
||||||
|
dispatch('showInFolder', item.id)
|
||||||
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
|
menuOpen = false
|
||||||
dispatch('delete', { id: item.id, type: item.type })
|
dispatch('delete', { id: item.id, type: item.type })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +75,15 @@
|
|||||||
dispatch('copy', item.id)
|
dispatch('copy', item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
menuOpen = false
|
||||||
|
if (isFolder) {
|
||||||
|
dispatch('navigate', item.id)
|
||||||
|
} else {
|
||||||
|
dispatch('preview', item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
menuOpen = !menuOpen
|
menuOpen = !menuOpen
|
||||||
}
|
}
|
||||||
@@ -107,48 +103,48 @@
|
|||||||
function handleDrop(e) {
|
function handleDrop(e) {
|
||||||
if (onDrop && isFolder) onDrop(e, item.id)
|
if (onDrop && isFolder) onDrop(e, item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oncontextmenu(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
menuOpen = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeMenu}/>
|
<svelte:window on:click={closeMenu}/>
|
||||||
|
|
||||||
<div class="file-row"
|
<div class="file-row"
|
||||||
class:file-row--selected={selected}
|
class:file-row--selected={selected}
|
||||||
class:file-row--dragover={false}
|
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
on:click={handleClick}
|
on:click={handleClick}
|
||||||
on:keydown={handleKeydown}
|
on:contextmenu={oncontextmenu}
|
||||||
on:dragstart={handleDragStart}
|
on:dragstart={handleDragStart}
|
||||||
on:dragover={handleDragOver}
|
on:dragover={handleDragOver}
|
||||||
on:drop={handleDrop}
|
on:drop={handleDrop}
|
||||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||||
<div class="file-row-icon">
|
<div class="file-row-icon">
|
||||||
<FileIcon {kind} size={22}/>
|
<FileIcon {kind} size={22}/>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-row-body">
|
<div class="file-row-body">
|
||||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||||
<div class="file-row-meta">
|
<div class="file-row-meta">
|
||||||
{#if isFolder}
|
<span>{fileType}</span>
|
||||||
<span>Folder</span>
|
{#if !isFolder}
|
||||||
{:else}
|
<span class="meta-sep">·</span>
|
||||||
<span>{formatFileSize(item.size)}</span>
|
<span>{formatFileSize(item.size)}</span>
|
||||||
{#if item.mime}
|
|
||||||
<span class="meta-sep">·</span>
|
|
||||||
<span>{formatMimeType(item.mime)}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-row-actions">
|
<div class="file-row-actions">
|
||||||
{#if !isFolder}
|
{#if !isFolder}
|
||||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
@@ -156,21 +152,21 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<circle cx="12" cy="5" r="2"/>
|
<circle cx="12" cy="5" r="2"/>
|
||||||
<circle cx="12" cy="12" r="2"/>
|
<circle cx="12" cy="12" r="2"/>
|
||||||
<circle cx="12" cy="19" r="2"/>
|
<circle cx="12" cy="19" r="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
@@ -183,10 +179,42 @@
|
|||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||||
<div class="menu" on:click|stopPropagation role="menu">
|
<div class="menu" on:click|stopPropagation role="menu">
|
||||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
Открыть
|
||||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
</button>
|
||||||
|
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
Открыть во внешней программе
|
||||||
|
</button>
|
||||||
|
{#if isFolder}
|
||||||
|
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
Показать в проводнике
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
Переименовать
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
Дублировать
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||||
|
Вырезать
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
<div class="menu-sep"></div>
|
||||||
|
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -201,6 +229,7 @@
|
|||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row:hover {
|
.file-row:hover {
|
||||||
@@ -314,17 +343,19 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: #1a1a28;
|
background: #1a1a28;
|
||||||
border: 1px solid #2a2a3c;
|
border: 1px solid #2a2a3c;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
min-width: 140px;
|
min-width: 220px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 12px;
|
padding: 7px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
@@ -337,10 +368,25 @@
|
|||||||
|
|
||||||
.menu-item:hover {
|
.menu-item:hover {
|
||||||
background: #2a2a3c;
|
background: #2a2a3c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-danger:hover {
|
||||||
|
background: #3a2222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:focus-visible {
|
.menu-item:focus-visible {
|
||||||
outline: 2px solid #5588ff;
|
outline: 2px solid #5588ff;
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: #2a2a3c;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
|
|||||||
|
|
||||||
// Re-export all methods
|
// Re-export all methods
|
||||||
export const listSections = () => App.ListSections()
|
export const listSections = () => App.ListSections()
|
||||||
|
export const listTodayView = () => App.ListTodayView()
|
||||||
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||||
export const listChildren = (parentID) => App.ListChildren(parentID)
|
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||||
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||||
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
|||||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||||
|
export const validateName = (name) => App.ValidateName(name)
|
||||||
|
|
||||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||||
export const runAction = (id) => App.RunAction(id)
|
export const runAction = (id) => App.RunAction(id)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
@@ -60,11 +60,11 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="preview-body">
|
<div class="preview-body">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="preview-status"><p>Loading preview...</p></div>
|
<div class="preview-status"><p>Загрузка...</p></div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if showImage && content}
|
{:else if showImage && content}
|
||||||
<div class="preview-image-container">
|
<div class="preview-image-container">
|
||||||
@@ -79,14 +79,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>PDF preview is not available in this environment.</p>
|
<p>Предпросмотр PDF недоступен.</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="preview-status">
|
<div class="preview-status">
|
||||||
<p>Preview is not available for this file type.</p>
|
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mimeLabels = {
|
const mimeLabels = {
|
||||||
'image/jpeg': 'JPEG image',
|
'image/jpeg': 'Изображение JPEG',
|
||||||
'image/png': 'PNG image',
|
'image/png': 'Изображение PNG',
|
||||||
'image/gif': 'GIF image',
|
'image/gif': 'Изображение GIF',
|
||||||
'image/webp': 'WebP image',
|
'image/webp': 'Изображение WebP',
|
||||||
'image/svg+xml': 'SVG image',
|
'image/svg+xml': 'Изображение SVG',
|
||||||
'image/bmp': 'BMP image',
|
'image/bmp': 'Изображение BMP',
|
||||||
'image/tiff': 'TIFF image',
|
'image/tiff': 'Изображение TIFF',
|
||||||
'image/avif': 'AVIF image',
|
'image/avif': 'Изображение AVIF',
|
||||||
'application/pdf': 'PDF document',
|
'application/pdf': 'PDF документ',
|
||||||
'application/msword': 'Word document',
|
'application/msword': 'Документ Word',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
'application/vnd.ms-excel': 'Таблица Excel',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||||
'application/zip': 'ZIP archive',
|
'application/zip': 'ZIP архив',
|
||||||
'application/gzip': 'GZIP archive',
|
'application/gzip': 'GZIP архив',
|
||||||
'application/x-tar': 'TAR archive',
|
'application/x-tar': 'TAR архив',
|
||||||
'application/x-7z-compressed': '7z archive',
|
'application/x-7z-compressed': '7z архив',
|
||||||
'application/x-rar-compressed': 'RAR archive',
|
'application/x-rar-compressed': 'RAR архив',
|
||||||
'text/plain': 'Text file',
|
'text/plain': 'Текстовый файл',
|
||||||
'text/html': 'HTML file',
|
'text/html': 'HTML файл',
|
||||||
'text/css': 'CSS file',
|
'text/css': 'CSS файл',
|
||||||
'text/javascript': 'JavaScript file',
|
'text/javascript': 'JavaScript файл',
|
||||||
'application/json': 'JSON file',
|
'application/json': 'JSON файл',
|
||||||
'application/xml': 'XML file',
|
'application/xml': 'XML файл',
|
||||||
'application/x-yaml': 'YAML file',
|
'application/x-yaml': 'YAML файл',
|
||||||
'application/octet-stream': 'Binary file',
|
'application/octet-stream': 'Бинарный файл',
|
||||||
'application/x-msdos-program': 'Executable',
|
'application/x-msdos-program': 'Исполняемый файл',
|
||||||
'inode/directory': 'Folder',
|
'inode/directory': 'Папка',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMimeType(mime) {
|
export function formatMimeType(mime) {
|
||||||
if (!mime) return 'Unknown'
|
if (!mime) return 'Неизвестно'
|
||||||
return mimeLabels[mime] || mime
|
return mimeLabels[mime] || mime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatFileType(item) {
|
||||||
|
if (item.type === 'folder') return 'Папка'
|
||||||
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||||
|
const name = (item.name || '').toLowerCase()
|
||||||
|
const ext = name.split('.').pop()
|
||||||
|
if (ext) return ext.toUpperCase()
|
||||||
|
return 'Файл'
|
||||||
|
}
|
||||||
|
|
||||||
export function getFileKind(item) {
|
export function getFileKind(item) {
|
||||||
if (item.type === 'folder') return 'folder'
|
if (item.type === 'folder') return 'folder'
|
||||||
const mime = (item.mime || '').toLowerCase()
|
const mime = (item.mime || '').toLowerCase()
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export function ListSections() {
|
|||||||
return window['go']['main']['App']['ListSections']();
|
return window['go']['main']['App']['ListSections']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListTodayView() {
|
||||||
|
return window['go']['main']['App']['ListTodayView']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListNodesBySection(arg1) {
|
export function ListNodesBySection(arg1) {
|
||||||
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-18
@@ -60,6 +60,59 @@ func (s *Service) DB() *storage.DB {
|
|||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- security helpers ---
|
||||||
|
|
||||||
|
// ValidateName is an exported wrapper for validateName.
|
||||||
|
func ValidateName(name string) error {
|
||||||
|
return validateName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateName rejects filenames with path separators, relative components,
|
||||||
|
// and other dangerous patterns.
|
||||||
|
func validateName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("name is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||||
|
return fmt.Errorf("name must not contain path separators")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
return fmt.Errorf("name must not contain '..'")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "\x00") {
|
||||||
|
return fmt.Errorf("name must not contain null bytes")
|
||||||
|
}
|
||||||
|
if len(name) > 255 {
|
||||||
|
return fmt.Errorf("name too long (max 255)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||||
|
func (s *Service) vaultPath(rel string) (string, error) {
|
||||||
|
abs := filepath.Join(s.vaultRoot, rel)
|
||||||
|
cleaned := filepath.Clean(abs)
|
||||||
|
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||||
|
return "", fmt.Errorf("path escapes vault root")
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// absPathSafe resolves an absolute path and checks jail if it's under vault.
|
||||||
|
// For "external" mode records the path stored may be an absolute external path.
|
||||||
|
// This function only checks path safety — it does not enforce that external
|
||||||
|
// files must be inside the vault.
|
||||||
|
func (s *Service) absPathSafe(rec *Record) (string, error) {
|
||||||
|
if rec.StorageMode == "vault" {
|
||||||
|
return s.vaultPath(rec.Path)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(rec.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("abs: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- public operations ---
|
// --- public operations ---
|
||||||
|
|
||||||
// AddExternal registers an external file (absolute path) without copying.
|
// AddExternal registers an external file (absolute path) without copying.
|
||||||
@@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||||
|
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||||
return nil, fmt.Errorf("mkdir: %w", err)
|
return nil, fmt.Errorf("mkdir: %w", err)
|
||||||
}
|
}
|
||||||
@@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||||
|
if _, err := s.vaultPath(relPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rec.StorageMode == "vault" {
|
if rec.StorageMode == "vault" {
|
||||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
src, err := s.vaultPath(rec.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||||
|
// verify trash is inside vault
|
||||||
|
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := os.Rename(src, dest); err != nil {
|
if err := os.Rename(src, dest); err != nil {
|
||||||
return fmt.Errorf("move to trash: %w", err)
|
return fmt.Errorf("move to trash: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var abs string
|
abs, err := s.absPathSafe(rec)
|
||||||
if rec.StorageMode == "vault" {
|
if err != nil {
|
||||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
return err
|
||||||
} else {
|
|
||||||
abs = rec.Path
|
|
||||||
}
|
}
|
||||||
return openWithSystem(abs)
|
return openWithSystem(abs)
|
||||||
}
|
}
|
||||||
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
|
|||||||
if rec.Size > maxPreviewSize {
|
if rec.Size > maxPreviewSize {
|
||||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||||
}
|
}
|
||||||
var abs string
|
abs, err := s.absPathSafe(rec)
|
||||||
if rec.StorageMode == "vault" {
|
if err != nil {
|
||||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
return "", err
|
||||||
} else {
|
|
||||||
abs = rec.Path
|
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(abs)
|
b, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
|||||||
if rec.Size > maxPreviewSize {
|
if rec.Size > maxPreviewSize {
|
||||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||||
}
|
}
|
||||||
var abs string
|
abs, err := s.absPathSafe(rec)
|
||||||
if rec.StorageMode == "vault" {
|
if err != nil {
|
||||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
return "", err
|
||||||
} else {
|
|
||||||
abs = rec.Path
|
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(abs)
|
b, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
|||||||
|
|
||||||
// CreateEmptyFile creates a file node and an empty vault file.
|
// CreateEmptyFile creates a file node and an empty vault file.
|
||||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||||
|
if err := validateName(filename); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||||
|
}
|
||||||
filename = s.uniqueTitle(parentID, filename)
|
filename = s.uniqueTitle(parentID, filename)
|
||||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
|||||||
}
|
}
|
||||||
f.Close()
|
f.Close()
|
||||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||||
|
// Verify dest is inside vault
|
||||||
|
if _, err := s.vaultPath(relPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||||
return node, err
|
return node, err
|
||||||
}
|
}
|
||||||
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||||||
if original.ParentID != nil {
|
if original.ParentID != nil {
|
||||||
parentID = *original.ParentID
|
parentID = *original.ParentID
|
||||||
}
|
}
|
||||||
newName := s.uniqueTitle(parentID, original.Title)
|
newName := s.copyTitle(parentID, original.Title)
|
||||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||||||
if len(records) > 0 {
|
if len(records) > 0 {
|
||||||
src := &records[0]
|
src := &records[0]
|
||||||
if src.StorageMode == "vault" {
|
if src.StorageMode == "vault" {
|
||||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
srcPath, err := s.vaultPath(src.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||||
os.MkdirAll(dir, 0o750)
|
os.MkdirAll(dir, 0o750)
|
||||||
dst := filepath.Join(dir, newName)
|
dst := filepath.Join(dir, newName)
|
||||||
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||||||
return nil, fmt.Errorf("copy file: %w", err)
|
return nil, fmt.Errorf("copy file: %w", err)
|
||||||
}
|
}
|
||||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||||
|
if _, err := s.vaultPath(relPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("path safety: %w", err)
|
||||||
|
}
|
||||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
|
||||||
|
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
|
||||||
|
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
|
||||||
|
func (s *Service) copyTitle(parentID, desired string) string {
|
||||||
|
children, _ := s.nodes.ListChildren(parentID, false)
|
||||||
|
used := make(map[string]bool, len(children))
|
||||||
|
for i := range children {
|
||||||
|
used[children[i].Title] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(desired)
|
||||||
|
base := strings.TrimSuffix(desired, ext)
|
||||||
|
copyName := base + " (copy)" + ext
|
||||||
|
if !used[copyName] {
|
||||||
|
return copyName
|
||||||
|
}
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
|
||||||
|
if !used[candidate] {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
|
||||||
|
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
|
||||||
|
return s.copyTitle(parentID, desired)
|
||||||
|
}
|
||||||
|
|
||||||
// --- implementation details ---
|
// --- implementation details ---
|
||||||
|
|
||||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||||
|
|||||||
@@ -50,16 +50,10 @@ func now() string {
|
|||||||
return time.Now().UTC().Format(time.RFC3339)
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CRUD ---
|
|
||||||
|
|
||||||
// Valid sections for root-level nodes.
|
|
||||||
var validSections = map[string]struct{}{
|
|
||||||
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a root or child node.
|
// Create inserts a root or child node.
|
||||||
// parentID may be empty for root-level nodes.
|
// parentID may be empty for root-level nodes.
|
||||||
// For root nodes, section determines sidebar placement (may be empty → inbox).
|
// For root nodes, section determines sidebar placement (may be empty = inbox).
|
||||||
|
// section must be a valid section (clients, projects, etc.) or empty for inbox.
|
||||||
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
|
||||||
if !IsValidType(typ) {
|
if !IsValidType(typ) {
|
||||||
return nil, fmt.Errorf("invalid node type: %s", typ)
|
return nil, fmt.Errorf("invalid node type: %s", typ)
|
||||||
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
return nil, errors.New("title is required")
|
return nil, errors.New("title is required")
|
||||||
}
|
}
|
||||||
|
if section != "" && !IsValidSection(section) {
|
||||||
|
return nil, fmt.Errorf("invalid section: %s", section)
|
||||||
|
}
|
||||||
|
|
||||||
n := &Node{
|
n := &Node{
|
||||||
ID: util.UUID7(),
|
ID: util.UUID7(),
|
||||||
@@ -182,6 +179,39 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
|
|||||||
return scanNodes(rows)
|
return scanNodes(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todayBoundaries returns RFC3339 start and end strings for the current day
|
||||||
|
// in the local timezone (the server's timezone, which should match the user's).
|
||||||
|
// TODO: accept a user timezone offset when multi-user support is added.
|
||||||
|
func todayBoundaries() (string, string) {
|
||||||
|
now := time.Now()
|
||||||
|
y, m, d := now.Date()
|
||||||
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
end := start.Add(24 * time.Hour)
|
||||||
|
return start.Format(time.RFC3339), end.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTodayNodes returns active root-level nodes created or updated today.
|
||||||
|
// This is a dynamic view, not a section — it shows the day's activity.
|
||||||
|
func (r *Repository) ListTodayNodes() ([]Node, error) {
|
||||||
|
start, end := todayBoundaries()
|
||||||
|
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
|
||||||
|
created_at,updated_at,deleted_at,revision,device_id
|
||||||
|
FROM nodes
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
(created_at >= ? AND created_at < ?)
|
||||||
|
OR
|
||||||
|
(updated_at >= ? AND updated_at < ?)
|
||||||
|
)
|
||||||
|
ORDER BY updated_at DESC, created_at DESC`
|
||||||
|
rows, err := r.db.Query(q, start, end, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanNodes(rows)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateTitle changes title (slug is recomputed).
|
// UpdateTitle changes title (slug is recomputed).
|
||||||
func (r *Repository) UpdateTitle(id, title string) error {
|
func (r *Repository) UpdateTitle(id, title string) error {
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -35,12 +35,40 @@ var TypeSet = map[string]struct{}{
|
|||||||
TypeLink: {},
|
TypeLink: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valid sections for root-level nodes.
|
||||||
|
// today and inbox are service sections, not stored in nodes.section.
|
||||||
|
var validSections = map[string]struct{}{
|
||||||
|
"clients": {},
|
||||||
|
"projects": {},
|
||||||
|
"recipes": {},
|
||||||
|
"documents": {},
|
||||||
|
"archive": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceSections are sidebar entries that are not stored as node sections.
|
||||||
|
var serviceSections = map[string]struct{}{
|
||||||
|
"today": {},
|
||||||
|
"inbox": {},
|
||||||
|
}
|
||||||
|
|
||||||
// IsValidType checks whether a type string is recognized.
|
// IsValidType checks whether a type string is recognized.
|
||||||
func IsValidType(t string) bool {
|
func IsValidType(t string) bool {
|
||||||
_, ok := TypeSet[t]
|
_, ok := TypeSet[t]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsValidSection returns true for sections that can be stored on a node.
|
||||||
|
func IsValidSection(s string) bool {
|
||||||
|
_, ok := validSections[s]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsServiceSection returns true for sidebar-only sections (today, inbox).
|
||||||
|
func IsServiceSection(s string) bool {
|
||||||
|
_, ok := serviceSections[s]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// Slugify converts a title into a filesystem-safe slug.
|
// Slugify converts a title into a filesystem-safe slug.
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user