From 928ce751ee6eeb03d6472e8c561f06b38db82643 Mon Sep 17 00:00:00 2001 From: flash Date: Sun, 14 Jun 2026 21:30:03 +0200 Subject: [PATCH 01/13] feat(web): add metadata sidebar panel New sidebar tab "Metadata" that displays custom metadata (user.oc.md.*) for the selected file or folder. Fetches data from the Graph API endpoint GET /drives/{driveID}/items/{itemID}/metadata. - MetadataPanel.vue: renders key-value pairs with formatted labels - useFileSideBars.ts: registers panel, visible for single item selection - Strips "oy." prefix and converts camelCase to Title Case for display Depends on: OpenCloud Graph API metadata endpoint --- .../SideBar/Metadata/MetadataPanel.vue | 96 +++++++++++++++++++ .../composables/extensions/useFileSideBars.ts | 18 ++++ 2 files changed, 114 insertions(+) create mode 100644 packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue diff --git a/packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue b/packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue new file mode 100644 index 0000000000..9143db1629 --- /dev/null +++ b/packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue @@ -0,0 +1,96 @@ + + + diff --git a/packages/web-app-files/src/composables/extensions/useFileSideBars.ts b/packages/web-app-files/src/composables/extensions/useFileSideBars.ts index 04e8c14469..0aaa527ad5 100644 --- a/packages/web-app-files/src/composables/extensions/useFileSideBars.ts +++ b/packages/web-app-files/src/composables/extensions/useFileSideBars.ts @@ -28,6 +28,7 @@ import { useGettext } from 'vue3-gettext' import { markRaw, unref } from 'vue' import { fileSideBarExtensionPoint } from '../../extensionPoints' import AudioMetaPanel from '../../components/SideBar/Audio/AudioMetaPanel.vue' +import MetadataPanel from '../../components/SideBar/Metadata/MetadataPanel.vue' import { isEmpty } from 'lodash-es' export const useSideBarPanels = (): SidebarPanelExtension[] => { @@ -177,6 +178,23 @@ export const useSideBarPanels = (): SidebarPanelExtension $gettext('Metadata'), + component: MetadataPanel, + isVisible: ({ items }) => { + if (items?.length !== 1) { + return false + } + return !isProjectSpaceResource(items[0]) + } + } + }, { id: 'com.github.opencloud-eu.web.files.sidebar-panel.actions', type: 'sidebarPanel', From 9b48ab92786d1c99f18966eaa97838342a2f3764 Mon Sep 17 00:00:00 2001 From: flash Date: Sun, 14 Jun 2026 21:55:58 +0200 Subject: [PATCH 02/13] feat(web): immutable UI - indicators + context menu actions 1. Resource model: add `immutable` boolean field 2. Indicators (useResourceIndicators.ts): - Frozen file: snowflake icon - Protected folder: shield-check icon 3. Context menu actions (useFileActionsImmutable.ts): - "Freeze file": POST /freeze with confirmation dialog (irreversible!) - "Protect folder": POST /protect - "Remove protection": DELETE /protect 4. Actions registered in useFileActions.ts for context menu Depends on: OpenCloud Graph API freeze/protect/unprotect endpoints (blocked by cs3org/cs3apis#275 - Gateway SetImmutable RPC) --- .../composables/extensions/useFileActions.ts | 31 +++++- .../web-client/src/helpers/resource/types.ts | 1 + .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsImmutable.ts | 99 +++++++++++++++++++ .../resources/useResourceIndicators.ts | 22 +++++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts diff --git a/packages/web-app-files/src/composables/extensions/useFileActions.ts b/packages/web-app-files/src/composables/extensions/useFileActions.ts index be0a962f69..c1045619b5 100644 --- a/packages/web-app-files/src/composables/extensions/useFileActions.ts +++ b/packages/web-app-files/src/composables/extensions/useFileActions.ts @@ -14,6 +14,7 @@ import { useFileActionsDownloadArchive, useFileActionsFavorite, useFileActionsEnableSync, + useFileActionsImmutable, useFileActionsMove, useFileActionsPaste, useFileActionsOpenShortcut, @@ -45,6 +46,14 @@ export const useFileActions = (): ActionExtension[] => { const { actions: setSpaceImageActions } = useSpaceActionsSetImage() const { actions: showDetailsActions } = useFileActionsShowDetails() const { actions: toggleHideShareActions } = useFileActionsToggleHideShare() + const { actions: immutableActions } = useFileActionsImmutable() + + const singleItemActions = unref(immutableActions).filter( + (a) => !a.name.startsWith('protect-folder') && !a.name.startsWith('unprotect-folder') + ) + const batchableActions = unref(immutableActions).filter( + (a) => a.name === 'protect-folder' || a.name === 'unprotect-folder' + ) return [ { @@ -217,6 +226,26 @@ export const useFileActions = (): ActionExtension[] => { ...unref(toggleHideShareActions)[0], category: 'tertiary' } - } + }, + // Immutable: single-item quick actions (freeze, frozen, shielded indicators) + ...singleItemActions.map((action) => ({ + id: `com.github.opencloud-eu.web.files.quick-action.${action.name}`, + extensionPointIds: [quickActionsExtensionPoint.id], + type: 'action' as const, + action + })), + // Immutable: protect/unprotect as quick action + batch action + ...batchableActions.map((action) => ({ + id: `com.github.opencloud-eu.web.files.quick-action.${action.name}`, + extensionPointIds: [quickActionsExtensionPoint.id], + type: 'action' as const, + action + })), + ...batchableActions.map((action) => ({ + id: `com.github.opencloud-eu.web.files.batch-action.${action.name}`, + extensionPointIds: [batchActionsExtensionPoint.id], + type: 'action' as const, + action + })) ] } diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index ab16338726..2b783fd614 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -53,6 +53,7 @@ export interface Resource { locked?: boolean lockOwner?: string lockTime?: string + immutable?: boolean mimeType?: string isFolder?: boolean mdate?: string diff --git a/packages/web-pkg/src/composables/actions/files/index.ts b/packages/web-pkg/src/composables/actions/files/index.ts index 5be66b8bef..fcb1427091 100644 --- a/packages/web-pkg/src/composables/actions/files/index.ts +++ b/packages/web-pkg/src/composables/actions/files/index.ts @@ -8,3 +8,4 @@ export * from './useFileActionsRestore' export * from './useFileActionsSaveAs' export * from './useFileActionsUndoDelete' export * from './useFileActionFallbackToDownload' +export * from './useFileActionsImmutable' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts new file mode 100644 index 0000000000..e6ac35c7f5 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -0,0 +1,99 @@ +import { useGettext } from 'vue3-gettext' +import { FileAction } from '../types' +import { computed } from 'vue' +import { useMessages, useModals } from '../../piniaStores' +import { useClientService } from '../../clientService' + +export const useFileActionsImmutable = () => { + const { $gettext } = useGettext() + const clientService = useClientService() + const { showMessage, showErrorMessage } = useMessages() + const { dispatchModal } = useModals() + + const callImmutableEndpoint = async ( + driveId: string, + itemId: string, + action: 'freeze' | 'protect', + method: 'POST' | 'DELETE' = 'POST' + ) => { + const httpClient = clientService.httpAuthenticated + const endpoint = action === 'freeze' ? 'freeze' : 'protect' + try { + const response = await httpClient.request({ + method, + url: `/graph/v1beta1/drives/${driveId}/items/${itemId}/${endpoint}` + }) + if (response.status === 204) { + const msg = + action === 'freeze' + ? $gettext('File has been frozen.') + : method === 'POST' + ? $gettext('Folder has been protected.') + : $gettext('Folder protection has been removed.') + showMessage({ title: msg }) + } + } catch (e) { + showErrorMessage({ + title: $gettext('Operation failed'), + errors: [e as Error] + }) + } + } + + const actions = computed((): FileAction[] => [ + { + name: 'freeze-file', + icon: 'snowflake', + label: () => $gettext('Freeze file'), + handler: ({ space, resources }) => { + const resource = resources[0] + dispatchModal({ + title: $gettext('Freeze file permanently?'), + confirmText: $gettext('Freeze'), + message: $gettext( + 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' + ), + onConfirm: () => { + callImmutableEndpoint(space.id, resource.id, 'freeze') + } + }) + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && !r.immutable + }, + class: 'oc-files-actions-freeze-trigger' + }, + { + name: 'protect-folder', + icon: 'shield-check', + label: () => $gettext('Protect folder'), + handler: ({ space, resources }) => { + callImmutableEndpoint(space.id, resources[0].id, 'protect') + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'folder' && !r.immutable + }, + class: 'oc-files-actions-protect-trigger' + }, + { + name: 'unprotect-folder', + icon: 'shield', + label: () => $gettext('Remove protection'), + handler: ({ space, resources }) => { + callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE') + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'folder' && r.immutable === true + }, + class: 'oc-files-actions-unprotect-trigger' + } + ]) + + return { actions } +} diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index dfab943e8c..ce4c9037b4 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -167,6 +167,24 @@ export const useResourceIndicators = () => { } } + const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { + const isFolder = resource.type === 'folder' + return { + id: `resource-immutable-${resource.getDomSelector()}`, + kind: 'icon', + accessibleDescription: isFolder + ? $gettext('Folder is protected') + : $gettext('File is frozen'), + label: isFolder + ? $gettext('This folder is protected') + : $gettext('This file is frozen'), + icon: isFolder ? 'shield-check' : 'snowflake', + category: 'system', + type: 'resource-immutable', + fillType: 'line' + } + } + const getProcessingIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { return { id: `resource-processing-${resource.getDomSelector()}`, @@ -217,6 +235,10 @@ export const useResourceIndicators = () => { indicators.push(getLockedIndicator({ resource })) } + if (resource.immutable) { + indicators.push(getImmutableIndicator({ resource })) + } + if (resource.processing) { indicators.push(getProcessingIndicator({ resource })) } From fc6c7a766201688f0104728f0a3506729219447a Mon Sep 17 00:00:00 2001 From: flash Date: Sun, 14 Jun 2026 23:16:50 +0200 Subject: [PATCH 03/13] feat(web): complete immutable state integration via WebDAV PROPFIND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read oc:immutable property from PROPFIND responses (no extra request). Reva returns "frozen" (file) or "protected" (folder/inherited) state. Data flow: Reva xattr → GetImmutableState() → oc:immutable property → WebDAV PROPFIND → DavProperty.Immutable → resource.immutableState ("frozen"|"protected"|undefined) → Quick Action icons + Indicator badges Quick Actions (hover buttons in file list): File | normal | leaf icon | click → freeze (confirmation dialog) File | frozen | snowflake icon | disabled (irreversible) File | protected | shield-fill | disabled (parent protected) Folder | normal | shield-line | click → protect Folder | protected | shield-fill | click → unprotect Indicators (badges next to filename): frozen → snowflake icon protected → shield-fill icon Changes: - DavProperty.Immutable added + included in default PROPFIND request - Resource type: immutableState?: 'frozen' | 'protected' - Resource builder: maps oc:immutable to immutableState - useFileActionsImmutable: 5 actions for all state combinations - useResourceIndicators: indicator based on immutableState - useFileActions: registered as quickActionsExtensionPoint (not context!) --- .../src/helpers/resource/functions.ts | 1 + .../web-client/src/helpers/resource/types.ts | 2 +- .../web-client/src/webdav/constants/dav.ts | 2 + .../actions/files/useFileActionsImmutable.ts | 49 ++++++++++++++++--- .../resources/useResourceIndicators.ts | 18 +++---- 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/web-client/src/helpers/resource/functions.ts b/packages/web-client/src/helpers/resource/functions.ts index 2afa82fd0c..0cda11c8cb 100644 --- a/packages/web-client/src/helpers/resource/functions.ts +++ b/packages/web-client/src/helpers/resource/functions.ts @@ -147,6 +147,7 @@ export function buildResource( locked: !!activeLock, lockOwner, lockTime, + immutableState: resource.props[DavProperty.Immutable] || undefined, processing: resource.processing || false, mdate: resource.props[DavProperty.LastModifiedDate], size: isFolder diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index 2b783fd614..c300b14970 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -53,7 +53,7 @@ export interface Resource { locked?: boolean lockOwner?: string lockTime?: string - immutable?: boolean + immutableState?: 'frozen' | 'protected' mimeType?: string isFolder?: boolean mdate?: string diff --git a/packages/web-client/src/webdav/constants/dav.ts b/packages/web-client/src/webdav/constants/dav.ts index 70afd4c1fd..996490d9a4 100644 --- a/packages/web-client/src/webdav/constants/dav.ts +++ b/packages/web-client/src/webdav/constants/dav.ts @@ -77,6 +77,7 @@ const DavPropertyMapping = { value: 'photo', type: null as Photo }, + Immutable: defString('immutable' as const), ETag: defString('getetag' as const), MimeType: defString('getcontenttype' as const), ResourceType: defStringArray('resourcetype' as const), @@ -145,6 +146,7 @@ export abstract class DavProperties { DavProperty.MimeType, DavProperty.ResourceType, DavProperty.Tags, + DavProperty.Immutable, DavProperty.Audio, DavProperty.Location, DavProperty.Image, diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index e6ac35c7f5..59eaed2464 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -41,9 +41,10 @@ export const useFileActionsImmutable = () => { } const actions = computed((): FileAction[] => [ + // File: not frozen/protected → leaf icon → click to freeze (with confirmation) { name: 'freeze-file', - icon: 'snowflake', + icon: 'leaf', label: () => $gettext('Freeze file'), handler: ({ space, resources }) => { const resource = resources[0] @@ -61,13 +62,48 @@ export const useFileActionsImmutable = () => { isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'file' && !r.immutable + return r.type === 'file' && !r.immutableState }, class: 'oc-files-actions-freeze-trigger' }, + // File: frozen → snowflake icon → no action (irreversible) + { + name: 'frozen-file', + icon: 'snowflake', + label: () => $gettext('File is frozen'), + handler: () => { + // no-op: frozen files cannot be unfrozen + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'frozen' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is permanently frozen and cannot be modified.'), + class: 'oc-files-actions-frozen-indicator' + }, + // File: protected (parent is protected) → snowflake outline → no action + { + name: 'protected-file', + icon: 'shield-fill', + label: () => $gettext('File is in a protected folder'), + handler: () => { + // no-op: inherited protection + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'protected' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), + class: 'oc-files-actions-protected-file-indicator' + }, + // Folder: not protected → empty shield → click to protect { name: 'protect-folder', - icon: 'shield-check', + icon: 'shield-line', label: () => $gettext('Protect folder'), handler: ({ space, resources }) => { callImmutableEndpoint(space.id, resources[0].id, 'protect') @@ -75,13 +111,14 @@ export const useFileActionsImmutable = () => { isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'folder' && !r.immutable + return r.type === 'folder' && !r.immutableState }, class: 'oc-files-actions-protect-trigger' }, + // Folder: protected (self) → filled shield → click to unprotect { name: 'unprotect-folder', - icon: 'shield', + icon: 'shield-fill', label: () => $gettext('Remove protection'), handler: ({ space, resources }) => { callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE') @@ -89,7 +126,7 @@ export const useFileActionsImmutable = () => { isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'folder' && r.immutable === true + return r.type === 'folder' && r.immutableState === 'protected' }, class: 'oc-files-actions-unprotect-trigger' } diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index ce4c9037b4..5210fac8c3 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -168,17 +168,17 @@ export const useResourceIndicators = () => { } const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { - const isFolder = resource.type === 'folder' + const isFrozen = resource.immutableState === 'frozen' return { id: `resource-immutable-${resource.getDomSelector()}`, kind: 'icon', - accessibleDescription: isFolder - ? $gettext('Folder is protected') - : $gettext('File is frozen'), - label: isFolder - ? $gettext('This folder is protected') - : $gettext('This file is frozen'), - icon: isFolder ? 'shield-check' : 'snowflake', + accessibleDescription: isFrozen + ? $gettext('File is frozen') + : $gettext('Item is protected'), + label: isFrozen + ? $gettext('This file is frozen') + : $gettext('This item is protected'), + icon: isFrozen ? 'snowflake' : 'shield-fill', category: 'system', type: 'resource-immutable', fillType: 'line' @@ -235,7 +235,7 @@ export const useResourceIndicators = () => { indicators.push(getLockedIndicator({ resource })) } - if (resource.immutable) { + if (resource.immutableState) { indicators.push(getImmutableIndicator({ resource })) } From a853bc61e6f33bacef97fcbd7169c472d66adbd3 Mon Sep 17 00:00:00 2001 From: flash Date: Mon, 15 Jun 2026 22:46:54 +0200 Subject: [PATCH 04/13] feat: shielded state + icon fixes - immutableState: 'frozen' | 'protected' | 'shielded' - Quick Actions: shield for protect/unprotect, leaf for freeze - Indicators: shield-fill (protected), shield-line (shielded), snowflake (frozen) - Shielded folders can still be protected/unprotected - console.error if folder has frozen state (bug detection) --- .../web-client/src/helpers/resource/types.ts | 2 +- .../actions/files/useFileActionsImmutable.ts | 49 ++++++++++--------- .../resources/useResourceIndicators.ts | 20 ++++++-- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index c300b14970..da6681d6ec 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -53,7 +53,7 @@ export interface Resource { locked?: boolean lockOwner?: string lockTime?: string - immutableState?: 'frozen' | 'protected' + immutableState?: 'frozen' | 'protected' | 'shielded' mimeType?: string isFolder?: boolean mdate?: string diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index 59eaed2464..ea65cd7ff6 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -1,7 +1,7 @@ import { useGettext } from 'vue3-gettext' import { FileAction } from '../types' import { computed } from 'vue' -import { useMessages, useModals } from '../../piniaStores' +import { useMessages, useModals, useResourcesStore } from '../../piniaStores' import { useClientService } from '../../clientService' export const useFileActionsImmutable = () => { @@ -9,12 +9,14 @@ export const useFileActionsImmutable = () => { const clientService = useClientService() const { showMessage, showErrorMessage } = useMessages() const { dispatchModal } = useModals() + const resourcesStore = useResourcesStore() const callImmutableEndpoint = async ( driveId: string, itemId: string, action: 'freeze' | 'protect', - method: 'POST' | 'DELETE' = 'POST' + method: 'POST' | 'DELETE' = 'POST', + newState: 'frozen' | 'protected' | undefined = undefined ) => { const httpClient = clientService.httpAuthenticated const endpoint = action === 'freeze' ? 'freeze' : 'protect' @@ -24,6 +26,11 @@ export const useFileActionsImmutable = () => { url: `/graph/v1beta1/drives/${driveId}/items/${itemId}/${endpoint}` }) if (response.status === 204) { + resourcesStore.updateResourceField({ + id: itemId, + field: 'immutableState', + value: newState + }) const msg = action === 'freeze' ? $gettext('File has been frozen.') @@ -41,7 +48,7 @@ export const useFileActionsImmutable = () => { } const actions = computed((): FileAction[] => [ - // File: not frozen/protected → leaf icon → click to freeze (with confirmation) + // File: normal → leaf → freeze (with confirmation) { name: 'freeze-file', icon: 'leaf', @@ -55,7 +62,7 @@ export const useFileActionsImmutable = () => { 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' ), onConfirm: () => { - callImmutableEndpoint(space.id, resource.id, 'freeze') + callImmutableEndpoint(space.id, resource.id, 'freeze', 'POST', 'frozen') } }) }, @@ -66,14 +73,12 @@ export const useFileActionsImmutable = () => { }, class: 'oc-files-actions-freeze-trigger' }, - // File: frozen → snowflake icon → no action (irreversible) + // File: frozen → snowflake (disabled) { name: 'frozen-file', icon: 'snowflake', label: () => $gettext('File is frozen'), - handler: () => { - // no-op: frozen files cannot be unfrozen - }, + handler: () => {}, isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] @@ -83,45 +88,43 @@ export const useFileActionsImmutable = () => { disabledTooltip: () => $gettext('This file is permanently frozen and cannot be modified.'), class: 'oc-files-actions-frozen-indicator' }, - // File: protected (parent is protected) → snowflake outline → no action + // File: shielded (inherited from parent) → shield (disabled) { - name: 'protected-file', - icon: 'shield-fill', + name: 'shielded-file', + icon: 'shield', label: () => $gettext('File is in a protected folder'), - handler: () => { - // no-op: inherited protection - }, + handler: () => {}, isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'file' && r.immutableState === 'protected' + return r.type === 'file' && r.immutableState === 'shielded' }, isDisabled: () => true, disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), - class: 'oc-files-actions-protected-file-indicator' + class: 'oc-files-actions-shielded-file-indicator' }, - // Folder: not protected → empty shield → click to protect + // Folder: normal → shield → protect { name: 'protect-folder', - icon: 'shield-line', + icon: 'shield', label: () => $gettext('Protect folder'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect') + callImmutableEndpoint(space.id, resources[0].id, 'protect', 'POST', 'protected') }, isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'folder' && !r.immutableState + return r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') }, class: 'oc-files-actions-protect-trigger' }, - // Folder: protected (self) → filled shield → click to unprotect + // Folder: protected (self) → shield-fill → unprotect { name: 'unprotect-folder', - icon: 'shield-fill', + icon: 'shield', label: () => $gettext('Remove protection'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE') + callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE', undefined) }, isVisible: ({ resources }) => { if (resources.length !== 1) return false diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index 5210fac8c3..362e68cae9 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -168,20 +168,30 @@ export const useResourceIndicators = () => { } const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { - const isFrozen = resource.immutableState === 'frozen' + const state = resource.immutableState + if (state === 'frozen' && resource.type === 'folder') { + console.error(`BUG: folder "${resource.name}" has immutableState "frozen" — folders can only be "protected" or "shielded"`) + } + const isFrozen = state === 'frozen' + const isProtected = state === 'protected' + // frozen = snowflake, protected (self) = shield-fill, shielded (inherited) = shield-line return { id: `resource-immutable-${resource.getDomSelector()}`, kind: 'icon', accessibleDescription: isFrozen ? $gettext('File is frozen') - : $gettext('Item is protected'), + : isProtected + ? $gettext('Item is protected') + : $gettext('Item is in a protected folder'), label: isFrozen ? $gettext('This file is frozen') - : $gettext('This item is protected'), - icon: isFrozen ? 'snowflake' : 'shield-fill', + : isProtected + ? $gettext('This item is protected') + : $gettext('This item is in a protected folder'), + icon: isFrozen ? 'snowflake' : 'shield', category: 'system', type: 'resource-immutable', - fillType: 'line' + fillType: isFrozen ? 'line' : isProtected ? 'fill' : 'line' } } From 516de709c551e78d4b51fbf60e6ad220c2a6a173 Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 00:30:36 +0200 Subject: [PATCH 05/13] feat: parent-lookup for unprotect + batch protect/unprotect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - After unprotect: check parent state → set 'shielded' if parent still protected - Batch protect: select multiple folders → protect all - Batch unprotect: select multiple protected folders → unprotect all - No batch freeze (irreversible, single-item only with confirmation) --- .../actions/files/useFileActionsImmutable.ts | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index ea65cd7ff6..40516afb33 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -11,9 +11,24 @@ export const useFileActionsImmutable = () => { const { dispatchModal } = useModals() const resourcesStore = useResourcesStore() + const resolveNewState = ( + resource: { id: string; parentFolderId?: string }, + explicitState: 'frozen' | 'protected' | undefined + ): 'frozen' | 'protected' | 'shielded' | undefined => { + if (explicitState) return explicitState + // After unprotect: check if parent is still protected → shielded + const parent = resourcesStore.resources.find( + (r) => r.id === resource.parentFolderId + ) + if (parent?.immutableState === 'protected' || parent?.immutableState === 'shielded') { + return 'shielded' + } + return undefined + } + const callImmutableEndpoint = async ( driveId: string, - itemId: string, + resource: { id: string; parentFolderId?: string }, action: 'freeze' | 'protect', method: 'POST' | 'DELETE' = 'POST', newState: 'frozen' | 'protected' | undefined = undefined @@ -23,13 +38,13 @@ export const useFileActionsImmutable = () => { try { const response = await httpClient.request({ method, - url: `/graph/v1beta1/drives/${driveId}/items/${itemId}/${endpoint}` + url: `/graph/v1beta1/drives/${driveId}/items/${resource.id}/${endpoint}` }) if (response.status === 204) { resourcesStore.updateResourceField({ - id: itemId, + id: resource.id, field: 'immutableState', - value: newState + value: resolveNewState(resource, newState) }) const msg = action === 'freeze' @@ -62,7 +77,7 @@ export const useFileActionsImmutable = () => { 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' ), onConfirm: () => { - callImmutableEndpoint(space.id, resource.id, 'freeze', 'POST', 'frozen') + callImmutableEndpoint(space.id, resource, 'freeze', 'POST', 'frozen') } }) }, @@ -103,33 +118,45 @@ export const useFileActionsImmutable = () => { disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), class: 'oc-files-actions-shielded-file-indicator' }, - // Folder: normal → shield → protect + // Folder(s): normal/shielded → protect (single + batch) { name: 'protect-folder', icon: 'shield', - label: () => $gettext('Protect folder'), + label: ({ resources }) => + resources?.length > 1 + ? $gettext('Protect %{count} folders', { count: String(resources.length) }) + : $gettext('Protect folder'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect', 'POST', 'protected') + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'POST', 'protected') + } }, isVisible: ({ resources }) => { - if (resources.length !== 1) return false - const r = resources[0] - return r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') + ) }, class: 'oc-files-actions-protect-trigger' }, - // Folder: protected (self) → shield-fill → unprotect + // Folder(s): protected → unprotect (single + batch) { name: 'unprotect-folder', icon: 'shield', - label: () => $gettext('Remove protection'), + label: ({ resources }) => + resources?.length > 1 + ? $gettext('Unprotect %{count} folders', { count: String(resources.length) }) + : $gettext('Remove protection'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE', undefined) + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'DELETE') + } }, isVisible: ({ resources }) => { - if (resources.length !== 1) return false - const r = resources[0] - return r.type === 'folder' && r.immutableState === 'protected' + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && r.immutableState === 'protected' + ) }, class: 'oc-files-actions-unprotect-trigger' } From 931357e8552faac195e2f53328c225203dacae90 Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 07:36:43 +0200 Subject: [PATCH 06/13] fix: parent-lookup via currentFolder for unprotect shielded state --- .../actions/files/useFileActionsImmutable.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index 40516afb33..3dea70e712 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -16,11 +16,9 @@ export const useFileActionsImmutable = () => { explicitState: 'frozen' | 'protected' | undefined ): 'frozen' | 'protected' | 'shielded' | undefined => { if (explicitState) return explicitState - // After unprotect: check if parent is still protected → shielded - const parent = resourcesStore.resources.find( - (r) => r.id === resource.parentFolderId - ) - if (parent?.immutableState === 'protected' || parent?.immutableState === 'shielded') { + // After unprotect: check if current folder (parent) is still protected → shielded + const currentFolder = resourcesStore.currentFolder + if (currentFolder?.immutableState === 'protected' || currentFolder?.immutableState === 'shielded') { return 'shielded' } return undefined @@ -122,9 +120,9 @@ export const useFileActionsImmutable = () => { { name: 'protect-folder', icon: 'shield', - label: ({ resources }) => - resources?.length > 1 - ? $gettext('Protect %{count} folders', { count: String(resources.length) }) + label: (options) => + options?.resources?.length > 1 + ? $gettext('Protect %{count} folders', { count: String(options.resources.length) }) : $gettext('Protect folder'), handler: ({ space, resources }) => { for (const r of resources) { @@ -143,9 +141,9 @@ export const useFileActionsImmutable = () => { { name: 'unprotect-folder', icon: 'shield', - label: ({ resources }) => - resources?.length > 1 - ? $gettext('Unprotect %{count} folders', { count: String(resources.length) }) + label: (options) => + options?.resources?.length > 1 + ? $gettext('Unprotect %{count} folders', { count: String(options.resources.length) }) : $gettext('Remove protection'), handler: ({ space, resources }) => { for (const r of resources) { From 00dcb35ef4136f8049b4e672d4f5dce821b868b3 Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 21:50:02 +0200 Subject: [PATCH 07/13] feat: port immutable actions to web-app-files + special folder view Move useFileActionsImmutable from web-pkg to web-app-files (where v7.x actions live). Add SpecialFolderHeader component and useSpecialFolderView composable for .special/ directory detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../FilesList/SpecialFolderHeader.vue | 37 ++++ .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsImmutable.ts | 162 ++++++++++++++++++ .../specialFolder/useSpecialFolderView.ts | 91 ++++++++++ 4 files changed, 291 insertions(+) create mode 100644 packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue create mode 100644 packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts create mode 100644 packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts diff --git a/packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue b/packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue new file mode 100644 index 0000000000..464dc32148 --- /dev/null +++ b/packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/web-app-files/src/composables/actions/files/index.ts b/packages/web-app-files/src/composables/actions/files/index.ts index 1cf249fa41..52a522e63a 100644 --- a/packages/web-app-files/src/composables/actions/files/index.ts +++ b/packages/web-app-files/src/composables/actions/files/index.ts @@ -17,3 +17,4 @@ export * from './useFileActionsRename' export * from './useFileActionsShowShares' export * from './useFileActionsShowDetails' export * from './useFileActionsToggleHideShare' +export * from './useFileActionsImmutable' diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts new file mode 100644 index 0000000000..303600ac6e --- /dev/null +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts @@ -0,0 +1,162 @@ +import { useGettext } from 'vue3-gettext' +import { FileAction, useMessages, useModals, useResourcesStore, useClientService } from '@opencloud-eu/web-pkg' +import { computed } from 'vue' + +export const useFileActionsImmutable = () => { + const { $gettext } = useGettext() + const clientService = useClientService() + const { showMessage, showErrorMessage } = useMessages() + const { dispatchModal } = useModals() + const resourcesStore = useResourcesStore() + + const resolveNewState = ( + resource: { id: string; parentFolderId?: string }, + explicitState: 'frozen' | 'protected' | undefined + ): 'frozen' | 'protected' | 'shielded' | undefined => { + if (explicitState) return explicitState + // After unprotect: check if current folder (parent) is still protected → shielded + const currentFolder = resourcesStore.currentFolder + if (currentFolder?.immutableState === 'protected' || currentFolder?.immutableState === 'shielded') { + return 'shielded' + } + return undefined + } + + const callImmutableEndpoint = async ( + driveId: string, + resource: { id: string; parentFolderId?: string }, + action: 'freeze' | 'protect', + method: 'POST' | 'DELETE' = 'POST', + newState: 'frozen' | 'protected' | undefined = undefined + ) => { + const httpClient = clientService.httpAuthenticated + const endpoint = action === 'freeze' ? 'freeze' : 'protect' + try { + const response = await httpClient.request({ + method, + url: `/graph/v1beta1/drives/${driveId}/items/${resource.id}/${endpoint}` + }) + if (response.status === 204) { + resourcesStore.updateResourceField({ + id: resource.id, + field: 'immutableState', + value: resolveNewState(resource, newState) + }) + const msg = + action === 'freeze' + ? $gettext('File has been frozen.') + : method === 'POST' + ? $gettext('Folder has been protected.') + : $gettext('Folder protection has been removed.') + showMessage({ title: msg }) + } + } catch (e) { + showErrorMessage({ + title: $gettext('Operation failed'), + errors: [e as Error] + }) + } + } + + const actions = computed((): FileAction[] => [ + // File: normal → leaf → freeze (with confirmation) + { + name: 'freeze-file', + icon: 'leaf', + label: () => $gettext('Freeze file'), + handler: ({ space, resources }) => { + const resource = resources[0] + dispatchModal({ + title: $gettext('Freeze file permanently?'), + confirmText: $gettext('Freeze'), + message: $gettext( + 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' + ), + onConfirm: () => { + callImmutableEndpoint(space.id, resource, 'freeze', 'POST', 'frozen') + } + }) + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && !r.immutableState + }, + class: 'oc-files-actions-freeze-trigger' + }, + // File: frozen → snowflake (disabled) + { + name: 'frozen-file', + icon: 'snowflake', + label: () => $gettext('File is frozen'), + handler: () => {}, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'frozen' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is permanently frozen and cannot be modified.'), + class: 'oc-files-actions-frozen-indicator' + }, + // File: shielded (inherited from parent) → shield (disabled) + { + name: 'shielded-file', + icon: 'shield', + label: () => $gettext('File is in a protected folder'), + handler: () => {}, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'shielded' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), + class: 'oc-files-actions-shielded-file-indicator' + }, + // Folder(s): normal/shielded → protect (single + batch) + { + name: 'protect-folder', + icon: 'shield', + label: (options) => + options?.resources?.length > 1 + ? $gettext('Protect %{count} folders', { count: String(options.resources.length) }) + : $gettext('Protect folder'), + handler: ({ space, resources }) => { + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'POST', 'protected') + } + }, + isVisible: ({ resources }) => { + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') + ) + }, + class: 'oc-files-actions-protect-trigger' + }, + // Folder(s): protected → unprotect (single + batch) + { + name: 'unprotect-folder', + icon: 'shield', + label: (options) => + options?.resources?.length > 1 + ? $gettext('Unprotect %{count} folders', { count: String(options.resources.length) }) + : $gettext('Remove protection'), + handler: ({ space, resources }) => { + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'DELETE') + } + }, + isVisible: ({ resources }) => { + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && r.immutableState === 'protected' + ) + }, + class: 'oc-files-actions-unprotect-trigger' + } + ]) + + return { actions } +} diff --git a/packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts b/packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts new file mode 100644 index 0000000000..c184902a58 --- /dev/null +++ b/packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts @@ -0,0 +1,91 @@ +import { computed, ref, unref, watch, Ref } from 'vue' +import { Resource, SpaceResource } from '@opencloud-eu/web-client' +import { useClientService } from '@opencloud-eu/web-pkg' + +export interface SpecialViewConfig { + type: string + [key: string]: unknown +} + +export interface SpecialFolderState { + /** .special/ directory detected in resource list */ + hasSpecialDir: Ref + /** Parsed view.json config, null if not loaded or error */ + viewConfig: Ref + /** Error message if .special/ present but view.json missing/broken */ + errorMessage: Ref + /** True while fetching view.json */ + isLoading: Ref +} + +export const useSpecialFolderView = ( + space: Ref, + resources: Ref +): SpecialFolderState => { + const clientService = useClientService() + const { getFileContents } = clientService.webdav + + const viewConfig = ref(null) + const errorMessage = ref(null) + const isLoading = ref(false) + + const hasSpecialDir = computed(() => { + return unref(resources).some( + (r) => r.isFolder && r.name === '.special' + ) + }) + + const specialDir = computed(() => { + return unref(resources).find( + (r) => r.isFolder && r.name === '.special' + ) + }) + + watch( + hasSpecialDir, + async (detected) => { + viewConfig.value = null + errorMessage.value = null + + if (!detected) { + return + } + + isLoading.value = true + try { + const dir = unref(specialDir) + const viewJsonPath = dir.path.replace(/\/?$/, '/view.json') + + const { body } = await getFileContents(unref(space), { + path: viewJsonPath + }) + + const parsed = JSON.parse(body as string) + if (!parsed.type || typeof parsed.type !== 'string') { + errorMessage.value = '.special/view.json: "type" field missing or invalid' + return + } + + viewConfig.value = parsed + } catch (e: any) { + if (e?.statusCode === 404 || e?.response?.status === 404) { + errorMessage.value = '.special/view.json not found' + } else if (e instanceof SyntaxError) { + errorMessage.value = '.special/view.json: invalid JSON' + } else { + errorMessage.value = `.special/view.json: ${e.message || 'unknown error'}` + } + } finally { + isLoading.value = false + } + }, + { immediate: true } + ) + + return { + hasSpecialDir, + viewConfig, + errorMessage, + isLoading + } +} From 7c4dd7d2609bd77988b7022cd9272989ea104fbb Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 22:07:31 +0200 Subject: [PATCH 08/13] feat: editable notice field in sidebar (oc:md-note xattr) Add a two-line textarea below tags in the file details sidebar that reads and writes the oc:md-note WebDAV property (user.oc.md.note xattr). Saves on blur or Ctrl+Enter via PROPPATCH. - DavProperty.Notice mapped to oc:md-note - SetNoticeFactory analog to SetFavoriteFactory - Resource.notice field parsed from PROPFIND - Unit tests for special characters (umlauts, CJK, XML, emoji, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SideBar/Details/FileDetails.vue | 53 ++++++++++++++++- .../src/helpers/resource/functions.ts | 1 + .../web-client/src/helpers/resource/types.ts | 1 + .../web-client/src/webdav/constants/dav.ts | 2 + packages/web-client/src/webdav/index.ts | 3 + packages/web-client/src/webdav/setNotice.ts | 19 +++++++ .../tests/unit/webdav/setNotice.spec.ts | 57 +++++++++++++++++++ 7 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 packages/web-client/src/webdav/setNotice.ts create mode 100644 packages/web-client/tests/unit/webdav/setNotice.spec.ts diff --git a/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue index dca11fc648..976bde4570 100644 --- a/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue +++ b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue @@ -113,6 +113,21 @@ +
+
{{ $gettext('Notice') }}
+