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/components/SideBar/Details/FileDetails.vue b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue
index dca11fc648..f9827fbe54 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') }}
+
+
+ {{ $gettext('Saving…') }}
+
+
@@ -131,7 +146,8 @@ import {
useResourceContents,
useLoadPreview,
useSideBar,
- useResourceIndicators
+ useResourceIndicators,
+ useClientService
} from '@opencloud-eu/web-pkg'
import upperFirst from 'lodash-es/upperFirst'
import {
@@ -166,6 +182,7 @@ const { loadPreview, previewsLoading } = useLoadPreview()
const { openSideBarPanel } = useSideBar()
const { getIndicators } = useResourceIndicators()
const { tagsHelper } = useContextualHelpers()
+const clientService = useClientService()
const language = useGettext()
const { $gettext, current: currentLanguage } = language
@@ -181,6 +198,55 @@ const space = inject[>('space')
const preview = ref(undefined)
+// Notice (user.oc.md.oy.notice xattr, loaded via metadata API)
+const noticeText = ref('')
+const noticeSaving = ref(false)
+const noticeOriginal = ref('')
+
+async function loadNotice() {
+ const res = unref(resource)
+ const sp = unref(space)
+ if (!res?.id || !sp?.id) return
+ try {
+ const httpClient = clientService.httpAuthenticated
+ const response = await httpClient.get(
+ `/graph/v1beta1/drives/${sp.id}/items/${res.id}/metadata`
+ )
+ const val = response.data?.['note'] || ''
+ noticeText.value = val
+ noticeOriginal.value = val
+ } catch {
+ noticeText.value = ''
+ noticeOriginal.value = ''
+ }
+}
+
+watch(() => unref(resource)?.id, () => loadNotice(), { immediate: true })
+
+async function saveNotice() {
+ if (unref(noticeText) === unref(noticeOriginal)) return
+ noticeSaving.value = true
+ try {
+ const res = unref(resource)
+ const sp = unref(space)
+ const httpClient = clientService.httpAuthenticated
+ await httpClient.put(
+ `/graph/v1beta1/drives/${sp.id}/items/${res.id}/metadata`,
+ { note: unref(noticeText) }
+ )
+ noticeOriginal.value = unref(noticeText)
+ resourcesStore.updateResourceField({
+ id: res.id,
+ field: 'notice',
+ value: unref(noticeText) || undefined
+ })
+ } catch (e) {
+ console.error('Failed to save notice', e)
+ } finally {
+ noticeSaving.value = false
+ }
+}
+
const authStore = useAuthStore()
const { publicLinkContextReady } = storeToRefs(authStore)
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..9de96a6ae6
--- /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/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/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-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',
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
+ }
+}
diff --git a/packages/web-client/src/helpers/resource/functions.ts b/packages/web-client/src/helpers/resource/functions.ts
index 2afa82fd0c..06c3da656a 100644
--- a/packages/web-client/src/helpers/resource/functions.ts
+++ b/packages/web-client/src/helpers/resource/functions.ts
@@ -147,6 +147,8 @@ export function buildResource(
locked: !!activeLock,
lockOwner,
lockTime,
+ immutableState: resource.props[DavProperty.Immutable] || undefined,
+ notice: resource.props[DavProperty.Notice] || 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 ab16338726..8725167661 100644
--- a/packages/web-client/src/helpers/resource/types.ts
+++ b/packages/web-client/src/helpers/resource/types.ts
@@ -53,6 +53,8 @@ export interface Resource {
locked?: boolean
lockOwner?: string
lockTime?: string
+ immutableState?: 'frozen' | 'protected' | 'shielded'
+ notice?: string
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..05976436c1 100644
--- a/packages/web-client/src/webdav/constants/dav.ts
+++ b/packages/web-client/src/webdav/constants/dav.ts
@@ -77,6 +77,8 @@ const DavPropertyMapping = {
value: 'photo',
type: null as Photo
},
+ Immutable: defString('immutable' as const),
+ Notice: defString('md-note' as const),
ETag: defString('getetag' as const),
MimeType: defString('getcontenttype' as const),
ResourceType: defStringArray('resourcetype' as const),
@@ -145,6 +147,8 @@ export abstract class DavProperties {
DavProperty.MimeType,
DavProperty.ResourceType,
DavProperty.Tags,
+ DavProperty.Immutable,
+ DavProperty.Notice,
DavProperty.Audio,
DavProperty.Location,
DavProperty.Image,
diff --git a/packages/web-client/src/webdav/index.ts b/packages/web-client/src/webdav/index.ts
index e0698b328d..7dc67df475 100644
--- a/packages/web-client/src/webdav/index.ts
+++ b/packages/web-client/src/webdav/index.ts
@@ -19,6 +19,7 @@ import { GetPathForFileIdFactory } from './getPathForFileId'
import { DAV } from './client/dav'
import { ListFileVersionsFactory } from './listFileVersions'
import { SetFavoriteFactory } from './setFavorite'
+import { SetNoticeFactory } from './setNotice'
export * from './constants'
export * from './types'
@@ -78,6 +79,7 @@ export const webdav = (baseURI: string, headers?: () => Headers): WebDAV => {
const { search } = SearchFactory(dav, options)
const { setFavorite } = SetFavoriteFactory(dav, options)
+ const { setNotice } = SetNoticeFactory(dav, options)
return {
copyFiles,
@@ -98,6 +100,7 @@ export const webdav = (baseURI: string, headers?: () => Headers): WebDAV => {
clearTrashBin,
search,
setFavorite,
+ setNotice,
registerExtraProp
}
diff --git a/packages/web-client/src/webdav/setNotice.ts b/packages/web-client/src/webdav/setNotice.ts
new file mode 100644
index 0000000000..ed16dd4d73
--- /dev/null
+++ b/packages/web-client/src/webdav/setNotice.ts
@@ -0,0 +1,19 @@
+import { urlJoin } from '../utils'
+import { SpaceResource } from '../helpers'
+import { WebDavOptions } from './types'
+import { DAV, DAVRequestOptions } from './client'
+import { DavProperty } from './constants'
+
+export const SetNoticeFactory = (dav: DAV, options: WebDavOptions) => {
+ return {
+ setNotice(
+ space: SpaceResource,
+ { path }: { path: string },
+ value: string,
+ opts: DAVRequestOptions = {}
+ ) {
+ const properties = { [DavProperty.Notice]: value }
+ return dav.propPatch(urlJoin(space.webDavPath, path), properties, opts)
+ }
+ }
+}
diff --git a/packages/web-client/tests/unit/webdav/setNotice.spec.ts b/packages/web-client/tests/unit/webdav/setNotice.spec.ts
new file mode 100644
index 0000000000..19768cdafc
--- /dev/null
+++ b/packages/web-client/tests/unit/webdav/setNotice.spec.ts
@@ -0,0 +1,57 @@
+import { mock } from 'vitest-mock-extended'
+import { SetNoticeFactory } from '../../../src/webdav/setNotice'
+import { DAV } from '../../../src/webdav/client'
+import { DavProperty } from '../../../src/webdav/constants'
+import { SpaceResource } from '../../../src/helpers'
+
+describe('setNotice', () => {
+ function setup() {
+ const dav = mock()
+ dav.propPatch.mockResolvedValue(undefined)
+ const { setNotice } = SetNoticeFactory(dav, { baseURI: '' })
+ const space = mock({ webDavPath: '/dav/spaces/abc' })
+ return { dav, setNotice, space }
+ }
+
+ it('sends a PROPPATCH with the notice value', async () => {
+ const { dav, setNotice, space } = setup()
+ await setNotice(space, { path: '/test.txt' }, 'hello')
+ expect(dav.propPatch).toHaveBeenCalledWith(
+ '/dav/spaces/abc/test.txt',
+ { [DavProperty.Notice]: 'hello' },
+ {}
+ )
+ })
+
+ it('handles empty string', async () => {
+ const { dav, setNotice, space } = setup()
+ await setNotice(space, { path: '/doc.pdf' }, '')
+ expect(dav.propPatch).toHaveBeenCalledWith(
+ '/dav/spaces/abc/doc.pdf',
+ { [DavProperty.Notice]: '' },
+ {}
+ )
+ })
+
+ it.each([
+ { name: 'German umlauts', value: 'Ä Ö Ü ä ö ü ß' },
+ { name: 'French accents', value: 'é è ê ë à â ç' },
+ { name: 'Cyrillic', value: 'Привет мир' },
+ { name: 'CJK characters', value: '你好世界 こんにちは' },
+ { name: 'emoji', value: '📁 Aktenplan 🔒' },
+ { name: 'XML special chars', value: ' & "quotes"' },
+ { name: 'newlines', value: 'line 1\nline 2\ttab' },
+ { name: 'backslashes and paths', value: 'C:\\Users\\test\\file.txt' },
+ { name: 'single quotes', value: "it's a test with 'quotes'" },
+ { name: 'null-like strings', value: 'null undefined NaN' },
+ { name: 'very long text', value: 'a'.repeat(1000) }
+ ])('preserves special characters: $name', async ({ value }) => {
+ const { dav, setNotice, space } = setup()
+ await setNotice(space, { path: '/file.txt' }, value)
+ expect(dav.propPatch).toHaveBeenCalledWith(
+ '/dav/spaces/abc/file.txt',
+ { [DavProperty.Notice]: value },
+ {}
+ )
+ })
+})
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..3dea70e712
--- /dev/null
+++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts
@@ -0,0 +1,164 @@
+import { useGettext } from 'vue3-gettext'
+import { FileAction } from '../types'
+import { computed } from 'vue'
+import { useMessages, useModals, useResourcesStore } from '../../piniaStores'
+import { useClientService } from '../../clientService'
+
+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-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts
index dfab943e8c..362e68cae9 100644
--- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts
+++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts
@@ -167,6 +167,34 @@ export const useResourceIndicators = () => {
}
}
+ const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => {
+ 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')
+ : isProtected
+ ? $gettext('Item is protected')
+ : $gettext('Item is in a protected folder'),
+ label: isFrozen
+ ? $gettext('This file is frozen')
+ : isProtected
+ ? $gettext('This item is protected')
+ : $gettext('This item is in a protected folder'),
+ icon: isFrozen ? 'snowflake' : 'shield',
+ category: 'system',
+ type: 'resource-immutable',
+ fillType: isFrozen ? 'line' : isProtected ? 'fill' : 'line'
+ }
+ }
+
const getProcessingIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => {
return {
id: `resource-processing-${resource.getDomSelector()}`,
@@ -217,6 +245,10 @@ export const useResourceIndicators = () => {
indicators.push(getLockedIndicator({ resource }))
}
+ if (resource.immutableState) {
+ indicators.push(getImmutableIndicator({ resource }))
+ }
+
if (resource.processing) {
indicators.push(getProcessingIndicator({ resource }))
}
]