-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwherefrombegone.py
More file actions
269 lines (251 loc) · 11.7 KB
/
wherefrombegone.py
File metadata and controls
269 lines (251 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import sys
import os
from PySide6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QFileDialog, QLabel, QHeaderView, QCheckBox
)
from xattr_utils import get_where_from_attr, is_xattr_available, list_other_xattrs, remove_xattr, remove_all_other_xattrs, remove_where_from, PROTECTED_KEYS
from ui_utils import show_toast
class FileBrowser(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("WhereFromBegone")
self.resize(900, 500)
self.current_dir = self.get_default_dir()
self.init_ui()
self.populate_table()
def get_default_dir(self):
downloads = os.path.join(os.path.expanduser("~"), "Downloads")
return downloads if os.path.isdir(downloads) else os.getcwd()
def init_ui(self):
layout = QVBoxLayout(self)
# Directory bar
dir_layout = QHBoxLayout()
self.dir_label = QLabel(self.current_dir)
dir_layout.addWidget(self.dir_label)
self.change_btn = self.create_big_button("📂 Change Directory", self.change_directory)
dir_layout.addWidget(self.change_btn)
self.clear_all_btn = self.create_big_button("🧹 Clear All", self.clear_all_where_from)
dir_layout.addWidget(self.clear_all_btn)
layout.addLayout(dir_layout)
# Table
self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(["Filename", "Where From", ""])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.table.setColumnWidth(2, 120)
layout.addWidget(self.table)
# Status and bottom bar
bottom_layout = QHBoxLayout()
self.status = QLabel("ℹ️ Ready.")
bottom_layout.addWidget(self.status)
bottom_layout.addStretch(1)
self.show_hidden_checkbox = QCheckBox("Show Hidden Files")
self.show_hidden_checkbox.setChecked(False)
self.show_hidden_checkbox.toggled.connect(self.populate_table)
bottom_layout.addWidget(self.show_hidden_checkbox)
layout.addLayout(bottom_layout)
def create_big_button(self, text, slot):
btn = QPushButton(text)
btn.setStyleSheet("font-size: 18px; padding: 10px 24px;")
btn.clicked.connect(slot)
return btn
def get_files(self):
files = sorted(os.listdir(self.current_dir), key=lambda x: x.lower())
if not self.show_hidden_checkbox.isChecked():
files = [f for f in files if not f.startswith('.')]
return files
def populate_table(self):
self.table.setRowCount(0)
files = self.get_files()
is_macos = is_xattr_available()
for f in files:
full_path = os.path.join(self.current_dir, f)
if os.path.isfile(full_path):
where_from = get_where_from_attr(full_path)
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(f))
self.table.setItem(row, 1, QTableWidgetItem(where_from))
# Clear 'Where From' button
btn = QPushButton("🧹 Clear")
btn.setEnabled(
is_macos and bool(where_from) and not any(
s in where_from.lower() for s in ["not available", "not found"]
)
)
if not btn.isEnabled():
btn.setText("🔒 Clear")
btn.clicked.connect(lambda _, path=full_path: self.clear_where_from(path))
self.table.setCellWidget(row, 2, btn)
# Extra: Manage Other Xattr button
other_xattrs = list_other_xattrs(full_path) if is_macos else []
if other_xattrs:
btn_other = QPushButton("Manage Other Xattr")
btn_other.setEnabled(True)
btn_other.clicked.connect(lambda _, path=full_path: self.show_other_xattr_dialog(path))
else:
btn_other = QPushButton("No Other Xattr")
btn_other.setEnabled(False)
# Add the extra button to the row (as a widget in a new column)
if self.table.columnCount() < 4:
self.table.insertColumn(3)
self.table.setCellWidget(row, 3, btn_other)
# Set headers if extra column present
if self.table.columnCount() == 4:
self.table.setHorizontalHeaderLabels(["Filename", "Where From", "", "Other Xattr"])
self.status.setText(f"ℹ️ {len(files)} items in '{self.current_dir}'")
def show_other_xattr_dialog(self, filepath: str) -> None:
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QDialogButtonBox, QScrollArea, QWidget, QSizePolicy
if not is_xattr_available():
show_toast(self, "Viewing xattr is only available on macOS.", error=True)
return
other_xattrs = list_other_xattrs(filepath)
if not other_xattrs:
show_toast(self, "No other xattr to manage.")
return
dialog = QDialog(self)
dialog.setWindowTitle("Manage Other Xattr")
dialog.resize(600, 400)
layout = QVBoxLayout(dialog)
# Show file name as a label at the top
file_label = QLabel(os.path.basename(filepath))
file_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
layout.addWidget(file_label)
# Use a scroll area for the attribute list
scroll = QScrollArea(dialog)
scroll.setWidgetResizable(True)
attr_widget = QWidget()
attr_layout = QVBoxLayout(attr_widget)
attr_layout.setContentsMargins(0, 0, 0, 0)
attr_layout.setSpacing(8)
btns = []
protected = PROTECTED_KEYS
for k in other_xattrs:
h = QHBoxLayout()
h.setSpacing(12)
k_str = k.decode(errors="replace") if isinstance(k, bytes) else str(k)
lbl = QLabel(k_str)
lbl.setStyleSheet("font-size: 15px; min-width: 340px;")
h.addWidget(lbl)
btn = QPushButton("Clear")
btn.setStyleSheet("font-size: 15px; padding: 8px 18px;")
is_protected = False
for prot in protected:
if (isinstance(k, bytes) and k == prot) or (isinstance(k, str) and k == prot.decode()):
is_protected = True
break
if is_protected:
btn.setEnabled(False)
btn.setText("Protected")
else:
def make_clear_func(attr_key):
def clear():
err = remove_xattr(filepath, attr_key)
if not err:
show_toast(self, f"Cleared {attr_key} for {os.path.basename(filepath)}")
dialog.accept()
self.populate_table()
else:
show_toast(self, err, error=True)
return clear
btn.clicked.connect(make_clear_func(k))
h.addWidget(btn)
attr_layout.addLayout(h)
btns.append(btn)
attr_widget.setLayout(attr_layout)
scroll.setWidget(attr_widget)
layout.addWidget(scroll, stretch=1)
# Add 'Clear All' button (skips protected)
clearable = [k for k in other_xattrs if k not in protected]
clear_all_btn = QPushButton("Clear All")
clear_all_btn.setStyleSheet("font-size: 15px; padding: 8px 18px;")
if clearable:
def clear_all():
count = remove_all_other_xattrs(filepath)
if count:
show_toast(self, f"Cleared {count} other xattr(s) for {os.path.basename(filepath)}")
dialog.accept()
self.populate_table()
else:
show_toast(self, "No other xattr to clear.")
clear_all_btn.clicked.connect(clear_all)
else:
clear_all_btn.setEnabled(False)
# Button bar at the bottom
button_bar = QHBoxLayout()
button_bar.addStretch(1)
button_bar.addWidget(clear_all_btn)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
buttons.rejected.connect(dialog.reject)
button_bar.addWidget(buttons)
layout.addLayout(button_bar)
dialog.exec()
def clear_other_xattr(self, filepath: str) -> None:
if not is_xattr_available():
show_toast(self, "Clearing is only available on macOS.", error=True)
return
count = remove_all_other_xattrs(filepath)
if count:
show_toast(self, f"Cleared {count} other xattr(s) for {os.path.basename(filepath)}")
else:
show_toast(self, "No other xattr to clear.")
self.populate_table()
def change_directory(self):
new_dir = QFileDialog.getExistingDirectory(self, "Select Directory", self.current_dir)
if new_dir:
self.current_dir = new_dir
self.dir_label.setText(self.current_dir)
self.populate_table()
self.status.setText(f"📂 Changed directory to '{self.current_dir}'")
def can_clear_where_from(self, filepath):
where_from = get_where_from_attr(filepath)
return (
is_xattr_available()
and where_from
and "Not available" not in where_from
and "Not found" not in where_from
)
def clear_where_from(self, filepath: str) -> None:
if self.can_clear_where_from(filepath):
err = remove_where_from(filepath)
if not err:
show_toast(self, f"Cleared 'Where From' for {os.path.basename(filepath)}")
self.populate_table()
else:
show_toast(self, f"Failed to clear: {err}", error=True)
else:
show_toast(self, "Clearing 'Where From' is only available on macOS.", error=True)
def clear_all_where_from(self) -> None:
if not is_xattr_available():
show_toast(self, "Clearing is only available on macOS.", error=True)
return
files = self.get_files()
cleared = 0
failed = 0
for f in files:
full_path = os.path.join(self.current_dir, f)
if not os.path.isfile(full_path) or not self.can_clear_where_from(full_path):
continue
err = remove_where_from(full_path)
if not err:
cleared += 1
else:
failed += 1
show_toast(self, f"Failed to clear {os.path.basename(full_path)}: {err}", error=True)
self.populate_table()
if cleared:
show_toast(self, f"🧹 Cleared 'Where From' for {cleared} file(s).")
if failed:
show_toast(self, f"❌ Failed to clear {failed} file(s).", error=True)
if not cleared and not failed:
show_toast(self, "No files to clear.")
def get_metadata_keys_to_remove(self) -> list[str]:
return ["com.apple.metadata:kMDItemWhereFroms"]
def edit_metadata_list(self):
show_toast(self, "Editing metadata list is not available.", error=True)
if __name__ == "__main__":
app = QApplication(sys.argv)
fb = FileBrowser()
fb.show()
sys.exit(app.exec())