From 274defbf031ee71100a3e4acf7de4df96e2e6a37 Mon Sep 17 00:00:00 2001
From: ecrum19
Date: Thu, 4 Jun 2026 10:45:51 +0200
Subject: [PATCH 1/2] added file viewer, size display, and pod registration
---
package-lock.json | 241 ++++++--
package.json | 3 +
src/components/Guides/LandingGuide.vue | 26 +-
src/components/Guides/PodBrowserGuide.vue | 6 +
src/components/PodBrowser.vue | 331 ++++++++++-
src/components/PodRegistration.vue | 147 +++--
src/components/PodResourceInspector.vue | 523 ++++++++++++++++++
src/composables/usePodResourceInspector.ts | 226 ++++++++
src/services/solid/fileUpload.ts | 30 +
src/services/solid/resourceInspector.ts | 471 ++++++++++++++++
src/stores/containerSize.ts | 38 ++
tests/components/AllComponentsSmoke.test.ts | 1 +
tests/components/PodBrowserFeatures.test.ts | 91 ++-
tests/components/PodRegistration.test.ts | 188 +++++++
tests/components/PodResourceInspector.test.ts | 213 +++++++
tests/unit/containerSizeStore.test.ts | 15 +
tests/unit/resourceInspector.test.ts | 141 +++++
17 files changed, 2575 insertions(+), 116 deletions(-)
create mode 100644 src/components/PodResourceInspector.vue
create mode 100644 src/composables/usePodResourceInspector.ts
create mode 100644 src/services/solid/resourceInspector.ts
create mode 100644 src/stores/containerSize.ts
create mode 100644 tests/components/PodRegistration.test.ts
create mode 100644 tests/components/PodResourceInspector.test.ts
create mode 100644 tests/unit/containerSizeStore.test.ts
create mode 100644 tests/unit/resourceInspector.test.ts
diff --git a/package-lock.json b/package-lock.json
index e0850d9..84a25cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "solid-cockpit",
- "version": "1.2.0",
+ "version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "solid-cockpit",
- "version": "1.2.0",
+ "version": "1.2.1",
"license": "MIT",
"dependencies": {
"@comunica/context-entries": "^5.2.0",
@@ -25,7 +25,10 @@
"actor-query-process-remote-cache": "^0.1.0",
"core-js": "^3.8.3",
"fs": "^0.0.1-security",
+ "jsonld": "^9.0.0",
"material-icons": "^1.13.14",
+ "n3": "^2.0.3",
+ "papaparse": "^5.5.3",
"pinia": "^2.3.1",
"query-sparql-remote-cache": "^0.0.9",
"sparqljs": "^3.7.3",
@@ -16881,6 +16884,18 @@
"jsonld-context-parse": "bin/jsonld-context-parse.js"
}
},
+ "node_modules/@comunica/actor-rdf-parse-n3/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/@comunica/actor-rdf-parse-n3/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
@@ -17316,6 +17331,18 @@
"n3": "^1.17.0"
}
},
+ "node_modules/@comunica/actor-rdf-serialize-n3/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/@comunica/actor-rdf-serialize-shaclc": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@comunica/actor-rdf-serialize-shaclc/-/actor-rdf-serialize-shaclc-4.2.0.tgz",
@@ -24693,18 +24720,6 @@
"entities": "^4.5.0"
}
},
- "node_modules/@comunica/query-sparql-link-traversal-solid/node_modules/n3": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz",
- "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==",
- "dependencies": {
- "buffer": "^6.0.3",
- "readable-stream": "^4.0.0"
- },
- "engines": {
- "node": ">=12.0"
- }
- },
"node_modules/@comunica/query-sparql-link-traversal-solid/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
@@ -29402,18 +29417,6 @@
"entities": "^4.5.0"
}
},
- "node_modules/@comunica/query-sparql-solid/node_modules/n3": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz",
- "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==",
- "dependencies": {
- "buffer": "^6.0.3",
- "readable-stream": "^4.0.0"
- },
- "engines": {
- "node": ">=12.0"
- }
- },
"node_modules/@comunica/query-sparql-solid/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
@@ -33828,18 +33831,6 @@
"entities": "^4.5.0"
}
},
- "node_modules/@comunica/query-sparql/node_modules/n3": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz",
- "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==",
- "dependencies": {
- "buffer": "^6.0.3",
- "readable-stream": "^4.0.0"
- },
- "engines": {
- "node": ">=12.0"
- }
- },
"node_modules/@comunica/query-sparql/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
@@ -34671,6 +34662,26 @@
"kuler": "^2.0.0"
}
},
+ "node_modules/@digitalbazaar/http-client": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.3.0.tgz",
+ "integrity": "sha512-6lMpxpt9BOmqHKGs9Xm6DP4LlZTBFer/ZjHvP3FcW3IaUWYIWC7dw5RFZnvw4fP57kAVcm1dp3IF+Y50qhBvAw==",
+ "dependencies": {
+ "ky": "^1.14.2",
+ "undici": "^6.23.0"
+ },
+ "engines": {
+ "node": ">=18.0"
+ }
+ },
+ "node_modules/@digitalbazaar/http-client/node_modules/undici": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
+ "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
@@ -34973,6 +34984,18 @@
"url": "https://github.com/sponsors/rubensworks/"
}
},
+ "node_modules/@inrupt/solid-client/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/@inrupt/solid-client/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
@@ -35695,6 +35718,18 @@
"@triply/yasgui": "4.x"
}
},
+ "node_modules/@triply/yasr/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/@tsconfig/node22": {
"version": "22.0.5",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz",
@@ -38537,6 +38572,18 @@
"fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js"
}
},
+ "node_modules/fetch-sparql-endpoint/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/fetch-sparql-endpoint/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -39705,6 +39752,20 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonld": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-9.0.0.tgz",
+ "integrity": "sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg==",
+ "dependencies": {
+ "@digitalbazaar/http-client": "^4.2.0",
+ "canonicalize": "^2.1.0",
+ "lru-cache": "^6.0.0",
+ "rdf-canonize": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/jsonld-context-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-3.1.0.tgz",
@@ -39777,6 +39838,25 @@
"url": "https://github.com/sponsors/rubensworks/"
}
},
+ "node_modules/jsonld/node_modules/canonicalize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz",
+ "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==",
+ "bin": {
+ "canonicalize": "bin/canonicalize.js"
+ }
+ },
+ "node_modules/jsonld/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -39818,6 +39898,17 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
+ "node_modules/ky": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz",
+ "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/ky?sponsor=1"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -40115,10 +40206,9 @@
"license": "MIT"
},
"node_modules/n3": {
- "version": "1.26.0",
- "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
- "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
- "license": "MIT",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz",
+ "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==",
"dependencies": {
"buffer": "^6.0.3",
"readable-stream": "^4.0.0"
@@ -40440,8 +40530,7 @@
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
- "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
- "license": "MIT"
+ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
},
"node_modules/parent-module": {
"version": "1.0.1",
@@ -41289,6 +41378,17 @@
"safe-buffer": "^5.1.0"
}
},
+ "node_modules/rdf-canonize": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-5.0.0.tgz",
+ "integrity": "sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==",
+ "dependencies": {
+ "setimmediate": "^1.0.5"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/rdf-data-factory": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz",
@@ -41997,6 +42097,18 @@
"readable-stream": "^4.0.0"
}
},
+ "node_modules/rdf-parse/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/rdf-parse/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -42103,6 +42215,18 @@
"readable-stream": "^4.3.0"
}
},
+ "node_modules/rdf-streaming-store/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/rdf-streaming-store/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
@@ -42511,6 +42635,11 @@
"randombytes": "^2.1.0"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+ },
"node_modules/shaclc-parse": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/shaclc-parse/-/shaclc-parse-1.4.3.tgz",
@@ -42521,6 +42650,18 @@
"n3": "^1.16.3"
}
},
+ "node_modules/shaclc-parse/node_modules/n3": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz",
+ "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "readable-stream": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/shaclc-write": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/shaclc-write/-/shaclc-write-1.6.3.tgz",
@@ -42531,18 +42672,6 @@
"rdf-string-ttl": "^2.0.1"
}
},
- "node_modules/shaclc-write/node_modules/n3": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz",
- "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==",
- "dependencies": {
- "buffer": "^6.0.3",
- "readable-stream": "^4.0.0"
- },
- "engines": {
- "node": ">=12.0"
- }
- },
"node_modules/shaclc-write/node_modules/rdf-data-factory": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz",
diff --git a/package.json b/package.json
index 5d5216d..0567cac 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,10 @@
"actor-query-process-remote-cache": "^0.1.0",
"core-js": "^3.8.3",
"fs": "^0.0.1-security",
+ "jsonld": "^9.0.0",
"material-icons": "^1.13.14",
+ "n3": "^2.0.3",
+ "papaparse": "^5.5.3",
"pinia": "^2.3.1",
"query-sparql-remote-cache": "^0.0.9",
"sparqljs": "^3.7.3",
diff --git a/src/components/Guides/LandingGuide.vue b/src/components/Guides/LandingGuide.vue
index 605a3ec..039e45e 100644
--- a/src/components/Guides/LandingGuide.vue
+++ b/src/components/Guides/LandingGuide.vue
@@ -97,7 +97,8 @@
Home authenticates with a Solid
identity provider, shows session state, lets you choose a registered
- pod, and provides copy controls for current identifiers.
+ pod, provides copy controls for current identifiers, and lets you
+ manually register a pod URL when a provider did not add it to your WebID automatically.
Data Upload accepts a typed
@@ -122,6 +123,29 @@
+
+ When you need to register a pod manually
+
+
+ Some Solid providers create a new pod without updating the
+ storage link on an already existing WebID.
+
+
+ In that case, the new pod will not appear automatically in the pod selector on
+ Home .
+
+
+ Use Register new pod or
+ Add Pod URL , paste the new pod container URL, and
+ click Register Pod .
+
+
+ After registration, switch to that pod from the selector if you want to start working
+ in it immediately.
+
+
+
+
diff --git a/src/components/Guides/PodBrowserGuide.vue b/src/components/Guides/PodBrowserGuide.vue
index 3500bc0..6f943dd 100644
--- a/src/components/Guides/PodBrowserGuide.vue
+++ b/src/components/Guides/PodBrowserGuide.vue
@@ -86,6 +86,12 @@
Containers include direct child-item counts.
+
+ Container Direct size is calculated only
+ when you open that container’s details. It sums the files directly inside
+ the container, caches the result for the session, and recomputes after
+ browser actions like move, rename, delete, or create container.
+
diff --git a/src/components/PodBrowser.vue b/src/components/PodBrowser.vue
index 4b1911d..e437ada 100644
--- a/src/components/PodBrowser.vue
+++ b/src/components/PodBrowser.vue
@@ -18,6 +18,13 @@
+
+
@@ -59,6 +66,10 @@
+
+
+ Create empty container
+
+ Add a new child container inside the currently selected container.
+
+
+
+
+
+ Create Container
+
+
+
+ {{ createContainerFeedback }}
+
+
+
@@ -126,7 +166,7 @@
containerCheck(url) ? "folder" : "description"
}}
- {{ url }}
+ {{ getItemName(url) }}
@@ -204,17 +244,13 @@
{{
- itemDetails.itemType === "Container" ? "folder_copy" : "save"
+ itemDetails.itemType === "Container" ? "save" : "save"
}}
{{
- itemDetails.itemType === "Container" ? "Direct items" : "File size"
- }}
- {{
- itemDetails.itemType === "Container"
- ? (itemDetails.directChildren ?? "Not available")
- : (itemDetails.sizeLabel || "Not available")
+ itemDetails.itemType === "Container" ? "Direct size" : "Size"
}}
+ {{ itemDetails.sizeLabel || "Not available" }}
@@ -269,6 +305,18 @@
{{ itemDetails.contentType || "Unknown" }}
+
+
+ folder_copy
+ Direct items
+
+ {{
+ itemDetails.directChildren ?? "Not available"
+ }}
+
link
@@ -303,6 +351,13 @@
{{ downloadFeedback }}
+
+
+
+
diff --git a/src/composables/usePodResourceInspector.ts b/src/composables/usePodResourceInspector.ts
new file mode 100644
index 0000000..d3e5ae1
--- /dev/null
+++ b/src/composables/usePodResourceInspector.ts
@@ -0,0 +1,226 @@
+import { computed, ref, watch, type Ref } from "vue";
+import {
+ analyzeResourceStructure,
+ buildCsvPreviewTable,
+ fetchResourceFullText,
+ fetchResourcePreview,
+ saveResourceContent,
+ validateResourceContent,
+ type CsvPreviewTable,
+ type ResourceFormatInfo,
+ type ResourceStructureSummary,
+ type ResourceValidationResult,
+} from "../services/solid/resourceInspector";
+
+export function usePodResourceInspector(
+ resourceUrl: Ref,
+ contentType: Ref
+) {
+ const isOpen = ref(false);
+ const previewLoaded = ref(false);
+ const isLoadingPreview = ref(false);
+ const isLoadingFull = ref(false);
+ const isEditing = ref(false);
+ const isSaving = ref(false);
+ const previewExpanded = ref(false);
+ const previewText = ref("");
+ const fullText = ref("");
+ const editedText = ref("");
+ const previewTruncated = ref(false);
+ const previewBytes = ref(0);
+ const errorMessage = ref("");
+ const saveMessage = ref("");
+ const formatInfo = ref({
+ format: null,
+ label: "Unknown",
+ contentType: null,
+ editable: false,
+ supported: false,
+ });
+ const validation = ref(null);
+ const csvPreview = ref(null);
+ const structureSummary = ref(null);
+ // Debounced validation avoids reparsing larger files on every keystroke.
+ let validationTimer: ReturnType | null = null;
+
+ function resetState() {
+ if (validationTimer !== null) {
+ clearTimeout(validationTimer);
+ validationTimer = null;
+ }
+ isOpen.value = false;
+ previewLoaded.value = false;
+ isLoadingPreview.value = false;
+ isLoadingFull.value = false;
+ isEditing.value = false;
+ isSaving.value = false;
+ previewExpanded.value = false;
+ previewText.value = "";
+ fullText.value = "";
+ editedText.value = "";
+ previewTruncated.value = false;
+ previewBytes.value = 0;
+ errorMessage.value = "";
+ saveMessage.value = "";
+ validation.value = null;
+ csvPreview.value = null;
+ structureSummary.value = null;
+ }
+
+ async function runValidation(text: string) {
+ validation.value = await validateResourceContent(formatInfo.value, text);
+ csvPreview.value =
+ formatInfo.value.format === "csv" ? buildCsvPreviewTable(text) : null;
+ structureSummary.value = analyzeResourceStructure(formatInfo.value, text);
+ }
+
+ async function loadPreview() {
+ isLoadingPreview.value = true;
+ errorMessage.value = "";
+ saveMessage.value = "";
+ try {
+ const preview = await fetchResourcePreview(resourceUrl.value);
+ previewLoaded.value = true;
+ previewText.value = preview.text;
+ previewTruncated.value = preview.truncated;
+ previewBytes.value = preview.byteLength;
+ formatInfo.value = {
+ ...preview.formatInfo,
+ contentType: preview.formatInfo.contentType || contentType.value,
+ };
+ await runValidation(preview.text);
+ previewExpanded.value = false;
+ } catch (error) {
+ errorMessage.value =
+ error instanceof Error ? error.message : "Could not load file preview.";
+ } finally {
+ isLoadingPreview.value = false;
+ }
+ }
+
+ async function toggleOpen() {
+ isOpen.value = !isOpen.value;
+ if (isOpen.value && !previewLoaded.value && !isLoadingPreview.value) {
+ await loadPreview();
+ }
+ }
+
+ async function enterEditMode() {
+ if (!previewLoaded.value) {
+ await loadPreview();
+ }
+ isLoadingFull.value = true;
+ errorMessage.value = "";
+ try {
+ const full = await fetchResourceFullText(resourceUrl.value);
+ fullText.value = full.text;
+ editedText.value = full.text;
+ previewBytes.value = full.byteLength;
+ previewTruncated.value = false;
+ formatInfo.value = {
+ ...full.formatInfo,
+ contentType: full.formatInfo.contentType || contentType.value,
+ };
+ isEditing.value = true;
+ await runValidation(full.text);
+ } catch (error) {
+ errorMessage.value =
+ error instanceof Error ? error.message : "Could not load full file for editing.";
+ } finally {
+ isLoadingFull.value = false;
+ }
+ }
+
+ function cancelEdit() {
+ isEditing.value = false;
+ previewExpanded.value = false;
+ editedText.value = fullText.value;
+ saveMessage.value = "";
+ errorMessage.value = "";
+ void runValidation(fullText.value || previewText.value);
+ }
+
+ function togglePreviewExpansion() {
+ previewExpanded.value = !previewExpanded.value;
+ }
+
+ function scheduleValidation() {
+ if (validationTimer !== null) {
+ clearTimeout(validationTimer);
+ }
+
+ validationTimer = setTimeout(() => {
+ void runValidation(editedText.value);
+ }, 450);
+ }
+
+ async function saveEdits() {
+ if (!isEditing.value) {
+ return;
+ }
+
+ isSaving.value = true;
+ errorMessage.value = "";
+ saveMessage.value = "";
+
+ try {
+ const currentValidation = await validateResourceContent(
+ formatInfo.value,
+ editedText.value
+ );
+ validation.value = currentValidation;
+ if (!currentValidation.valid) {
+ errorMessage.value = "Fix validation issues before saving this file.";
+ return;
+ }
+
+ await saveResourceContent(resourceUrl.value, editedText.value, formatInfo.value);
+ fullText.value = editedText.value;
+ previewText.value = editedText.value;
+ previewTruncated.value = false;
+ saveMessage.value = "Saved changes to the pod resource.";
+ isEditing.value = false;
+ } catch (error) {
+ errorMessage.value =
+ error instanceof Error ? error.message : "Could not save resource changes.";
+ } finally {
+ isSaving.value = false;
+ }
+ }
+
+ watch(resourceUrl, () => {
+ resetState();
+ });
+
+ const displayText = computed(() =>
+ isEditing.value ? editedText.value : previewText.value
+ );
+
+ return {
+ isOpen,
+ previewLoaded,
+ isLoadingPreview,
+ isLoadingFull,
+ isEditing,
+ isSaving,
+ previewExpanded,
+ previewText,
+ previewTruncated,
+ previewBytes,
+ displayText,
+ editedText,
+ errorMessage,
+ saveMessage,
+ formatInfo,
+ validation,
+ csvPreview,
+ structureSummary,
+ toggleOpen,
+ loadPreview,
+ enterEditMode,
+ cancelEdit,
+ togglePreviewExpansion,
+ scheduleValidation,
+ saveEdits,
+ };
+}
diff --git a/src/services/solid/fileUpload.ts b/src/services/solid/fileUpload.ts
index d104132..433ccec 100644
--- a/src/services/solid/fileUpload.ts
+++ b/src/services/solid/fileUpload.ts
@@ -133,6 +133,36 @@ export async function getPodResourceDownload(resourceUrl: string) {
);
}
+/**
+ * Creates an empty container inside an existing parent container.
+ *
+ * @param parentContainerUrl The existing container where the new child container should live.
+ * @param containerName The child container name without path separators.
+ * @returns The created container URL when successful, or "error" on failure.
+ */
+export async function createPodContainer(
+ parentContainerUrl: string,
+ containerName: string
+): Promise {
+ const sanitizedName = containerName.trim().replace(/^\/+|\/+$/g, "");
+ if (!sanitizedName || sanitizedName.includes("/")) {
+ return "error";
+ }
+
+ const normalizedParent = parentContainerUrl.endsWith("/")
+ ? parentContainerUrl
+ : `${parentContainerUrl}/`;
+ const targetContainerUrl = `${normalizedParent}${sanitizedName}/`;
+
+ try {
+ await createContainerAt(targetContainerUrl, { fetch });
+ return targetContainerUrl;
+ } catch (error) {
+ console.error(`Error creating container ${targetContainerUrl}:`, error);
+ return "error";
+ }
+}
+
/**
* Deletes a file from a Solid Pod using the @inrupt/solid-client method deleteFile().
*
diff --git a/src/services/solid/resourceInspector.ts b/src/services/solid/resourceInspector.ts
new file mode 100644
index 0000000..44ecb0b
--- /dev/null
+++ b/src/services/solid/resourceInspector.ts
@@ -0,0 +1,471 @@
+import { overwriteFile } from "@inrupt/solid-client";
+import { fetch as solidFetch } from "@inrupt/solid-client-authn-browser";
+import Papa from "papaparse";
+import jsonld from "jsonld";
+import { Parser as N3Parser } from "n3";
+
+export type SupportedInspectorFormat =
+ | "txt"
+ | "csv"
+ | "json"
+ | "jsonld"
+ | "ttl"
+ | "nt"
+ | "nq"
+ | "trig"
+ | "n3";
+
+export interface ResourceFormatInfo {
+ format: SupportedInspectorFormat | null;
+ label: string;
+ contentType: string | null;
+ editable: boolean;
+ supported: boolean;
+}
+
+export interface ResourcePreviewResult {
+ text: string;
+ formatInfo: ResourceFormatInfo;
+ truncated: boolean;
+ byteLength: number;
+}
+
+export interface ResourceValidationResult {
+ valid: boolean;
+ summary: string;
+ details: string[];
+}
+
+export interface ResourceInspectorOptions {
+ maxBytes?: number;
+ fetchFn?: typeof fetch;
+}
+
+export interface CsvPreviewTable {
+ headers: string[];
+ rows: string[][];
+ parseErrors: string[];
+}
+
+export interface ResourceStructureSummary {
+ title: string;
+ value: string;
+}
+
+const DEFAULT_PREVIEW_BYTES = 256 * 1024;
+
+const CONTENT_TYPE_BY_FORMAT: Record = {
+ txt: "text/plain",
+ csv: "text/csv",
+ json: "application/json",
+ jsonld: "application/ld+json",
+ ttl: "text/turtle",
+ nt: "application/n-triples",
+ nq: "application/n-quads",
+ trig: "application/trig",
+ n3: "text/n3",
+};
+
+const FORMAT_LABELS: Record = {
+ txt: "Plain text",
+ csv: "CSV",
+ json: "JSON",
+ jsonld: "JSON-LD",
+ ttl: "Turtle",
+ nt: "N-Triples",
+ nq: "N-Quads",
+ trig: "TriG",
+ n3: "Notation3",
+};
+
+const FORMAT_BY_EXTENSION: Record = {
+ txt: "txt",
+ csv: "csv",
+ json: "json",
+ jsonld: "jsonld",
+ ttl: "ttl",
+ turtle: "ttl",
+ nt: "nt",
+ nq: "nq",
+ trig: "trig",
+ n3: "n3",
+};
+
+const FORMAT_BY_CONTENT_TYPE: Array<[string, SupportedInspectorFormat]> = [
+ ["text/plain", "txt"],
+ ["text/csv", "csv"],
+ ["application/json", "json"],
+ ["application/ld+json", "jsonld"],
+ ["text/turtle", "ttl"],
+ ["application/n-triples", "nt"],
+ ["application/n-quads", "nq"],
+ ["application/trig", "trig"],
+ ["text/n3", "n3"],
+];
+
+function stripContentTypeParameters(contentType: string | null): string | null {
+ return contentType ? contentType.split(";")[0].trim().toLowerCase() : null;
+}
+
+function inferFormatFromUrl(resourceUrl: string): SupportedInspectorFormat | null {
+ const cleanUrl = resourceUrl.split(/[?#]/)[0];
+ const extension = cleanUrl.split(".").pop()?.toLowerCase();
+ return extension ? FORMAT_BY_EXTENSION[extension] || null : null;
+}
+
+export function detectResourceFormat(
+ resourceUrl: string,
+ contentType: string | null
+): ResourceFormatInfo {
+ const normalizedType = stripContentTypeParameters(contentType);
+ const contentTypeMatch = FORMAT_BY_CONTENT_TYPE.find(
+ ([candidate]) => candidate === normalizedType
+ )?.[1];
+ const format = contentTypeMatch || inferFormatFromUrl(resourceUrl);
+
+ if (!format) {
+ return {
+ format: null,
+ label: "Unsupported preview format",
+ contentType: normalizedType,
+ editable: false,
+ supported: false,
+ };
+ }
+
+ return {
+ format,
+ label: FORMAT_LABELS[format],
+ contentType: normalizedType || CONTENT_TYPE_BY_FORMAT[format],
+ editable: true,
+ supported: true,
+ };
+}
+
+async function readStreamWithLimit(
+ response: Response,
+ maxBytes: number
+): Promise<{ bytes: Uint8Array; truncated: boolean }> {
+ if (!response.body) {
+ const arrayBuffer = await response.arrayBuffer();
+ const bytes = new Uint8Array(arrayBuffer);
+ return {
+ bytes: bytes.slice(0, maxBytes),
+ truncated: bytes.length > maxBytes,
+ };
+ }
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let received = 0;
+ let truncated = false;
+
+ while (received < maxBytes) {
+ const { done, value } = await reader.read();
+ if (done || !value) {
+ break;
+ }
+
+ const remaining = maxBytes - received;
+ if (value.byteLength > remaining) {
+ chunks.push(value.slice(0, remaining));
+ received += remaining;
+ truncated = true;
+ await reader.cancel();
+ break;
+ }
+
+ chunks.push(value);
+ received += value.byteLength;
+ }
+
+ if (!truncated && received >= maxBytes) {
+ truncated = true;
+ await reader.cancel();
+ }
+
+ const bytes = new Uint8Array(received);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+
+ return { bytes, truncated };
+}
+
+function ensureSuccessfulResponse(response: Response, resourceUrl: string): Response {
+ if (!response.ok) {
+ throw new Error(
+ `Could not load file preview (${response.status} ${response.statusText}) for ${resourceUrl}.`
+ );
+ }
+ return response;
+}
+
+/**
+ * Fetches a bounded preview of a pod resource so large files do not have to be
+ * loaded fully just to show a preview inside the browser.
+ */
+export async function fetchResourcePreview(
+ resourceUrl: string,
+ options: ResourceInspectorOptions = {}
+): Promise {
+ const maxBytes = options.maxBytes ?? DEFAULT_PREVIEW_BYTES;
+ const fetchFn = options.fetchFn ?? solidFetch;
+
+ const response = ensureSuccessfulResponse(
+ await fetchFn(resourceUrl, {
+ headers: {
+ Range: `bytes=0-${maxBytes - 1}`,
+ },
+ }),
+ resourceUrl
+ );
+
+ const { bytes, truncated } = await readStreamWithLimit(response, maxBytes);
+ const previewText = new TextDecoder().decode(bytes);
+ const formatInfo = detectResourceFormat(
+ resourceUrl,
+ response.headers.get("content-type")
+ );
+
+ return {
+ text: previewText,
+ formatInfo,
+ truncated,
+ byteLength: bytes.byteLength,
+ };
+}
+
+/**
+ * Loads full text only when the user explicitly enters edit mode.
+ */
+export async function fetchResourceFullText(
+ resourceUrl: string,
+ fetchFn: typeof fetch = solidFetch
+): Promise {
+ const response = ensureSuccessfulResponse(await fetchFn(resourceUrl), resourceUrl);
+ const text = await response.text();
+ const formatInfo = detectResourceFormat(
+ resourceUrl,
+ response.headers.get("content-type")
+ );
+
+ return {
+ text,
+ formatInfo,
+ truncated: false,
+ byteLength: new TextEncoder().encode(text).byteLength,
+ };
+}
+
+function toValidationResult(
+ valid: boolean,
+ summary: string,
+ details: string[] = []
+): ResourceValidationResult {
+ return { valid, summary, details };
+}
+
+async function validateRdfContent(
+ text: string,
+ format: SupportedInspectorFormat
+): Promise {
+ try {
+ const parser = new N3Parser({ format: CONTENT_TYPE_BY_FORMAT[format] });
+ parser.parse(text);
+ return toValidationResult(true, `${FORMAT_LABELS[format]} syntax is valid.`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown RDF parse error.";
+ return toValidationResult(false, `${FORMAT_LABELS[format]} syntax is invalid.`, [message]);
+ }
+}
+
+export async function validateResourceContent(
+ formatInfo: ResourceFormatInfo,
+ text: string
+): Promise {
+ if (!formatInfo.format) {
+ return toValidationResult(false, "This file format is not supported for inline inspection.");
+ }
+
+ switch (formatInfo.format) {
+ case "txt":
+ return toValidationResult(true, "Plain text does not require structural validation.");
+ case "json":
+ try {
+ JSON.parse(text);
+ return toValidationResult(true, "JSON syntax is valid.");
+ } catch (error) {
+ return toValidationResult(false, "JSON syntax is invalid.", [
+ error instanceof Error ? error.message : "Unknown JSON parse error.",
+ ]);
+ }
+ case "jsonld":
+ try {
+ const parsed = JSON.parse(text);
+ await jsonld.expand(parsed);
+ return toValidationResult(true, "JSON-LD structure is valid.");
+ } catch (error) {
+ return toValidationResult(false, "JSON-LD structure is invalid.", [
+ error instanceof Error ? error.message : "Unknown JSON-LD validation error.",
+ ]);
+ }
+ case "csv": {
+ const result = Papa.parse(text, {
+ skipEmptyLines: false,
+ });
+ if (result.errors.length > 0) {
+ return toValidationResult(
+ false,
+ "CSV contains parse warnings.",
+ result.errors.map(
+ (entry) =>
+ `${entry.message}${typeof entry.row === "number" ? ` (row ${entry.row + 1})` : ""}`
+ )
+ );
+ }
+ return toValidationResult(true, "CSV parsed successfully.");
+ }
+ case "ttl":
+ case "nt":
+ case "nq":
+ case "trig":
+ case "n3":
+ return validateRdfContent(text, formatInfo.format);
+ default:
+ return toValidationResult(false, "Unsupported file format.");
+ }
+}
+
+export function buildCsvPreviewTable(
+ text: string,
+ maxRows = 20
+): CsvPreviewTable | null {
+ const result = Papa.parse(text, {
+ skipEmptyLines: false,
+ preview: maxRows,
+ });
+
+ if (!Array.isArray(result.data)) {
+ return null;
+ }
+
+ const normalizedRows = result.data.map((row) =>
+ Array.isArray(row) ? row.map((value) => String(value)) : [String(row)]
+ );
+ const [headerRow, ...bodyRows] = normalizedRows;
+
+ return {
+ headers: headerRow || [],
+ rows: bodyRows,
+ parseErrors: result.errors.map((entry) => entry.message),
+ };
+}
+
+function countJsonObjects(value: unknown): number {
+ if (Array.isArray(value)) {
+ return value.reduce((count, entry) => count + countJsonObjects(entry), 0);
+ }
+
+ if (value && typeof value === "object") {
+ return (
+ 1 +
+ Object.values(value as Record).reduce(
+ (count, entry) => count + countJsonObjects(entry),
+ 0
+ )
+ );
+ }
+
+ return 0;
+}
+
+/**
+ * Produces a compact structural summary so the UI can surface file shape at a glance.
+ */
+export function analyzeResourceStructure(
+ formatInfo: ResourceFormatInfo,
+ text: string
+): ResourceStructureSummary | null {
+ if (!formatInfo.format) {
+ return null;
+ }
+
+ switch (formatInfo.format) {
+ case "csv": {
+ const result = Papa.parse(text, { skipEmptyLines: false });
+ const rows = Array.isArray(result.data) ? result.data.length : 0;
+ const columns = Array.isArray(result.data)
+ ? result.data.reduce((max, row) => {
+ const width = Array.isArray(row) ? row.length : 1;
+ return Math.max(max, width);
+ }, 0)
+ : 0;
+ return {
+ title: "CSV dimensions",
+ value: `${rows} rows / ${columns} columns`,
+ };
+ }
+ case "txt": {
+ const rows = text.length === 0 ? 0 : text.split(/\r?\n/).length;
+ const columns = text.split(/\r?\n/).reduce((max, line) => Math.max(max, line.length), 0);
+ return {
+ title: "Text dimensions",
+ value: `${rows} rows / ${columns} columns`,
+ };
+ }
+ case "json":
+ case "jsonld": {
+ try {
+ const parsed = JSON.parse(text);
+ const objectCount = countJsonObjects(parsed);
+ return {
+ title: "JSON object count",
+ value: `${objectCount} ${objectCount === 1 ? "object" : "objects"}`,
+ };
+ } catch {
+ return null;
+ }
+ }
+ case "ttl":
+ case "nt":
+ case "nq":
+ case "trig":
+ case "n3": {
+ try {
+ const parser = new N3Parser({ format: CONTENT_TYPE_BY_FORMAT[formatInfo.format] });
+ const statements = parser.parse(text).length;
+ return {
+ title: "RDF triple count",
+ value: `${statements} ${statements === 1 ? "triple" : "triples"}`,
+ };
+ } catch {
+ return null;
+ }
+ }
+ default:
+ return null;
+ }
+}
+
+/**
+ * Saves edited text back into the pod using the same resource URL.
+ */
+export async function saveResourceContent(
+ resourceUrl: string,
+ text: string,
+ formatInfo: ResourceFormatInfo,
+ fetchFn: typeof fetch = solidFetch,
+ overwriteFn: typeof overwriteFile = overwriteFile
+): Promise {
+ const contentType =
+ formatInfo.contentType ||
+ (formatInfo.format ? CONTENT_TYPE_BY_FORMAT[formatInfo.format] : "text/plain");
+
+ await overwriteFn(resourceUrl, new Blob([text], { type: contentType }), {
+ contentType,
+ fetch: fetchFn,
+ });
+}
diff --git a/src/stores/containerSize.ts b/src/stores/containerSize.ts
new file mode 100644
index 0000000..2c1981e
--- /dev/null
+++ b/src/stores/containerSize.ts
@@ -0,0 +1,38 @@
+import { defineStore } from "pinia";
+
+interface DirectContainerSizeEntry {
+ bytes: number;
+ computedAt: number;
+ stale: boolean;
+}
+
+export const useContainerSizeStore = defineStore("containerSize", {
+ state: () => ({
+ directSizeByContainer: {} as Record,
+ }),
+ actions: {
+ getDirectSize(containerUrl: string): number | undefined {
+ const entry = this.directSizeByContainer[containerUrl];
+ if (!entry || entry.stale) {
+ return undefined;
+ }
+ return entry.bytes;
+ },
+ setDirectSize(containerUrl: string, bytes: number) {
+ this.directSizeByContainer[containerUrl] = {
+ bytes,
+ computedAt: Date.now(),
+ stale: false,
+ };
+ },
+ markStale(containerUrl: string) {
+ const entry = this.directSizeByContainer[containerUrl];
+ if (entry) {
+ entry.stale = true;
+ }
+ },
+ clear() {
+ this.directSizeByContainer = {};
+ },
+ },
+});
diff --git a/tests/components/AllComponentsSmoke.test.ts b/tests/components/AllComponentsSmoke.test.ts
index 9dc41f7..4b2168b 100644
--- a/tests/components/AllComponentsSmoke.test.ts
+++ b/tests/components/AllComponentsSmoke.test.ts
@@ -62,6 +62,7 @@ vi.mock("../../src/services/solid/fileUpload.ts", () => ({
file: new File(["mock"], "mock-file.ttl", { type: "text/turtle" }),
fileName: "mock-file.ttl",
})),
+ createPodContainer: vi.fn(async () => "https://pod.example/new-container/"),
deleteThing: vi.fn(async () => true),
}));
diff --git a/tests/components/PodBrowserFeatures.test.ts b/tests/components/PodBrowserFeatures.test.ts
index da085b8..32c8d8a 100644
--- a/tests/components/PodBrowserFeatures.test.ts
+++ b/tests/components/PodBrowserFeatures.test.ts
@@ -19,6 +19,7 @@ const {
getPodResourceDownloadMock,
movePodItemMock,
renamePodItemMock,
+ createPodContainerMock,
} = vi.hoisted(() => {
const mockUrls = [
"https://pod.example/",
@@ -27,6 +28,7 @@ const {
"https://pod.example/image.png",
];
const dctModified = "http://purl.org/dc/terms/modified";
+ const posixSize = "http://www.w3.org/ns/posix/stat#size";
return {
mockUrls,
@@ -37,6 +39,7 @@ const {
__things: [
{
[dctModified]: new Date("2026-03-26T09:15:00Z"),
+ [posixSize]: 4096,
},
],
internal_resourceInfo: {
@@ -67,8 +70,14 @@ const {
const value = thing[predicate];
return value instanceof Date ? value : null;
}),
- getIntegerMock: vi.fn(() => null),
- getDecimalMock: vi.fn(() => null),
+ getIntegerMock: vi.fn((thing: Record, predicate: string) => {
+ const value = thing[predicate];
+ return typeof value === "number" ? value : null;
+ }),
+ getDecimalMock: vi.fn((thing: Record, predicate: string) => {
+ const value = thing[predicate];
+ return typeof value === "number" ? value : null;
+ }),
getStringNoLocaleMock: vi.fn(() => null),
getPodResourceDownloadMock: vi.fn(async (url: string) => ({
file: new File(["downloaded"], url.split("/").pop() || "file.ttl", {
@@ -78,6 +87,7 @@ const {
})),
movePodItemMock: vi.fn(async () => "https://pod.example/archive/report.ttl"),
renamePodItemMock: vi.fn(async () => "https://pod.example/docs/renamed.ttl"),
+ createPodContainerMock: vi.fn(async () => "https://pod.example/new-container/"),
};
});
@@ -96,6 +106,7 @@ vi.mock("../../src/services/solid/fileUpload.ts", () => ({
getPodResourceDownload: getPodResourceDownloadMock,
movePodItem: movePodItemMock,
renamePodItem: renamePodItemMock,
+ createPodContainer: createPodContainerMock,
}));
vi.mock("../../src/services/solid/privacyEdit.ts", () => ({
@@ -120,9 +131,20 @@ vi.mock("@inrupt/solid-client-authn-browser", () => ({
const flushPromises = async () => {
await Promise.resolve();
await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await nextTick();
await nextTick();
};
+function findItemToggleByName(wrapper: ReturnType, itemName: string) {
+ return wrapper
+ .findAll(".item-toggle")
+ .find((toggle) => toggle.text().includes(itemName));
+}
+
function mountBrowser() {
const pinia = createPinia();
const authStore = useAuthStore(pinia);
@@ -140,6 +162,9 @@ function mountBrowser() {
stubs: {
PodRegistration: true,
PodBrowserGuide: true,
+ PodResourceInspector: {
+ template: 'inspector
',
+ },
ContainerNav: {
template: 'nav
',
},
@@ -203,11 +228,33 @@ describe("PodBrowser features", () => {
expect(wrapper.text()).toContain("Delete item");
});
+ it("computes and caches direct container size information in the detail view", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ await wrapper.findAll(".item-toggle")[0].trigger("click");
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("4.0 KB");
+ expect(wrapper.text()).toContain("Direct size");
+ expect(wrapper.text()).toContain("Direct items");
+ expect(getFileMock).toHaveBeenCalledTimes(2);
+
+ await wrapper.findAll(".item-toggle")[0].trigger("click");
+ await flushPromises();
+ await wrapper.findAll(".item-toggle")[0].trigger("click");
+ await flushPromises();
+
+ expect(getFileMock).toHaveBeenCalledTimes(2);
+ });
+
it("supports move destination modes and calls move helper", async () => {
const wrapper = mountBrowser();
await flushPromises();
- await wrapper.findAll(".item-toggle")[2].trigger("click");
+ const reportToggle = findItemToggleByName(wrapper, "report.ttl");
+ expect(reportToggle).toBeTruthy();
+ await reportToggle!.trigger("click");
await flushPromises();
await wrapper.findAll(".action-toggle")[0].trigger("click");
@@ -220,7 +267,7 @@ describe("PodBrowser features", () => {
await wrapper.get(".move-btn").trigger("click");
expect(movePodItemMock).toHaveBeenCalledWith(
- "https://pod.example/image.png",
+ "https://pod.example/docs/report.ttl",
"https://pod.example/archive/",
"https://pod.example/"
);
@@ -230,7 +277,9 @@ describe("PodBrowser features", () => {
const wrapper = mountBrowser();
await flushPromises();
- await wrapper.findAll(".item-toggle")[2].trigger("click");
+ const imageToggle = findItemToggleByName(wrapper, "image.png");
+ expect(imageToggle).toBeTruthy();
+ await imageToggle!.trigger("click");
await flushPromises();
await wrapper.findAll(".action-toggle")[1].trigger("click");
@@ -246,6 +295,25 @@ describe("PodBrowser features", () => {
);
});
+ it("creates an empty container inside the selected container", async () => {
+ const wrapper = mountBrowser();
+ await flushPromises();
+
+ await wrapper.get(".create-container-toggle").trigger("click");
+ await wrapper.get(".create-container-input").setValue("new-container");
+ await wrapper.get(".create-container-btn").trigger("click");
+ await flushPromises();
+
+ expect(createPodContainerMock).toHaveBeenCalledWith(
+ "https://pod.example/",
+ "new-container"
+ );
+ expect(wrapper.text()).toContain("Container created: https://pod.example/new-container/");
+ expect((wrapper.vm as unknown as { filteredUrls: string[] }).filteredUrls).toContain(
+ "https://pod.example/new-container/"
+ );
+ });
+
it("shows resource-only compact download action and triggers the download helper", async () => {
const wrapper = mountBrowser();
await flushPromises();
@@ -254,9 +322,12 @@ describe("PodBrowser features", () => {
await flushPromises();
expect(wrapper.find(".download-icon-button").exists()).toBe(false);
- await wrapper.findAll(".item-toggle")[2].trigger("click");
+ const imageToggle = findItemToggleByName(wrapper, "image.png");
+ expect(imageToggle).toBeTruthy();
+ await imageToggle!.trigger("click");
await flushPromises();
expect(wrapper.find(".download-icon-button").exists()).toBe(true);
+ expect(wrapper.find(".resource-inspector-stub").exists()).toBe(true);
await wrapper.get(".download-icon-button").trigger("click");
await flushPromises();
@@ -268,9 +339,9 @@ describe("PodBrowser features", () => {
it("renders specific parser diagnostics when a Turtle resource is malformed", async () => {
fetchDataMock.mockImplementation(async (url: string) => {
- if (url === "https://pod.example/image.png") {
+ if (url === "https://pod.example/docs/report.ttl") {
throw new Error(
- 'Encountered an error parsing the Resource at [https://pod.example/image.png] with content type [text/turtle]: Error: Expected punctuation to follow ""Azinphos-methyl ((#))"" on line 82.'
+ 'Encountered an error parsing the Resource at [https://pod.example/docs/report.ttl] with content type [text/turtle]: Error: Expected punctuation to follow ""Azinphos-methyl ((#))"" on line 82.'
);
}
return {
@@ -286,7 +357,9 @@ describe("PodBrowser features", () => {
const wrapper = mountBrowser();
await flushPromises();
- await wrapper.findAll(".item-toggle")[2].trigger("click");
+ const reportToggle = findItemToggleByName(wrapper, "report.ttl");
+ expect(reportToggle).toBeTruthy();
+ await reportToggle!.trigger("click");
await flushPromises();
expect(wrapper.find(".info-warning").exists()).toBe(true);
diff --git a/tests/components/PodRegistration.test.ts b/tests/components/PodRegistration.test.ts
new file mode 100644
index 0000000..937072e
--- /dev/null
+++ b/tests/components/PodRegistration.test.ts
@@ -0,0 +1,188 @@
+import { mount } from "@vue/test-utils";
+import { createPinia } from "pinia";
+import { nextTick } from "vue";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import PodRegistration from "../../src/components/PodRegistration.vue";
+import LandingGuide from "../../src/components/Guides/LandingGuide.vue";
+import { useAuthStore } from "../../src/stores/auth";
+
+const {
+ getPodURLsMock,
+ currentWebIdMock,
+ webIdDatasetMock,
+ checkUrlMock,
+ getSolidDatasetMock,
+} = vi.hoisted(() => ({
+ getPodURLsMock: vi.fn(),
+ currentWebIdMock: vi.fn(() => "https://user.example/profile/card#me"),
+ webIdDatasetMock: vi.fn(async () => {}),
+ checkUrlMock: vi.fn(() => false),
+ getSolidDatasetMock: vi.fn(async () => ({})),
+}));
+
+vi.mock("../../src/services/solid/login.ts", () => ({
+ currentWebId: currentWebIdMock,
+ getPodURLs: getPodURLsMock,
+}));
+
+vi.mock("../../src/services/solid/getData.ts", () => ({
+ webIdDataset: webIdDatasetMock,
+}));
+
+vi.mock("../../src/services/solid/privacyEdit.ts", () => ({
+ checkUrl: checkUrlMock,
+}));
+
+vi.mock("@inrupt/solid-client", () => ({
+ getSolidDataset: getSolidDatasetMock,
+}));
+
+vi.mock("@inrupt/solid-client-authn-browser", () => ({
+ fetch: vi.fn(),
+}));
+
+const flushPromises = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await nextTick();
+};
+
+function makeVuetifyStubs() {
+ return {
+ "v-btn": {
+ props: ["type", "disabled", "loading"],
+ emits: ["click"],
+ template:
+ ' ',
+ },
+ "v-icon": {
+ template: ' ',
+ },
+ "v-select": {
+ props: ["modelValue", "items"],
+ emits: ["update:modelValue"],
+ template: `
+
+ {{ item }}
+
+ `,
+ },
+ "v-text-field": {
+ props: ["modelValue", "label", "placeholder"],
+ emits: ["update:modelValue"],
+ template: `
+
+ `,
+ },
+ };
+}
+
+function mountPodRegistration(selectedPodUrl = "https://pod.example/") {
+ const pinia = createPinia();
+ const authStore = useAuthStore(pinia);
+ authStore.setAuth(true, "https://user.example/profile/card#me");
+ authStore.setSelectedPodUrl(selectedPodUrl);
+
+ return mount(PodRegistration, {
+ global: {
+ plugins: [pinia],
+ stubs: makeVuetifyStubs(),
+ },
+ });
+}
+
+describe("PodRegistration manual registration flow", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getPodURLsMock.mockResolvedValue(["https://pod.example/"]);
+ checkUrlMock.mockReturnValue(false);
+ getSolidDatasetMock.mockResolvedValue({});
+ });
+
+ it("shows manual registration controls even when a pod is already selected", async () => {
+ const wrapper = mountPodRegistration();
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("Register new pod");
+
+ await wrapper
+ .findAll(".v-btn-stub")
+ .find((button) => button.text().includes("Register new pod"))
+ ?.trigger("click");
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("Register a pod URL");
+ expect(wrapper.text()).toContain("your WebID already existed");
+ });
+
+ it("registers a manually entered pod URL for users who already have a pod", async () => {
+ let callCount = 0;
+ getPodURLsMock.mockImplementation(async () => {
+ callCount += 1;
+ return callCount > 1
+ ? ["https://pod.example/", "https://pod.example/new/"]
+ : ["https://pod.example/"];
+ });
+
+ const wrapper = mountPodRegistration();
+ await flushPromises();
+
+ await wrapper
+ .findAll(".v-btn-stub")
+ .find((button) => button.text().includes("Register new pod"))
+ ?.trigger("click");
+ await flushPromises();
+
+ await wrapper.get(".v-text-field-stub").setValue("https://pod.example/new/");
+ await wrapper.get("form").trigger("submit");
+ await flushPromises();
+
+ expect(webIdDatasetMock).toHaveBeenCalledWith(
+ "https://user.example/profile/card#me",
+ "https://pod.example/new/"
+ );
+ expect(wrapper.text()).toContain("Pod URL registered. Click Change Pod if you want to switch to it now.");
+ });
+
+ it("does not write a pod URL to the WebID when the target is not a readable Solid container", async () => {
+ getSolidDatasetMock.mockRejectedValueOnce(new Error("403 Forbidden"));
+
+ const wrapper = mountPodRegistration();
+ await flushPromises();
+
+ await wrapper
+ .findAll(".v-btn-stub")
+ .find((button) => button.text().includes("Register new pod"))
+ ?.trigger("click");
+ await flushPromises();
+
+ await wrapper.get(".v-text-field-stub").setValue("https://pod.example/not-readable/");
+ await wrapper.get("form").trigger("submit");
+ await flushPromises();
+
+ expect(webIdDatasetMock).not.toHaveBeenCalled();
+ expect(wrapper.text()).toContain("could not be confirmed as a readable Solid pod container");
+ });
+});
+
+describe("Landing guide pod registration note", () => {
+ it("documents the manual registration workflow for newly created pods", async () => {
+ const wrapper = mount(LandingGuide);
+
+ await wrapper.get(".guide-toggle").trigger("click");
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("When you need to register a pod manually");
+ expect(wrapper.text()).toContain("provider did not add it to your WebID automatically");
+ expect(wrapper.text()).toContain("Register new pod");
+ });
+});
diff --git a/tests/components/PodResourceInspector.test.ts b/tests/components/PodResourceInspector.test.ts
new file mode 100644
index 0000000..134370d
--- /dev/null
+++ b/tests/components/PodResourceInspector.test.ts
@@ -0,0 +1,213 @@
+import { mount } from "@vue/test-utils";
+import { nextTick } from "vue";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import PodResourceInspector from "../../src/components/PodResourceInspector.vue";
+
+const {
+ fetchAclAgentsMock,
+ fetchPublicAccessMock,
+ fetchResourcePreviewMock,
+ fetchResourceFullTextMock,
+ validateResourceContentMock,
+ buildCsvPreviewTableMock,
+ analyzeResourceStructureMock,
+ saveResourceContentMock,
+} = vi.hoisted(() => ({
+ fetchAclAgentsMock: vi.fn(async () => ({
+ "https://user.example/profile/card#me": {
+ read: true,
+ write: true,
+ append: true,
+ control: false,
+ },
+ })),
+ fetchPublicAccessMock: vi.fn(async () => ({
+ read: true,
+ append: false,
+ write: false,
+ control: false,
+ })),
+ fetchResourcePreviewMock: vi.fn(async () => ({
+ text: '{\n "hello": "world"\n}',
+ formatInfo: {
+ format: "json",
+ label: "JSON",
+ contentType: "application/json",
+ editable: true,
+ supported: true,
+ },
+ truncated: false,
+ byteLength: 22,
+ })),
+ fetchResourceFullTextMock: vi.fn(async () => ({
+ text: '{\n "hello": "world"\n}',
+ formatInfo: {
+ format: "json",
+ label: "JSON",
+ contentType: "application/json",
+ editable: true,
+ supported: true,
+ },
+ truncated: false,
+ byteLength: 22,
+ })),
+ validateResourceContentMock: vi.fn(async () => ({
+ valid: true,
+ summary: "JSON syntax is valid.",
+ details: [],
+ })),
+ buildCsvPreviewTableMock: vi.fn(() => null),
+ analyzeResourceStructureMock: vi.fn(() => ({
+ title: "JSON object count",
+ value: "1 object",
+ })),
+ saveResourceContentMock: vi.fn(async () => {}),
+}));
+
+vi.mock("../../src/services/solid/getData.ts", () => ({
+ fetchAclAgents: fetchAclAgentsMock,
+ fetchPublicAccess: fetchPublicAccessMock,
+}));
+
+vi.mock("../../src/services/solid/resourceInspector.ts", () => ({
+ fetchResourcePreview: fetchResourcePreviewMock,
+ fetchResourceFullText: fetchResourceFullTextMock,
+ validateResourceContent: validateResourceContentMock,
+ buildCsvPreviewTable: buildCsvPreviewTableMock,
+ analyzeResourceStructure: analyzeResourceStructureMock,
+ saveResourceContent: saveResourceContentMock,
+}));
+
+const flushPromises = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await nextTick();
+};
+
+type InspectorVm = {
+ permissionState: "unknown" | "confirmed" | "blocked";
+};
+
+function mountInspector() {
+ return mount(PodResourceInspector, {
+ props: {
+ resourceUrl: "https://pod.example/data.json",
+ contentType: "application/json",
+ webId: "https://user.example/profile/card#me",
+ },
+ global: {
+ stubs: {
+ "v-btn": {
+ props: ["disabled", "loading"],
+ emits: ["click"],
+ template:
+ ' ',
+ },
+ },
+ },
+ });
+}
+
+describe("PodResourceInspector", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("loads the preview lazily when the panel is opened", async () => {
+ const wrapper = mountInspector();
+
+ expect(fetchResourcePreviewMock).not.toHaveBeenCalled();
+ expect(wrapper.text()).not.toContain("JSON syntax is valid.");
+
+ await wrapper.get(".inspector-toggle").trigger("click");
+ await flushPromises();
+
+ expect(fetchResourcePreviewMock).toHaveBeenCalledOnce();
+ expect(fetchAclAgentsMock).toHaveBeenCalledOnce();
+ expect(wrapper.text()).toContain("JSON syntax is valid.");
+ expect(wrapper.text()).toContain("1 object");
+ expect(wrapper.text()).toContain('"hello": "world"');
+ });
+
+ it("collapses longer previews and lets the user expand them on demand", async () => {
+ fetchResourcePreviewMock.mockResolvedValueOnce({
+ text: Array.from({ length: 24 }, (_, index) => `line ${index + 1}`).join("\n"),
+ formatInfo: {
+ format: "txt",
+ label: "Plain text",
+ contentType: "text/plain",
+ editable: true,
+ supported: true,
+ },
+ truncated: false,
+ byteLength: 180,
+ });
+ analyzeResourceStructureMock.mockReturnValueOnce({
+ title: "Text dimensions",
+ value: "24 rows / 7 columns",
+ });
+
+ const wrapper = mountInspector();
+ await wrapper.get(".inspector-toggle").trigger("click");
+ await flushPromises();
+
+ expect(wrapper.find(".preview-expand-toggle").exists()).toBe(true);
+ expect(wrapper.find(".inspector-text-surface").classes()).toContain("collapsed");
+
+ await wrapper.get(".preview-expand-toggle").trigger("click");
+ await flushPromises();
+
+ expect(wrapper.find(".inspector-text-surface").classes()).not.toContain("collapsed");
+ expect(wrapper.text()).toContain("Show less");
+ });
+
+ it("supports entering edit mode and saving validated changes", async () => {
+ const wrapper = mountInspector();
+
+ await wrapper.get(".inspector-toggle").trigger("click");
+ await flushPromises();
+ await wrapper.get(".v-btn-stub").trigger("click");
+ await flushPromises();
+
+ const editor = wrapper.get(".inspector-editor");
+ await editor.setValue('{\n "hello": "updated"\n}');
+ await flushPromises();
+
+ const buttons = wrapper.findAll(".v-btn-stub");
+ await buttons[0].trigger("click");
+ await flushPromises();
+
+ expect(fetchResourceFullTextMock).toHaveBeenCalledOnce();
+ expect(saveResourceContentMock).toHaveBeenCalledWith(
+ "https://pod.example/data.json",
+ '{\n "hello": "updated"\n}',
+ expect.objectContaining({
+ format: "json",
+ })
+ );
+ expect(wrapper.text()).toContain("Saved changes to the pod resource.");
+ });
+
+ it("keeps editing unavailable when ACL data indicates read-only access", async () => {
+ fetchAclAgentsMock.mockResolvedValueOnce({
+ "https://user.example/profile/card#me": {
+ read: true,
+ write: false,
+ append: false,
+ control: false,
+ },
+ });
+ fetchPublicAccessMock.mockResolvedValueOnce({
+ read: true,
+ write: false,
+ append: false,
+ control: false,
+ });
+
+ const wrapper = mountInspector();
+ await wrapper.get(".inspector-toggle").trigger("click");
+ await flushPromises();
+
+ expect((wrapper.vm as unknown as InspectorVm).permissionState).toBe("blocked");
+ });
+});
diff --git a/tests/unit/containerSizeStore.test.ts b/tests/unit/containerSizeStore.test.ts
new file mode 100644
index 0000000..8890503
--- /dev/null
+++ b/tests/unit/containerSizeStore.test.ts
@@ -0,0 +1,15 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createPinia, setActivePinia } from "pinia";
+import { useContainerSizeStore } from "../../src/stores/containerSize.ts";
+
+test("container size store returns cached values until invalidated", () => {
+ setActivePinia(createPinia());
+ const store = useContainerSizeStore();
+
+ store.setDirectSize("https://pod.example/docs/", 2048);
+ assert.equal(store.getDirectSize("https://pod.example/docs/"), 2048);
+
+ store.markStale("https://pod.example/docs/");
+ assert.equal(store.getDirectSize("https://pod.example/docs/"), undefined);
+});
diff --git a/tests/unit/resourceInspector.test.ts b/tests/unit/resourceInspector.test.ts
new file mode 100644
index 0000000..a06831d
--- /dev/null
+++ b/tests/unit/resourceInspector.test.ts
@@ -0,0 +1,141 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ analyzeResourceStructure,
+ buildCsvPreviewTable,
+ detectResourceFormat,
+ fetchResourcePreview,
+ saveResourceContent,
+ validateResourceContent,
+} from "../../src/services/solid/resourceInspector.ts";
+
+test("detectResourceFormat resolves supported formats from extension and content type", () => {
+ const turtleInfo = detectResourceFormat(
+ "https://pod.example/data/report.ttl",
+ "text/turtle; charset=utf-8"
+ );
+ assert.equal(turtleInfo.format, "ttl");
+ assert.equal(turtleInfo.supported, true);
+
+ const csvInfo = detectResourceFormat(
+ "https://pod.example/data/report.unknown",
+ "text/csv"
+ );
+ assert.equal(csvInfo.format, "csv");
+});
+
+test("fetchResourcePreview truncates large responses without requiring a full file load", async () => {
+ const fetchFn = async () =>
+ new Response("abcdefghi", {
+ status: 200,
+ headers: { "content-type": "text/plain" },
+ });
+
+ const preview = await fetchResourcePreview("https://pod.example/note.txt", {
+ fetchFn: fetchFn as typeof fetch,
+ maxBytes: 5,
+ });
+
+ assert.equal(preview.text, "abcde");
+ assert.equal(preview.truncated, true);
+ assert.equal(preview.formatInfo.format, "txt");
+});
+
+test("validateResourceContent reports JSON and RDF syntax errors", async () => {
+ const jsonValidation = await validateResourceContent(
+ detectResourceFormat("https://pod.example/data.json", "application/json"),
+ '{"broken": }'
+ );
+ assert.equal(jsonValidation.valid, false);
+
+ const rdfValidation = await validateResourceContent(
+ detectResourceFormat("https://pod.example/data.ttl", "text/turtle"),
+ '@prefix ex: . ex:s ex:p "missing-dot"'
+ );
+ assert.equal(rdfValidation.valid, false);
+});
+
+test("buildCsvPreviewTable returns headers, rows, and parse warnings", () => {
+ const preview = buildCsvPreviewTable("name,age\nAlice,42\nBob");
+ assert.ok(preview);
+ assert.deepEqual(preview?.headers, ["name", "age"]);
+ assert.equal(preview?.rows.length, 2);
+});
+
+test("analyzeResourceStructure summarizes csv, json, text, and rdf resources", () => {
+ assert.deepEqual(
+ analyzeResourceStructure(
+ detectResourceFormat("https://pod.example/data.csv", "text/csv"),
+ "name,age\nAlice,42\nBob,20"
+ ),
+ {
+ title: "CSV dimensions",
+ value: "3 rows / 2 columns",
+ }
+ );
+
+ assert.deepEqual(
+ analyzeResourceStructure(
+ detectResourceFormat("https://pod.example/data.txt", "text/plain"),
+ "alpha\nbeta"
+ ),
+ {
+ title: "Text dimensions",
+ value: "2 rows / 5 columns",
+ }
+ );
+
+ assert.deepEqual(
+ analyzeResourceStructure(
+ detectResourceFormat("https://pod.example/data.json", "application/json"),
+ '{"root":{"child":1},"list":[{"leaf":true}]}'
+ ),
+ {
+ title: "JSON object count",
+ value: "3 objects",
+ }
+ );
+
+ assert.deepEqual(
+ analyzeResourceStructure(
+ detectResourceFormat("https://pod.example/data.ttl", "text/turtle"),
+ '@prefix ex: . ex:s ex:p ex:o .'
+ ),
+ {
+ title: "RDF triple count",
+ value: "1 triple",
+ }
+ );
+});
+
+test("saveResourceContent writes edited content with the inferred content type", async () => {
+ const calls: Array<{
+ url: string;
+ contentType: string;
+ payload: string;
+ }> = [];
+
+ await saveResourceContent(
+ "https://pod.example/data.json",
+ '{"ok":true}',
+ detectResourceFormat("https://pod.example/data.json", "application/json"),
+ (async () => new Response(null, { status: 200 })) as typeof fetch,
+ (async (url, data, options) => {
+ calls.push({
+ url: String(url),
+ contentType: String(options?.contentType),
+ payload: await (data as Blob).text(),
+ });
+ return {
+ internal_resourceInfo: {
+ sourceIri: String(url),
+ },
+ } as never;
+ }) as never
+ );
+
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].url, "https://pod.example/data.json");
+ assert.equal(calls[0].contentType, "application/json");
+ assert.equal(calls[0].payload, '{"ok":true}');
+});
From 79662fc96b4b5bef6bb914b3c5f8e5735f5a1a4c Mon Sep 17 00:00:00 2001
From: ecrum19
Date: Thu, 4 Jun 2026 11:43:10 +0200
Subject: [PATCH 2/2] added version update script
---
CITATION.bib | 2 +-
CITATION.cff | 4 +-
README.md | 21 +++--
package-lock.json | 4 +-
package.json | 3 +-
scripts/bump-version.mjs | 150 +++++++++++++++++++++++++++++++++
tests/unit/bumpVersion.test.ts | 55 ++++++++++++
vite.config.js | 2 +-
vitest.config.ts | 2 +-
9 files changed, 224 insertions(+), 19 deletions(-)
create mode 100644 scripts/bump-version.mjs
create mode 100644 tests/unit/bumpVersion.test.ts
diff --git a/CITATION.bib b/CITATION.bib
index f6033e2..7eaf9ba 100644
--- a/CITATION.bib
+++ b/CITATION.bib
@@ -2,7 +2,7 @@ @misc{solidcockpit_2026
author = {Crum, Elias},
title = {{Solid Cockpit}},
year = {2026},
- version = {1.0.0},
+ version = {1.3.0},
publisher = {GitHub},
howpublished = {\\url{https://github.com/KNowledgeOnWebScale/solid-cockpit}},
note = {Software. Web app: \\url{https://knowledgeonwebscale.github.io/solid-cockpit}.}
diff --git a/CITATION.cff b/CITATION.cff
index eda3b99..b52c533 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -2,8 +2,8 @@ cff-version: 1.2.0
message: "If you use Solid Cockpit in academic work, please cite it using the metadata below."
title: "Solid Cockpit"
type: software
-version: "1.0.0"
-date-released: 2026-03-04
+version: "1.3.0"
+date-released: 2026-06-04
license: "MIT"
authors:
- family-names: "Crum"
diff --git a/README.md b/README.md
index 7418c9d..cd1173c 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
# Solid Cockpit
-
-
+
+



@@ -75,7 +75,7 @@ Then upload `void.ttl` to the pod root using the app's `Data Upload` page.
If you use this tool in an academic publication, you can cite:
-`Crum, E. (2026). Solid Cockpit (Version 1.0.0) [Software]. GitHub. https://github.com/KNowledgeOnWebScale/solid-cockpit`
+`Crum, E. (2026). Solid Cockpit (Version 1.3.0) [Software]. GitHub. https://github.com/KNowledgeOnWebScale/solid-cockpit`
BibTeX:
@@ -84,7 +84,7 @@ BibTeX:
author = {Crum, Elias},
title = {{Solid Cockpit}},
year = {2026},
- version = {1.0.0},
+ version = {1.3.0},
publisher = {GitHub},
howpublished = {\url{https://github.com/KNowledgeOnWebScale/solid-cockpit}},
note = {Software. Web app: \url{https://knowledgeonwebscale.github.io/solid-cockpit}. Accessed: 2026-03-04}
@@ -265,13 +265,13 @@ CI compliance check:
Current app version:
-- `package.json` version: `1.0.0`
-- web-app release tag convention: `web-app-v`
-- current computed web-app tag: `web-app-v1.0.0`
+- `package.json` version: `1.3.0`
+- release tag convention: `v`
+- current computed release tag: `v1.3.0`
In-app visibility:
-- Footer displays semantic version (`vX.Y.Z`) and computed release tag (`web-app-vX.Y.Z`)
+- Footer displays semantic version (`vX.Y.Z`).
- Values are injected at build time from `package.json` via Vite defines
Recommended release workflow:
@@ -279,7 +279,7 @@ Recommended release workflow:
1. Update version:
```bash
-npm version X.Y.Z
+npm run version:bump -- X.Y.Z
```
2. Build and validate:
@@ -293,8 +293,7 @@ npm run build:highmem
```bash
git tag vX.Y.Z
-git tag web-app-vX.Y.Z
-git push origin vX.Y.Z web-app-vX.Y.Z
+git push origin vX.Y.Z
```
### Deployment
diff --git a/package-lock.json b/package-lock.json
index 84a25cb..557a2ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "solid-cockpit",
- "version": "1.2.1",
+ "version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "solid-cockpit",
- "version": "1.2.1",
+ "version": "1.3.0",
"license": "MIT",
"dependencies": {
"@comunica/context-entries": "^5.2.0",
diff --git a/package.json b/package.json
index 0567cac..bfd95dd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "solid-cockpit",
- "version": "1.2.1",
+ "version": "1.3.0",
"description": "The Solid Cockpit application for Solid Pod utilization",
"main": "~/src/App.vue",
"homepage": "https://knowledgeonwebscale.github.io/solid-cockpit",
@@ -15,6 +15,7 @@
},
"scripts": {
"dev": "vite",
+ "version:bump": "node ./scripts/bump-version.mjs",
"build": "vite build",
"build:highmem": "NODE_OPTIONS=--max-old-space-size=8192 vite build",
"serve": "vite preview",
diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs
new file mode 100644
index 0000000..f14326c
--- /dev/null
+++ b/scripts/bump-version.mjs
@@ -0,0 +1,150 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawnSync } from "node:child_process";
+
+const VERSION_RE = /^\d+\.\d+\.\d+$/;
+
+export function resolveNextVersion(currentVersion, requestedVersion) {
+ if (!requestedVersion) {
+ throw new Error("Provide a version like 1.2.3 or a bump keyword: patch, minor, major.");
+ }
+
+ if (VERSION_RE.test(requestedVersion)) {
+ return requestedVersion;
+ }
+
+ const parts = currentVersion.split(".").map((part) => Number(part));
+ if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part))) {
+ throw new Error(`Current package version is not valid semver: ${currentVersion}`);
+ }
+
+ const [major, minor, patch] = parts;
+ switch (requestedVersion) {
+ case "patch":
+ return `${major}.${minor}.${patch + 1}`;
+ case "minor":
+ return `${major}.${minor + 1}.0`;
+ case "major":
+ return `${major + 1}.0.0`;
+ default:
+ throw new Error(
+ `Unsupported bump target "${requestedVersion}". Use major, minor, patch, or an explicit X.Y.Z version.`
+ );
+ }
+}
+
+export function updateReadmeVersionReferences(content, version) {
+ const releaseTag = `v${version}`;
+ return content
+ .replace(
+ /!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-[^)]+-blue\)/,
+ ``
+ )
+ .replace(
+ /!\[Web App Tag\]\(https:\/\/img\.shields\.io\/badge\/web--app--tag-[^)]+-0a7ea4\)/,
+ ``
+ )
+ .replace(
+ /`Crum, E\. \(2026\)\. Solid Cockpit \(Version [^)]+\) \[Software\]\. GitHub\. https:\/\/github\.com\/KNowledgeOnWebScale\/solid-cockpit`/,
+ `\`Crum, E. (2026). Solid Cockpit (Version ${version}) [Software]. GitHub. https://github.com/KNowledgeOnWebScale/solid-cockpit\``
+ )
+ .replace(/version\s+=\s+\{[^}]+\},/, `version = {${version}},`)
+ .replace(/- `package\.json` version: `[^`]+`/, `- \`package.json\` version: \`${version}\``)
+ .replace(/- web-app release tag convention: `[^`]+`/, `- release tag convention: \`v\``)
+ .replace(/- current computed web-app tag: `[^`]+`/, `- current computed release tag: \`${releaseTag}\``)
+ .replace(/- Footer displays semantic version \(`vX\.Y\.Z`\) and computed release tag \(`[^`]+`\)/, "- Footer displays semantic version (`vX.Y.Z`).")
+ .replace(/npm version X\.Y\.Z/, "npm run version:bump -- X.Y.Z")
+ .replace(/git tag web-app-vX\.Y\.Z\ngit push origin vX\.Y\.Z web-app-vX\.Y\.Z/, "git push origin vX.Y.Z");
+}
+
+export function updateCitationCff(content, version, releaseDate) {
+ return content
+ .replace(/version:\s+"[^"]+"/, `version: "${version}"`)
+ .replace(/date-released:\s+\d{4}-\d{2}-\d{2}/, `date-released: ${releaseDate}`);
+}
+
+export function updateCitationBib(content, version) {
+ return content.replace(/version\s+=\s+\{[^}]+\},/, `version = {${version}},`);
+}
+
+function runVersionCommand(rootDir, nextVersion) {
+ const result = spawnSync(
+ "npm",
+ ["version", nextVersion, "--no-git-tag-version", "--allow-same-version"],
+ {
+ cwd: rootDir,
+ stdio: "inherit",
+ shell: process.platform === "win32",
+ }
+ );
+
+ if (result.status !== 0) {
+ throw new Error(`npm version failed with exit code ${result.status ?? "unknown"}`);
+ }
+}
+
+function syncStaticVersionReferences(rootDir, version, releaseDate) {
+ const readmePath = resolve(rootDir, "README.md");
+ const citationCffPath = resolve(rootDir, "CITATION.cff");
+ const citationBibPath = resolve(rootDir, "CITATION.bib");
+
+ writeFileSync(
+ readmePath,
+ updateReadmeVersionReferences(readFileSync(readmePath, "utf8"), version),
+ "utf8"
+ );
+ writeFileSync(
+ citationCffPath,
+ updateCitationCff(readFileSync(citationCffPath, "utf8"), version, releaseDate),
+ "utf8"
+ );
+ writeFileSync(
+ citationBibPath,
+ updateCitationBib(readFileSync(citationBibPath, "utf8"), version),
+ "utf8"
+ );
+}
+
+export function runBumpVersion(rootDir, requestedVersion, releaseDate = new Date().toISOString().slice(0, 10)) {
+ const packageJsonPath = resolve(rootDir, "package.json");
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
+ const currentVersion = packageJson.version ?? "0.0.0";
+ const nextVersion = resolveNextVersion(currentVersion, requestedVersion);
+
+ runVersionCommand(rootDir, nextVersion);
+ syncStaticVersionReferences(rootDir, nextVersion, releaseDate);
+
+ return {
+ currentVersion,
+ nextVersion,
+ releaseTag: `v${nextVersion}`,
+ releaseDate,
+ };
+}
+
+const isMainModule =
+ process.argv[1] &&
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
+
+if (isMainModule) {
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+ try {
+ const { currentVersion, nextVersion, releaseTag, releaseDate } = runBumpVersion(
+ rootDir,
+ process.argv[2]
+ );
+
+ console.log(
+ [
+ `Updated version ${currentVersion} -> ${nextVersion}`,
+ `Release tag: ${releaseTag}`,
+ `Release date: ${releaseDate}`,
+ ].join("\n")
+ );
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ }
+}
diff --git a/tests/unit/bumpVersion.test.ts b/tests/unit/bumpVersion.test.ts
new file mode 100644
index 0000000..21ef697
--- /dev/null
+++ b/tests/unit/bumpVersion.test.ts
@@ -0,0 +1,55 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ resolveNextVersion,
+ updateCitationBib,
+ updateCitationCff,
+ updateReadmeVersionReferences,
+} from "../../scripts/bump-version.mjs";
+
+test("resolveNextVersion supports semver bump keywords and explicit versions", () => {
+ assert.equal(resolveNextVersion("1.2.3", "patch"), "1.2.4");
+ assert.equal(resolveNextVersion("1.2.3", "minor"), "1.3.0");
+ assert.equal(resolveNextVersion("1.2.3", "major"), "2.0.0");
+ assert.equal(resolveNextVersion("1.2.3", "4.5.6"), "4.5.6");
+});
+
+test("updateReadmeVersionReferences updates badges, citation text, and release workflow text", () => {
+ const input = [
+ "",
+ "",
+ "`Crum, E. (2026). Solid Cockpit (Version 1.0.0) [Software]. GitHub. https://github.com/KNowledgeOnWebScale/solid-cockpit`",
+ " version = {1.0.0},",
+ "- `package.json` version: `1.0.0`",
+ "- web-app release tag convention: `web-app-v`",
+ "- current computed web-app tag: `web-app-v1.0.0`",
+ "- Footer displays semantic version (`vX.Y.Z`) and computed release tag (`web-app-vX.Y.Z`)",
+ "npm version X.Y.Z",
+ "git tag web-app-vX.Y.Z\ngit push origin vX.Y.Z web-app-vX.Y.Z",
+ ].join("\n");
+
+ const updated = updateReadmeVersionReferences(input, "1.2.1");
+
+ assert.match(updated, /badge\/version-1\.2\.1-blue/);
+ assert.match(updated, /web--app--tag-v1\.2\.1-0a7ea4/);
+ assert.match(updated, /Version 1\.2\.1/);
+ assert.match(updated, /version\s+=\s+\{1\.2\.1\},/);
+ assert.match(updated, /`package\.json` version: `1\.2\.1`/);
+ assert.match(updated, /release tag convention: `v`/);
+ assert.match(updated, /`v1\.2\.1`/);
+ assert.match(updated, /Footer displays semantic version \(`vX\.Y\.Z`\)\./);
+ assert.match(updated, /npm run version:bump -- X\.Y\.Z/);
+ assert.match(updated, /git push origin vX\.Y\.Z/);
+});
+
+test("citation helpers update version and release date fields", () => {
+ assert.equal(
+ updateCitationCff('version: "1.0.0"\ndate-released: 2026-03-04\n', "1.2.1", "2026-06-04"),
+ 'version: "1.2.1"\ndate-released: 2026-06-04\n'
+ );
+
+ assert.equal(
+ updateCitationBib(" version = {1.0.0},\n", "1.2.1"),
+ " version = {1.2.1},\n"
+ );
+});
diff --git a/vite.config.js b/vite.config.js
index 5aa7da1..d53e688 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -7,7 +7,7 @@ const packageJson = JSON.parse(
readFileSync(new URL("./package.json", import.meta.url), "utf-8")
);
const appVersion = packageJson.version ?? "0.0.0";
-const appReleaseTag = `web-app-v${appVersion}`;
+const appReleaseTag = `v${appVersion}`;
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
diff --git a/vitest.config.ts b/vitest.config.ts
index ea79f1b..fd2537f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,7 +7,7 @@ const packageJson = JSON.parse(
readFileSync(new URL("./package.json", import.meta.url), "utf-8")
);
const appVersion = packageJson.version ?? "0.0.0";
-const appReleaseTag = `web-app-v${appVersion}`;
+const appReleaseTag = `v${appVersion}`;
export default defineConfig({
plugins: [vue()],