Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add import & export of xattr tags #92

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
"electron-google-analytics4": "^1.1.1",
"electron-updater": "^6.1.7",
"fs-extra": "^11.1.0",
"fs-xattr": "^0.4.0",
"geo-coordinates-parser": "^1.6.3",
"libheif-js": "^1.17.1",
"mapbox-gl": "^3.0.1",
Expand Down
37 changes: 37 additions & 0 deletions src/frontend/containers/Settings/ImportExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,43 @@ export const ImportExport = observer(() => {
</ButtonGroup>
</div>

<div className="vstack">
<p>
Import tags from extended file attributes (KDE Dolphin tags).
</p>

<ButtonGroup>
<Button
text="Import tags from xattr"
onClick={() => {
fileStore.readExtendedAttributeTagsFromFiles();
}}
styling="outlined"
/>
<Button
text="Export tags to xattr"
onClick={() => setConfirmingMetadataExport(true)}
styling="outlined"
/>
<Alert
open={isConfirmingMetadataExport}
title="Are you sure you want to overwrite your files' tags?"
primaryButtonText="Export"
onClick={(button) => {
if (button === DialogButton.PrimaryButton) {
fileStore.writeExtendedAttributeTagsToFiles();
}
setConfirmingMetadataExport(false);
}}
>
<p>
This will overwrite any existing user.xdg.tags in those files with
OneFolder&#39;s tags. It is recommended to import all tags before writing new tags.
</p>
</Alert>
</ButtonGroup>
</div>

<h3>Backup Database as File</h3>

<Callout icon={IconSet.INFO}>
Expand Down
175 changes: 175 additions & 0 deletions src/frontend/stores/FileStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fse from 'fs-extra';
import { getAttribute, setAttribute } from 'fs-xattr'
import { action, computed, makeObservable, observable, runInAction } from 'mobx';

import { getThumbnailPath } from 'common/fs';
Expand Down Expand Up @@ -128,6 +129,106 @@ class FileStore {
}
}

@action.bound async readExtendedAttributeTagsFromFiles(): Promise<void> {
// NOTE: https://wiki.archlinux.org/title/Extended_attributes
// implemented KDE Dolphin tags and ratings
const toastKey = 'read-tags-from-file';
try {
const numFiles = this.fileList.length;
for (let i = 0; i < numFiles; i++) {
AppToaster.show(
{
message: `Reading tags from files ${((100 * i) / numFiles).toFixed(0)}%...`,
timeout: 0,
},
toastKey,
);
const file = runInAction(() => this.fileList[i]);

const absolutePath = file.absolutePath;

try {
// any of these fields may, or may not be present
let kdeTags = Buffer.from('')
let balooScore = Buffer.from('')
try {
// a buffer with comma separated strings
kdeTags = await getAttribute(absolutePath, 'user.xdg.tags')
} catch (e) {
console.error('Error reading user.xdg.tags for', absolutePath, e);
}
try {
// balooScore is 5/5 stars, but is double in xfattr
balooScore = await getAttribute(absolutePath, 'user.baloo.rating')
} catch (e) {
console.error('Error reading user.baloo.rating for', absolutePath, e);
}

// convert buffer to string, then split in array. Also remove trailing whitespace
let tagsNameHierarchies = kdeTags.toString().split(',').filter(String)
// if there is no score, skip adding a tag for it
if (balooScore.toString() != '') {
tagsNameHierarchies.push('score:' + balooScore.toString())
}

// Now that we know the tag names in file metadata, add them to the files in OneFolder
const { tagStore } = this.rootStore;
for (const tagHierarchy of tagsNameHierarchies) {
const match = tagStore.findByName(tagHierarchy[tagHierarchy.length - 1]);
if (match) {
// If there is a match to the leaf tag, just add it to the file
file.addTag(match);
} else {
// If there is no direct match to the leaf, insert it in the tag hierarchy: first check if any of its parents exist
// parent tags are written as: parentTag/subparent/subtag for example
if (tagHierarchy.includes('/')) {
let curTag = tagStore.root;
// further check for subparents
for (const nodeName of tagHierarchy.split('/')) {
const nodeMatch = tagStore.findByName(nodeName);
if (nodeMatch) {
curTag = nodeMatch;
} else {
curTag = await tagStore.create(curTag, nodeName);
}
}
file.addTag(curTag);
} else {
// base tag, not a parent tag
let curTag = tagStore.root;
const nodeMatch = tagStore.findByName(tagHierarchy);
if (nodeMatch) {
curTag = nodeMatch;
} else {
curTag = await tagStore.create(curTag, tagHierarchy);
}
file.addTag(curTag);
}
}
}
} catch (e) {
console.error('Could not import tags for', absolutePath, e);
}
}
AppToaster.show(
{
message: 'Reading tags from files... Done!',
timeout: 5000,
},
toastKey,
);
} catch (e) {
console.error('Could not read tags', e);
AppToaster.show(
{
message: 'Reading tags from files failed. Check the dev console for more details',
timeout: 5000,
},
toastKey,
);
}
}

// @action.bound async readFacesAnnotationsFromFiles(): Promise<void> {
// const toastKey = 'read-faces-annotations-from-file';
// try {
Expand Down Expand Up @@ -233,6 +334,80 @@ class FileStore {
}
}

