Skip to content

Commit 988f30a

Browse files
committed
add import & export of xattr tags
1 parent 0a5e418 commit 988f30a

File tree

4 files changed

+204
-0
lines changed

4 files changed

+204
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"electron-google-analytics4": "^1.1.1",
141141
"electron-updater": "^6.1.7",
142142
"fs-extra": "^11.1.0",
143+
"fs-xattr": "^0.4.0",
143144
"geo-coordinates-parser": "^1.6.3",
144145
"libheif-js": "^1.17.1",
145146
"mapbox-gl": "^3.0.1",

src/frontend/containers/Settings/ImportExport.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,43 @@ export const ImportExport = observer(() => {
124124
</ButtonGroup>
125125
</div>
126126

127+
<div className="vstack">
128+
<p>
129+
Import tags from extended file attributes (KDE Dolphin tags).
130+
</p>
131+
132+
<ButtonGroup>
133+
<Button
134+
text="Import tags from xattr"
135+
onClick={() => {
136+
fileStore.readExtendedAttributeTagsFromFiles();
137+
}}
138+
styling="outlined"
139+
/>
140+
<Button
141+
text="Export tags to xattr"
142+
onClick={() => setConfirmingMetadataExport(true)}
143+
styling="outlined"
144+
/>
145+
<Alert
146+
open={isConfirmingMetadataExport}
147+
title="Are you sure you want to overwrite your files' tags?"
148+
primaryButtonText="Export"
149+
onClick={(button) => {
150+
if (button === DialogButton.PrimaryButton) {
151+
fileStore.writeExtendedAttributeTagsToFiles();
152+
}
153+
setConfirmingMetadataExport(false);
154+
}}
155+
>
156+
<p>
157+
This will overwrite any existing user.xdg.tags in those files with
158+
OneFolder&#39;s tags. It is recommended to import all tags before writing new tags.
159+
</p>
160+
</Alert>
161+
</ButtonGroup>
162+
</div>
163+
127164
<h3>Backup Database as File</h3>
128165

129166
<Callout icon={IconSet.INFO}>

src/frontend/stores/FileStore.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fse from 'fs-extra';
2+
import { getAttribute, setAttribute } from 'fs-xattr'
23
import { action, computed, makeObservable, observable, runInAction } from 'mobx';
34

45
import { getThumbnailPath } from 'common/fs';
@@ -128,6 +129,92 @@ class FileStore {
128129
}
129130
}
130131

132+
@action.bound async readExtendedAttributeTagsFromFiles(): Promise<void> {
133+
// NOTE: https://wiki.archlinux.org/title/Extended_attributes
134+
// implemented KDE Dolphin tags and ratings
135+
const toastKey = 'read-tags-from-file';
136+
try {
137+
const numFiles = this.fileList.length;
138+
for (let i = 0; i < numFiles; i++) {
139+
AppToaster.show(
140+
{
141+
message: `Reading tags from files ${((100 * i) / numFiles).toFixed(0)}%...`,
142+
timeout: 0,
143+
},
144+
toastKey,
145+
);
146+
const file = runInAction(() => this.fileList[i]);
147+
148+
const absolutePath = file.absolutePath;
149+
150+
try {
151+
// a buffer with comma separated strings
152+
const kdeTags = await getAttribute(absolutePath, 'user.xdg.tags')
153+
// balooScore is 5/5 stars, but is double in xfattr
154+
const balooScore = await getAttribute(absolutePath, 'user.baloo.rating')
155+
// convert buffer to string, then split in array. Also remove trailing whitespace
156+
let tagsNameHierarchies = kdeTags.toString().split(',').filter(String)
157+
tagsNameHierarchies.push('score:' + balooScore)
158+
159+
// Now that we know the tag names in file metadata, add them to the files in OneFolder
160+
161+
const { tagStore } = this.rootStore;
162+
for (const tagHierarchy of tagsNameHierarchies) {
163+
const match = tagStore.findByName(tagHierarchy[tagHierarchy.length - 1]);
164+
if (match) {
165+
// If there is a match to the leaf tag, just add it to the file
166+
file.addTag(match);
167+
} else {
168+
// If there is no direct match to the leaf, insert it in the tag hierarchy: first check if any of its parents exist
169+
// parent tags are written as: parentTag/subparent/subtag for example
170+
if (tagHierarchy.includes('/')) {
171+
let curTag = tagStore.root;
172+
// further check for subparents
173+
for (const nodeName of tagHierarchy.split('/')) {
174+
const nodeMatch = tagStore.findByName(nodeName);
175+
if (nodeMatch) {
176+
curTag = nodeMatch;
177+
} else {
178+
curTag = await tagStore.create(curTag, nodeName);
179+
}
180+
}
181+
file.addTag(curTag);
182+
} else {
183+
// base tag, not a parent tag
184+
let curTag = tagStore.root;
185+
const nodeMatch = tagStore.findByName(tagHierarchy);
186+
if (nodeMatch) {
187+
curTag = nodeMatch;
188+
} else {
189+
curTag = await tagStore.create(curTag, tagHierarchy);
190+
}
191+
file.addTag(curTag);
192+
}
193+
}
194+
}
195+
} catch (e) {
196+
console.error('Could not import tags for', absolutePath, e);
197+
}
198+
}
199+
AppToaster.show(
200+
{
201+
message: 'Reading tags from files... Done!',
202+
timeout: 5000,
203+
},
204+
toastKey,
205+
);
206+
} catch (e) {
207+
console.error('Could not read tags', e);
208+
AppToaster.show(
209+
{
210+
message: 'Reading tags from files failed. Check the dev console for more details',
211+
timeout: 5000,
212+
},
213+
toastKey,
214+
);
215+
}
216+
}
217+
131218
// @action.bound async readFacesAnnotationsFromFiles(): Promise<void> {
132219
// const toastKey = 'read-faces-annotations-from-file';
133220
// try {
@@ -233,6 +320,80 @@ class FileStore {
233320
}
234321
}
235322

323+
@action.bound async writeExtendedAttributeTagsToFiles(): Promise<void> {
324+
const toastKey = 'write-tags-to-file';
325+
try {
326+
const numFiles = this.fileList.length;
327+
const tagFilePairs = runInAction(() =>
328+
this.fileList.map((f) => ({
329+
absolutePath: f.absolutePath,
330+
tagHierarchy: Array.from(
331+
f.tags,
332+
action((t) => t.path),
333+
),
334+
})),
335+
);
336+
let lastToastVal = '0';
337+
for (let i = 0; i < tagFilePairs.length; i++) {
338+
const newToastVal = ((100 * i) / numFiles).toFixed(0);
339+
if (lastToastVal !== newToastVal) {
340+
lastToastVal = newToastVal;
341+
AppToaster.show(
342+
{
343+
message: `Writing tags to files ${newToastVal}%...`,
344+
timeout: 0,
345+
},
346+
toastKey,
347+
);
348+
}
349+
350+
const { absolutePath, tagHierarchy } = tagFilePairs[i];
351+
try {
352+
let tagArray = []
353+
let balooScore = '0'
354+
for (const tagH of tagHierarchy) {
355+
// readExtendedAttributeTagsFromFiles creates score:# for balooScore
356+
// tagH is an array; even with one element
357+
if (tagH[0].includes('score') {
358+
balooScore = tagH[0].split(':')[1]
359+
// skipping adding it to tagArray
360+
continue
361+
}
362+
if (typeof(tagH) != 'string') {
363+
// concatenate parents with their sub elements
364+
tagArray.push(tagH.join('/'))
365+
} else {
366+
tagArray.push(tagH)
367+
}
368+
};
369+
370+
// tagArray now must be joined into one string, comma separated
371+
await setAttribute(absolutePath, 'user.xdg.tags', tagArray.join(','));
372+
await setAttribute(absolutePath, 'user.baloo.rating', String(balooScore));
373+
374+
} catch (e) {
375+
console.error('Could not write tags to', absolutePath, tagHierarchy, e);
376+
}
377+
}
378+
AppToaster.show(
379+
{
380+
message: 'Writing tags to files... Done!',
381+
timeout: 5000,
382+
},
383+
toastKey,
384+
);
385+
} catch (e) {
386+
console.error('Could not write tags', e);
387+
AppToaster.show(
388+
{
389+
message: 'Writing tags to files failed. Check the dev console for more details',
390+
timeout: 5000,
391+
},
392+
toastKey,
393+
);
394+
}
395+
}
396+
236397
@computed get showsAllContent(): boolean {
237398
return this.content === Content.All;
238399
}

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4403,6 +4403,11 @@ fs-minipass@^2.0.0:
44034403
dependencies:
44044404
minipass "^3.0.0"
44054405

4406+
fs-xattr@^0.4.0:
4407+
version "0.4.0"
4408+
resolved "https://registry.yarnpkg.com/fs-xattr/-/fs-xattr-0.4.0.tgz#30797399631287b740994a0bfab7822295e5f482"
4409+
integrity sha512-Lw90zx483YTGiHfR67IPtrbrZ+yBr1/W98v/iyTeSkUbixg/wrHfX5x9oMUOnirC5P7SZ5HlrpnIRMMqt8Ej0A==
4410+
44064411
fs.realpath@^1.0.0:
44074412
version "1.0.0"
44084413
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"

0 commit comments

Comments
 (0)