@action.bound async writeExtendedAttributeTagsToFiles(): Promise<void> {
const toastKey = 'write-tags-to-file';
try {
const numFiles = this.fileList.length;
const tagFilePairs = runInAction(() =>
this.fileList.map((f) => ({
absolutePath: f.absolutePath,
tagHierarchy: Array.from(
f.tags,
action((t) => t.path),
),
})),
);
let lastToastVal = '0';
for (let i = 0; i < tagFilePairs.length; i++) {
const newToastVal = ((100 * i) / numFiles).toFixed(0);
if (lastToastVal !== newToastVal) {
lastToastVal = newToastVal;
AppToaster.show(
{
message: `Writing tags to files ${newToastVal}%...`,
timeout: 0,
},
toastKey,
);
}

const { absolutePath, tagHierarchy } = tagFilePairs[i];
try {
let tagArray = []
let balooScore = '0'
for (const tagH of tagHierarchy) {
// readExtendedAttributeTagsFromFiles creates score:# for balooScore
// tagH is an array; even with one element
if (tagH[0].includes('score')) {
balooScore = tagH[0].split(':')[1]
// skipping adding it to tagArray
continue
}
if (typeof(tagH) != 'string') {
// concatenate parents with their sub elements
tagArray.push(tagH.join('/'))
} else {
tagArray.push(tagH)
}
};

// tagArray now must be joined into one string, comma separated
await setAttribute(absolutePath, 'user.xdg.tags', tagArray.join(','));
await setAttribute(absolutePath, 'user.baloo.rating', String(balooScore));

} catch (e) {
console.error('Could not write tags to', absolutePath, tagHierarchy, e);
}
}
AppToaster.show(
{
message: 'Writing tags to files... Done!',
timeout: 5000,
},
toastKey,
);
} catch (e) {
console.error('Could not write tags', e);
AppToaster.show(
{
message: 'Writing tags to files failed. Check the dev console for more details',
timeout: 5000,
},
toastKey,
);
}
}

@computed get showsAllContent(): boolean {
return this.content === Content.All;
}
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4403,6 +4403,11 @@ fs-minipass@^2.0.0:
dependencies:
minipass "^3.0.0"

fs-xattr@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/fs-xattr/-/fs-xattr-0.4.0.tgz#30797399631287b740994a0bfab7822295e5f482"
integrity sha512-Lw90zx483YTGiHfR67IPtrbrZ+yBr1/W98v/iyTeSkUbixg/wrHfX5x9oMUOnirC5P7SZ5HlrpnIRMMqt8Ej0A==

fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
Expand Down